From 4a00fc39f0312ddf9431c96731b87a08a488c6fa Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Jul 2026 16:32:38 +0800 Subject: [PATCH] fix(update): verify debug Metro project identity --- docs/IMPLEMENTATION_HANDOFF.md | 10 ++++ packages/update/README.md | 5 ++ packages/update/metro/index.js | 29 +++++++++- packages/update/scripts/metro.test.mjs | 31 +++++++++- packages/update/scripts/xuqm-rn.mjs | 72 +++++++++++++++++++++++- packages/update/scripts/xuqm-rn.test.mjs | 23 ++++++-- 6 files changed, 159 insertions(+), 11 deletions(-) diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index f714fa8..92375d9 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -484,3 +484,13 @@ pnpm --dir packages/update test - 工作区内 18 个 XuqmGroup 子仓库的本地 `origin` 也已改为 SSH,审计结果不再存在 `https://user:token@host` 形式。旧访问令牌曾出现在命令审计输出,必须在 Gitea 轮换;本文不记录其值。 + +### 2026-07-27 / Debug Metro 工程身份约束 + +- `withXuqmModuleConfig()` 新增仅供本机开发链路读取的 + `/xuqm/metro-info` 工程标识;它不进入 Release bundle,也不承载业务或凭据信息。 +- `xuqm-rn run android/ios` 在复用已占用端口前校验工程绝对路径。其他工程的 + Metro 或不支持该协议的旧 Metro 会被明确拒绝,避免 React Native CLI 仅凭 + “packager running” 静默加载错误工程或陈旧代码。 +- 当前工程的标准 Metro 继续直接提供 startup/common/app/buz 源码与 Fast Refresh; + Debug 不执行插件构建、解压或安装。单元测试覆盖工程标识响应与 CLI 参数转发。 diff --git a/packages/update/README.md b/packages/update/README.md index fcaa231..20d89a4 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -53,6 +53,11 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname)) - `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。 - `pnpm publish:android`:上传插件产物;SDK npm 制品发布只能通过 Jenkins。 +`withXuqmModuleConfig()` 会让开发服务器公开当前工程标识;`xuqm-rn run` 在复用 +已有 Metro 前必须校验该标识。若端口属于其他工程或不支持标识校验的旧服务,命令 +会明确失败并要求重启 Metro,禁止静默连接后运行错误 bundle。确认属于当前工程后 +仍完全使用标准 Metro 与 Fast Refresh,不生成或读取 Debug 插件包。 + 发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于 `xuqm.modules.json` 或 `config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据 进入源码、插件包或构建日志。 diff --git a/packages/update/metro/index.js b/packages/update/metro/index.js index fbab9b4..04a29bf 100644 --- a/packages/update/metro/index.js +++ b/packages/update/metro/index.js @@ -80,10 +80,35 @@ function createModuleSerializer(environment = process.env) { function withXuqmModuleConfig(metroConfig) { const configured = withXuqmConfig(metroConfig) const moduleSerializer = createModuleSerializer() - if (!moduleSerializer) return configured const configuredFilter = configured.serializer?.processModuleFilter - return { + const configuredEnhanceMiddleware = configured.server?.enhanceMiddleware + const result = { ...configured, + server: { + ...configured.server, + enhanceMiddleware(middleware, metroServer) { + const nextMiddleware = configuredEnhanceMiddleware + ? configuredEnhanceMiddleware(middleware, metroServer) + : middleware + return (request, response, next) => { + if (request.url === '/xuqm/metro-info') { + response.setHeader('Content-Type', 'application/json; charset=utf-8') + response.end( + JSON.stringify({ + projectRoot: path.resolve(process.cwd()), + protocolVersion: 1, + }), + ) + return + } + nextMiddleware(request, response, next) + } + }, + }, + } + if (!moduleSerializer) return result + return { + ...result, serializer: { ...configured.serializer, ...moduleSerializer, diff --git a/packages/update/scripts/metro.test.mjs b/packages/update/scripts/metro.test.mjs index 19aec71..ae3a829 100644 --- a/packages/update/scripts/metro.test.mjs +++ b/packages/update/scripts/metro.test.mjs @@ -6,7 +6,7 @@ import path from 'node:path' import test from 'node:test' const require = createRequire(import.meta.url) -const { createModuleSerializer } = require('../metro') +const { createModuleSerializer, withXuqmModuleConfig } = require('../metro') function context(cacheFile, moduleId, moduleType, moduleIndex, reset = false) { return { @@ -46,3 +46,32 @@ test('Metro module ranges share startup/common and isolate every app or buz', () rmSync(directory, { force: true, recursive: true }) } }) + +test('Metro exposes the current project identity for safe debug reuse', () => { + const configured = withXuqmModuleConfig({}) + const middleware = configured.server.enhanceMiddleware((_request, _response, next) => next(), {}) + let contentType = '' + let responseBody = '' + let delegated = false + middleware( + { url: '/xuqm/metro-info' }, + { + setHeader(name, value) { + if (name === 'Content-Type') contentType = value + }, + end(value) { + responseBody = value + }, + }, + () => { + delegated = true + }, + ) + + assert.equal(delegated, false) + assert.equal(contentType, 'application/json; charset=utf-8') + assert.deepEqual(JSON.parse(responseBody), { + projectRoot: path.resolve(process.cwd()), + protocolVersion: 1, + }) +}) diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index 9f7e28d..ce4dc8d 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -13,6 +13,7 @@ import { writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' +import http from 'node:http' import path from 'node:path' import process from 'node:process' import { strToU8, zipSync } from 'fflate' @@ -663,8 +664,75 @@ function startMetro(optionArgs) { runReactNative(['start', ...optionArgs]) } -function runApp(platform, optionArgs) { +function optionValue(optionArgs, name) { + const index = optionArgs.indexOf(name) + return index >= 0 ? optionArgs[index + 1] : undefined +} + +function readMetroIdentity(port) { + return new Promise(resolve => { + const request = http.get( + { + host: '127.0.0.1', + path: '/xuqm/metro-info', + port, + timeout: 800, + }, + response => { + let body = '' + response.setEncoding('utf8') + response.on('data', chunk => { + body += chunk + }) + response.on('end', () => { + if (response.statusCode !== 200) { + resolve({ kind: 'unmanaged' }) + return + } + try { + const value = JSON.parse(body) + resolve( + typeof value?.projectRoot === 'string' + ? { kind: 'managed', projectRoot: path.resolve(value.projectRoot) } + : { kind: 'unmanaged' }, + ) + } catch { + resolve({ kind: 'unmanaged' }) + } + }) + }, + ) + request.on('timeout', () => request.destroy()) + request.on('error', error => { + resolve( + error?.code === 'ECONNREFUSED' || error?.code === 'ECONNRESET' + ? { kind: 'absent' } + : { kind: 'unmanaged' }, + ) + }) + }) +} + +async function assertMetroBelongsToCurrentProject(optionArgs) { + if (optionArgs.includes('--no-packager')) return + const parsedPort = Number.parseInt(optionValue(optionArgs, '--port') ?? '8081', 10) + const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 8081 + const identity = await readMetroIdentity(port) + if (identity.kind === 'absent') return + if (identity.kind === 'managed' && identity.projectRoot === path.resolve(root)) return + if (identity.kind === 'managed') { + fail( + `Metro port ${port} belongs to ${identity.projectRoot}; stop it or choose another --port before running this project`, + ) + } + fail( + `Metro port ${port} is occupied by an unidentified or outdated server; restart it with \`xuqm-rn start --reset-cache\``, + ) +} + +async function runApp(platform, optionArgs) { assertPlatform(platform) + await assertMetroBelongsToCurrentProject(optionArgs) runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs]) } @@ -705,7 +773,7 @@ try { startMetro(args) break case 'run': - runApp(args.shift() ?? 'android', args) + await runApp(args.shift() ?? 'android', args) break case 'build': build(args.shift() ?? 'android', args) diff --git a/packages/update/scripts/xuqm-rn.test.mjs b/packages/update/scripts/xuqm-rn.test.mjs index ecfca6c..0932dea 100644 --- a/packages/update/scripts/xuqm-rn.test.mjs +++ b/packages/update/scripts/xuqm-rn.test.mjs @@ -552,15 +552,26 @@ test('start and run forward arguments through the host-local React Native CLI', env: environment, stdio: 'pipe', }) - execFileSync(process.execPath, [cli, 'run', 'android', '--device', 'emulator-5554'], { - cwd: root, - env: environment, - stdio: 'pipe', - }) + execFileSync( + process.execPath, + [cli, 'run', 'android', '--device', 'emulator-5554', '--port', '45999', '--no-packager'], + { + cwd: root, + env: environment, + stdio: 'pipe', + }, + ) const calls = JSON.parse(readFileSync(path.join(root, 'calls.json'), 'utf8')) assert.deepEqual(calls[0].args, ['start', '--reset-cache']) - assert.deepEqual(calls[1].args, ['run-android', '--device', 'emulator-5554']) + assert.deepEqual(calls[1].args, [ + 'run-android', + '--device', + 'emulator-5554', + '--port', + '45999', + '--no-packager', + ]) assert.equal(calls[0].forceColor, '0') assert.equal(calls[1].forceColor, '0') assert.equal(calls[0].noColor, null)