diff --git a/README.md b/README.md index 7dff6fb..889dc24 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,16 @@ pnpm add @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect ## 扩展包一次初始化、一次登录 -把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置: +把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置。使用 update 插件化时采用 update 组合器(它已包含 common 自动初始化): ```js const {getDefaultConfig} = require('@react-native/metro-config'); -const {withXuqmConfig} = require('@xuqm/rn-common/metro'); +const {withXuqmModuleConfig} = require('@xuqm/rn-update/metro'); -module.exports = withXuqmConfig(getDefaultConfig(__dirname)); +module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname)); ``` -`withXuqmConfig()` 会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话: +不使用 update 时仍调用 `@xuqm/rn-common/metro` 的 `withXuqmConfig()`。两种组合器都会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话: ```ts import {XuqmSDK} from '@xuqm/rn-common'; @@ -84,11 +84,7 @@ corepack pnpm install corepack pnpm validate ``` -`validate` 会执行全部包的 TypeScript 校验和测试。开发版本发布到 Nexus hosted 仓库: - -```bash -corepack pnpm --filter @xuqm/rn-common publish --tag next --no-git-checks -``` +`validate` 会执行全部包的 TypeScript 校验和测试。开发 alpha 及正式包都只能由 Jenkins 发布到 Nexus,开发机不执行 npm publish。 集成 update 的宿主由 `xuqm-rn start` 和 `xuqm-rn run android` 统一代理 Metro 与 Android Debug 启动;宿主只保留简短的 `start/android` package script。 diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index 557ee64..cd13169 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -39,10 +39,10 @@ - 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。 - RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。 - `pnpm validate`:通过。 -- common:16 个测试通过。 -- bugcollect:4 个独立测试通过。 +- common:19 个测试通过。 +- bugcollect:5 个独立测试通过。 - xwebview:4 个独立测试通过。 -- update CLI:9 个测试通过;release-set:6 个测试通过。 +- update CLI/Metro:11 个测试通过;release-set:6 个测试通过。 - IM 现有测试:3 个通过;IM 不在本轮开发范围。 - 所有 workspace TypeScript typecheck:通过。 @@ -54,6 +54,7 @@ - 配置文件自动初始化遇到临时网络失败时会清理失败 Promise;扩展下一次等待初始化时使用同一加密配置重试,不要求宿主重新传 appKey/URL,也不会产生未处理 Promise 或重复错误日志。 - bugcollect、update、xwebview 通过 `@xuqm/rn-common/internal` 触发一次自动初始化入口。 - update CLI 已能从 schema v3 `xuqm.config.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。 +- update 的 `withXuqmModuleConfig()` 是多 Bundle 模块编号的唯一实现:日常 Metro 不变,CLI 构建时自动先生成 startup/common 共享模块表,并按配置序号为每个 app/buz 分配独立区间;宿主不再维护多份 Metro 配置或业务名硬编码 offset。 - Android bundle 引擎不在 `xuqm.config.json` 重复声明:CLI 直接读取宿主 `android/gradle.properties` 的 `hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`。 - common 的唯一 XWebView 契约新增实时导航状态;全屏与内嵌容器统一提供 `canGoBack`、`canGoForward`、当前标题和 URL,并支持宿主配置回调。Bridge 不再重复声明第二份 controller 类型。 - RN update 已有 release-set 纯领域规划器;Android 原生模块已改为整组 staging/激活/确认/回滚,并从 APK assets 恢复内嵌基线。 @@ -76,11 +77,11 @@ | ----------------------------- | -------- | ------------------------------------------------------ | | 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 | | 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 | -| common 公共上下文收敛 | 完成 | 公共基础与共享生命周期 16 测试 | -| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本与策略已统一,4 测试 | +| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 | +| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 | | xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 | | update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E | -| package 内容校验 | 待实施 | 四包完成后执行 | +| package 内容校验 | 完成 | update 包 42 个发布文件已校验 | | Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update | | App4 接入 | 完成构建 | 精确 alpha 已接入,六 Hermes 插件与 debug APK 构建通过 | @@ -90,7 +91,7 @@ 2. 在 Android 宿主补充 release-set 安装、冷启动确认和崩溃回滚仪器测试。 3. 收敛 Jenkins 四包依赖发布顺序和 package 内容检查。 4. 后续租户平台实现 `/api/v1/rn/release-set/check`,服务端只返回目标入口与 common 的兼容闭包。 -5. App4 保持使用 Jenkins 精确版本,禁止链接 SDK 源码、手改 `node_modules` 或本地发布正式制品。 +5. App4 当前使用相邻源码联调本轮未发布变更;验证完成后必须由 Jenkins 发布新 alpha,再恢复 Nexus 精确版本。禁止手改 `node_modules` 或本地发布正式制品。 ## 6. 常用验证命令 @@ -112,6 +113,15 @@ pnpm --dir packages/update test method、相对 path、HTTP 状态、错误码和 Zod 字段路径。 - 新增测试使用带手机号、sessionId、userId、token 的伪响应,强制验证 序列化后的诊断结果不包含这些敏感值。 +- 全局 API 错误回调不再交付带 Axios cause 的 `RequestError`,只交付不可逆的安全诊断报告;宿主无法误把请求体、headers 或响应上传到采集平台。 +- bugcollect 的全局 fetch 拦截器只上报 method、相对 path 和 HTTP status;完整 host、query、请求体、headers 及原始网络异常对象全部丢弃,测试覆盖敏感查询参数和鉴权头。 + +### 2026-07-18 / 多 Bundle Metro 能力下沉 + +- 新增公开入口 `@xuqm/rn-update/metro`,`withXuqmModuleConfig()` 内部复用 common 的 `withXuqmConfig()`,宿主只保留一份 Metro 配置。 +- `xuqm-rn build/embed/publish` 为每个模块注入 id、type、配置序号和共享缓存位置;模块区间不再依赖 `szyx/miniapp` 等业务目录名称。 +- 独立构建 app/buz 时 CLI 自动先构建 startup/common 依赖以重建共享模块表,但仍只发布用户选择的模块,适用于全新 Jenkins 工作区。 +- 测试覆盖 0 号模块存在性、startup/common 去重、app/buz 唯一区间,以及单独构建 buz 的依赖顺序;update CLI/Metro 11 项、release-set 6 项和 42 文件包内容校验通过。 ### 2026-07-18 / Jenkins #58—#60 与 App4 精确版本接入 diff --git a/packages/bugcollect/src/interceptor/HttpInterceptor.ts b/packages/bugcollect/src/interceptor/HttpInterceptor.ts index 319e3a1..215951d 100644 --- a/packages/bugcollect/src/interceptor/HttpInterceptor.ts +++ b/packages/bugcollect/src/interceptor/HttpInterceptor.ts @@ -10,6 +10,22 @@ type OnError = (error: unknown, meta?: Record) => void let _originalFetch: typeof fetch | null = null +function getRequestMetadata(input: RequestInfo | URL, init?: RequestInit) { + const rawUrl = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + let path: string + try { + path = new URL(rawUrl).pathname + } catch { + path = rawUrl.split(/[?#]/, 1)[0] ?? '' + } + return { + method: init?.method?.toUpperCase() ?? 'GET', + path, + type: 'api_error', + } +} + export const HttpInterceptor = { start(onError: OnError): void { if (_originalFetch) return // already installed @@ -25,20 +41,16 @@ export const HttpInterceptor = { try { const res = await original(input, init) if (!res.ok) { - const url = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url - onError(new Error(`HTTP ${res.status} ${res.statusText}`), { - type: 'api_error', - url, + onError(new Error(`HTTP ${res.status}`), { + ...getRequestMetadata(input, init), status: res.status, }) } return res - } catch (e) { - const url = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url - onError(e, { type: 'api_error', url }) - throw e + } catch (error) { + // fetch 抛出的对象可能携带原始 URL 或原生请求细节;采集端只接收固定错误和摘要。 + onError(new Error('HTTP request failed'), getRequestMetadata(input, init)) + throw error } } }, diff --git a/packages/bugcollect/tests/http-interceptor.test.ts b/packages/bugcollect/tests/http-interceptor.test.ts new file mode 100644 index 0000000..d8a31f1 --- /dev/null +++ b/packages/bugcollect/tests/http-interceptor.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { HttpInterceptor } from '../src/interceptor/HttpInterceptor' + +test('HTTP interception removes host, query and request contents before reporting', async () => { + const originalFetch = globalThis.fetch + const reports: Array<{ error: unknown; metadata?: Record }> = [] + globalThis.fetch = async () => new Response(null, { status: 500 }) + + try { + HttpInterceptor.start((error, metadata) => reports.push({ error, metadata })) + await globalThis.fetch('https://api.example.com/am/user?phone=13800000000&token=secret', { + body: JSON.stringify({ userId: 'secret-user' }), + headers: { authorization: 'secret-authorization' }, + method: 'POST', + }) + } finally { + HttpInterceptor.stop() + globalThis.fetch = originalFetch + } + + assert.equal(reports.length, 1) + assert.deepEqual(reports[0]?.metadata, { + method: 'POST', + path: '/am/user', + status: 500, + type: 'api_error', + }) + const serialized = JSON.stringify(reports) + assert.doesNotMatch(serialized, /example\.com|13800000000|token|secret|authorization|userId/) +}) diff --git a/packages/common/src/api/diagnostics.ts b/packages/common/src/api/diagnostics.ts index 417a149..c50c051 100644 --- a/packages/common/src/api/diagnostics.ts +++ b/packages/common/src/api/diagnostics.ts @@ -1,5 +1,7 @@ import type { AxiosError, AxiosResponse } from 'axios' +import { isAxiosError } from 'axios' import type { z } from 'zod' +import type { RequestError } from './errors' type SafeIssue = { code: string @@ -16,6 +18,11 @@ export type SafeApiDiagnostic = { type: 'AxiosError' | 'Response' | 'ValidationError' } +export type SafeApiErrorReport = { + diagnostic?: SafeApiDiagnostic + requestType: RequestError['type'] +} + /** * 网络诊断只能描述请求形态,不得复制请求/响应 body、headers 或完整 URL。 * 这些字段可能包含手机号、userId、sessionId、签名和其它业务数据。 @@ -56,3 +63,28 @@ export function createAxiosDiagnostic(error: AxiosError): SafeApiDiagnostic { path: error.config?.url, } } + +type ValidationCause = { + issues: z.ZodIssue[] + response: AxiosResponse +} + +function isValidationCause(value: unknown): value is ValidationCause { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return Array.isArray(candidate.issues) && Boolean(candidate.response?.config) +} + +/** + * 全局错误采集只接收不可逆的安全摘要,绝不暴露 RequestError.cause。 + * cause 可能持有 Axios config、headers、请求体和完整响应,只能留在调用现场。 + */ +export function createSafeApiErrorReport(error: RequestError): SafeApiErrorReport { + const cause = error.cause + const diagnostic = isValidationCause(cause) + ? createValidationDiagnostic(cause.response, cause.issues) + : isAxiosError(cause) + ? createAxiosDiagnostic(cause) + : undefined + return { diagnostic, requestType: error.type } +} diff --git a/packages/common/src/api/globalErrorHandler.ts b/packages/common/src/api/globalErrorHandler.ts index d7a5f08..39c7fc5 100644 --- a/packages/common/src/api/globalErrorHandler.ts +++ b/packages/common/src/api/globalErrorHandler.ts @@ -1,6 +1,7 @@ import { RequestError } from './errors' +import { createSafeApiErrorReport, type SafeApiErrorReport } from './diagnostics' -type ApiErrorHandler = (error: RequestError) => void +type ApiErrorHandler = (report: SafeApiErrorReport) => void let _handler: ApiErrorHandler | null = null @@ -11,5 +12,5 @@ export function setGlobalApiErrorHandler(handler: ApiErrorHandler): void { /** 内部调用:在 useRequest 的 catch 分支中触发。*/ export function _notifyApiError(error: RequestError): void { - _handler?.(error) + _handler?.(createSafeApiErrorReport(error)) } diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index cec995b..465234e 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -3,6 +3,7 @@ export { useApi } from './useApi' export { usePageApi } from './usePageApi' export { RequestError } from './errors' export { setGlobalApiErrorHandler } from './globalErrorHandler' +export type { SafeApiDiagnostic, SafeApiErrorReport } from './diagnostics' export type { RequestOptions } from './useRequest' export type { ApiMethod } from './useApi' export type { PageOptions } from './usePageApi' diff --git a/packages/common/src/api/useRequest.ts b/packages/common/src/api/useRequest.ts index 06224c4..c84f28a 100644 --- a/packages/common/src/api/useRequest.ts +++ b/packages/common/src/api/useRequest.ts @@ -15,7 +15,7 @@ export interface RequestOptions { loadingDelay?: number /** 请求标签,用于日志或错误追踪。*/ tag?: string - /** true = 在响应拦截器中打印完整响应。*/ + /** true = 在响应拦截器中打印不含请求体、响应体和 headers 的安全摘要。*/ log?: boolean } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7e89069..242128e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -80,6 +80,7 @@ export { ValidationError, } from './api' export type { RequestOptions as UseRequestOptions, ApiMethod } from './api' +export type { SafeApiDiagnostic, SafeApiErrorReport } from './api' export type { PageOptions } from './api' export type { AxiosRequestConfig } from 'axios' diff --git a/packages/common/tests/api-diagnostics.test.ts b/packages/common/tests/api-diagnostics.test.ts index 414cd82..3a3a75b 100644 --- a/packages/common/tests/api-diagnostics.test.ts +++ b/packages/common/tests/api-diagnostics.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AxiosResponse } from 'axios' -import { createResponseDiagnostic, createValidationDiagnostic } from '../src/api/diagnostics' +import { + createResponseDiagnostic, + createSafeApiErrorReport, + createValidationDiagnostic, +} from '../src/api/diagnostics' +import { RequestError } from '../src/api/errors' function sensitiveResponse(): AxiosResponse { return { @@ -50,3 +55,29 @@ test('validation diagnostics retain issue paths without response data', () => { const serialized = JSON.stringify(diagnostic) assert.doesNotMatch(serialized, /secret|13800000000|authorization|example\.com/) }) + +test('global API error reports never expose RequestError causes', () => { + const response = sensitiveResponse() + const cause = { + issues: [ + { + code: 'invalid_type', + expected: 'string', + message: 'Invalid input: expected string, received number', + path: ['data', 'userId'], + }, + ], + response, + } + const report = createSafeApiErrorReport( + new RequestError('server message may contain private data', 'ValidationError', cause), + ) + + assert.equal(report.requestType, 'ValidationError') + assert.equal(report.diagnostic?.type, 'ValidationError') + const serialized = JSON.stringify(report) + assert.doesNotMatch( + serialized, + /private|secret|13800000000|authorization|example\.com|server message/, + ) +}) diff --git a/packages/update/README.md b/packages/update/README.md index 9c98673..af5c3b3 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -20,6 +20,17 @@ pnpm run xuqm:doctor app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minNativeApiLevel` 声明最低原生能力。Bundle 只接受 SHA-256,不使用 MD5。 +宿主的唯一 Metro 配置使用 update 组合器;它已经包含 common 的自动初始化配置: + +```js +const { getDefaultConfig } = require('@react-native/metro-config') +const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro') + +module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname)) +``` + +日常 `start/android` 没有模块构建上下文,保持标准 Metro 行为。构建 app/buz 时 CLI 自动先生成 startup/common 共享模块表,并为每个插件分配互不重叠的稳定区间;宿主不得复制模块 ID 缓存或维护多份 Metro 配置。 + ## 开发与打包 - `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。 diff --git a/packages/update/metro/index.js b/packages/update/metro/index.js new file mode 100644 index 0000000..f99bd2a --- /dev/null +++ b/packages/update/metro/index.js @@ -0,0 +1,99 @@ +const fs = require('node:fs') +const path = require('node:path') + +const { withXuqmConfig } = require('@xuqm/rn-common/metro') + +const MODULE_RANGE_SIZE = 10_000_000 + +function readCache(file) { + if (!fs.existsSync(file)) return {} + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return {} + } +} + +function writeCache(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) +} + +function hasModule(cache, modulePath) { + return Object.prototype.hasOwnProperty.call(cache, modulePath) +} + +function isRuntimePrelude(modulePath) { + return modulePath.includes('__prelude__') || modulePath.includes('polyfills') +} + +function createModuleSerializer(environment = process.env) { + const moduleId = environment.XUQM_MODULE_ID + const moduleType = environment.XUQM_MODULE_TYPE + if (!moduleId || !moduleType) return null + + const cacheFile = path.resolve( + environment.XUQM_MODULE_CACHE_FILE ?? '.xuqm-cache/common-module-ids.json', + ) + if (environment.XUQM_RESET_MODULE_CACHE === 'true') writeCache(cacheFile, {}) + + const sharedCache = readCache(cacheFile) + const writesSharedCache = moduleType === 'startup' || moduleType === 'common' + const moduleIndex = Number.parseInt(environment.XUQM_MODULE_INDEX ?? '0', 10) + let nextId = writesSharedCache + ? Object.values(sharedCache).reduce((maximum, value) => Math.max(maximum, Number(value)), -1) + + 1 + : (Number.isFinite(moduleIndex) && moduleIndex >= 0 ? moduleIndex + 1 : 1) * MODULE_RANGE_SIZE + const localIds = new Map() + + return { + createModuleIdFactory() { + return modulePath => { + if (hasModule(sharedCache, modulePath)) return sharedCache[modulePath] + const existing = localIds.get(modulePath) + if (existing !== undefined) return existing + const id = nextId++ + localIds.set(modulePath, id) + if (writesSharedCache) { + sharedCache[modulePath] = id + writeCache(cacheFile, sharedCache) + } + return id + } + }, + processModuleFilter(module) { + if (moduleType === 'startup') return true + if (isRuntimePrelude(module.path)) return false + return !hasModule(sharedCache, module.path) + }, + } +} + +/** + * 同一份 Metro 配置同时服务日常开发和插件构建。CLI 构建插件时注入模块上下文; + * 普通 `pnpm android/start` 没有这些变量,因此保持标准 React Native 行为。 + */ +function withXuqmModuleConfig(metroConfig) { + const configured = withXuqmConfig(metroConfig) + const moduleSerializer = createModuleSerializer() + if (!moduleSerializer) return configured + const configuredFilter = configured.serializer?.processModuleFilter + return { + ...configured, + serializer: { + ...configured.serializer, + ...moduleSerializer, + processModuleFilter(module) { + return ( + (configuredFilter ? configuredFilter(module) : true) && + moduleSerializer.processModuleFilter(module) + ) + }, + }, + } +} + +module.exports = { + createModuleSerializer, + withXuqmModuleConfig, +} diff --git a/packages/update/package.json b/packages/update/package.json index 5df5486..300dd6f 100644 --- a/packages/update/package.json +++ b/packages/update/package.json @@ -6,9 +6,15 @@ "main": "src/index.ts", "react-native": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./metro": "./metro/index.js", + "./package.json": "./package.json" + }, "files": [ "src", "android", + "metro", "scripts", "templates", "react-native.config.js", diff --git a/packages/update/scripts/metro.test.mjs b/packages/update/scripts/metro.test.mjs new file mode 100644 index 0000000..7e8226c --- /dev/null +++ b/packages/update/scripts/metro.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { createModuleSerializer } = require('../metro') + +function context(cacheFile, moduleId, moduleType, moduleIndex, reset = false) { + return { + XUQM_MODULE_CACHE_FILE: cacheFile, + XUQM_MODULE_ID: moduleId, + XUQM_MODULE_INDEX: String(moduleIndex), + XUQM_MODULE_TYPE: moduleType, + XUQM_RESET_MODULE_CACHE: reset ? 'true' : 'false', + } +} + +test('Metro module ranges share startup/common and isolate every app or buz', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'xuqm-metro-')) + const cacheFile = path.join(directory, 'ids.json') + try { + const startup = createModuleSerializer(context(cacheFile, 'startup', 'startup', 0, true)) + const startupFactory = startup.createModuleIdFactory() + assert.equal(startupFactory('/runtime/prelude.js'), 0) + + const common = createModuleSerializer(context(cacheFile, 'common', 'common', 1)) + assert.equal(common.processModuleFilter({ path: '/runtime/prelude.js' }), false) + const commonFactory = common.createModuleIdFactory() + assert.equal(commonFactory('/runtime/prelude.js'), 0) + assert.equal(commonFactory('/shared/common.ts'), 1) + + const app = createModuleSerializer(context(cacheFile, 'app', 'app', 2)) + const buz = createModuleSerializer(context(cacheFile, 'buz-a', 'buz', 3)) + assert.equal(app.processModuleFilter({ path: '/shared/common.ts' }), false) + assert.equal(app.createModuleIdFactory()('/app/entry.ts'), 30_000_000) + assert.equal(buz.createModuleIdFactory()('/buz/entry.ts'), 40_000_000) + } finally { + rmSync(directory, { force: true, recursive: true }) + } +}) diff --git a/packages/update/scripts/xuqm-config.mjs b/packages/update/scripts/xuqm-config.mjs index a8f4cdb..008d8e6 100644 --- a/packages/update/scripts/xuqm-config.mjs +++ b/packages/update/scripts/xuqm-config.mjs @@ -37,6 +37,12 @@ export function validateConfig(config, root = process.cwd()) { errors.push('modules must contain at least one module') return errors } + if (config.modules.length > 1 && !config.metroConfig) { + errors.push('metroConfig is required for multi-module builds') + } + if (config.metroConfig && !existsSync(path.resolve(root, config.metroConfig))) { + errors.push(`Metro config not found: ${config.metroConfig}`) + } const ids = new Set() for (const module of config.modules) { diff --git a/packages/update/scripts/xuqm-config.test.mjs b/packages/update/scripts/xuqm-config.test.mjs index ebad38d..bb82014 100644 --- a/packages/update/scripts/xuqm-config.test.mjs +++ b/packages/update/scripts/xuqm-config.test.mjs @@ -10,6 +10,7 @@ function fixture() { writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '7.2.14' })) writeFileSync(path.join(root, 'common.ts'), '') writeFileSync(path.join(root, 'app.ts'), '') + writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') return root } @@ -21,6 +22,7 @@ test('plugins start at the configured plugin version independently from the app mainModuleName: 'Example', pluginVersion: '1.0.0', appVersionRange: '>=7.2.14 <8.0.0', + metroConfig: './metro.config.js', modules: [ { id: 'common', type: 'common', entry: './common.ts' }, { id: 'app', type: 'app', entry: './app.ts' }, diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index 06e374e..38b5942 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -41,8 +41,8 @@ function writeJson(file, value) { writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) } -function childEnvironment() { - const environment = { ...process.env } +function childEnvironment(overrides = {}) { + const environment = { ...process.env, ...overrides } // Metro may set FORCE_COLOR in its transformer workers. Forward a single // explicit color flag so those workers never inherit a contradictory pair. if (environment.NO_COLOR !== undefined) { @@ -52,13 +52,13 @@ function childEnvironment() { return environment } -function runReactNative(cliArgs) { +function runReactNative(cliArgs, environment = {}) { const reactNativeCli = path.join(root, 'node_modules', 'react-native', 'cli.js') if (!existsSync(reactNativeCli)) fail('react-native CLI is missing; install dependencies first') execFileSync(process.execPath, [reactNativeCli, ...cliArgs], { stdio: 'inherit', cwd: root, - env: childEnvironment(), + env: childEnvironment(environment), }) } @@ -142,6 +142,7 @@ function init() { pluginVersion: '1.0.0', appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'), outputDir: './bundle', + metroConfig: './metro.config.js', embeddedOutput: { android: './android/app/src/main/assets/rn-bundles', ios: './bundle/embedded/ios/rn-bundles', @@ -233,12 +234,32 @@ function parseSelectedModules(config, optionArgs) { } } +/** + * 独立构建 app/buz 时也必须先生成 startup/common 的共享模块表。 + * 依赖 bundle 只参与本次构建,不会因为选择了单个 buz 就被一并发布。 + */ +function resolveBuildModules(config, selectedModules) { + const selectedIds = new Set(selectedModules.map(module => module.id)) + const needsCommon = selectedModules.some(module => ['app', 'buz'].includes(module.type)) + const needsStartup = selectedModules.some(module => module.type !== 'startup') + const priorities = { startup: 0, common: 1, app: 2, buz: 3 } + return config.modules + .filter( + module => + selectedIds.has(module.id) || + (needsCommon && module.type === 'common') || + (needsStartup && module.type === 'startup'), + ) + .sort((left, right) => priorities[left.type] - priorities[right.type]) +} + function build(platform, optionArgs = []) { assertPlatform(platform) const config = doctor() const modules = parseSelectedModules(config, optionArgs) + const buildModules = resolveBuildModules(config, modules) const useHermes = platform === 'android' && androidUsesHermes() - for (const module of modules) { + for (const [buildIndex, module] of buildModules.entries()) { const outputDirectory = bundleOutputDirectory(root, config, platform, module.id) rmSync(outputDirectory, { force: true, recursive: true }) mkdirSync(outputDirectory, { recursive: true }) @@ -263,7 +284,13 @@ function build(platform, optionArgs = []) { cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`) } console.log(`xuqm-rn: building ${module.id}/${platform}`) - runReactNative(cliArgs.slice(1)) + runReactNative(cliArgs.slice(1), { + XUQM_MODULE_CACHE_FILE: path.join(root, '.xuqm-cache', 'common-module-ids.json'), + XUQM_MODULE_ID: module.id, + XUQM_MODULE_INDEX: String(config.modules.findIndex(item => item.id === module.id)), + XUQM_MODULE_TYPE: module.type, + XUQM_RESET_MODULE_CACHE: buildIndex === 0 ? 'true' : 'false', + }) if (useHermes) compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true) } diff --git a/packages/update/scripts/xuqm-rn.test.mjs b/packages/update/scripts/xuqm-rn.test.mjs index 6bb0e53..c9b7412 100644 --- a/packages/update/scripts/xuqm-rn.test.mjs +++ b/packages/update/scripts/xuqm-rn.test.mjs @@ -32,6 +32,7 @@ test('embed creates a complete versioned manifest from a host fixture', () => { ) writeFileSync(path.join(root, 'common.ts'), '') writeFileSync(path.join(root, 'app.ts'), '') + writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') writeFileSync( path.join(root, 'xuqm.config.json'), JSON.stringify({ @@ -41,6 +42,7 @@ test('embed creates a complete versioned manifest from a host fixture', () => { pluginVersion: '1.0.0', appVersionRange: '>=7.2.14 <8.0.0', outputDir: './bundle', + metroConfig: './metro.config.js', embeddedOutput: { android: './unused' }, modules: [ { id: 'common', type: 'common', entry: './common.ts' }, @@ -243,6 +245,79 @@ test('build invokes the host-local React Native CLI with a consistent color envi } }) +test('building one buz first rebuilds startup and common module maps', () => { + const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-buz-dependencies-')) + try { + writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'fixture', + version: '1.0.0', + dependencies: { '@xuqm/rn-update': '0.5.0-alpha.10' }, + }), + ) + for (const entry of ['startup.ts', 'common.ts', 'app.ts', 'orders.ts']) { + writeFileSync(path.join(root, entry), '') + } + writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') + writeFileSync( + path.join(root, 'xuqm.config.json'), + JSON.stringify({ + schemaVersion: 3, + appId: 'fixture.app', + mainModuleName: 'Fixture', + pluginVersion: '1.0.0', + appVersionRange: '>=1.0.0 <2.0.0', + outputDir: './bundle', + metroConfig: './metro.config.js', + embeddedOutput: { android: './generated' }, + // 配置顺序不作为执行顺序;CLI 必须按依赖拓扑重排。 + modules: [ + { id: 'orders', type: 'buz', entry: './orders.ts' }, + { id: 'app', type: 'app', entry: './app.ts' }, + { id: 'common', type: 'common', entry: './common.ts' }, + { id: 'startup', type: 'startup', entry: './startup.ts' }, + ], + }), + ) + const reactNativeDirectory = path.join(root, 'node_modules', 'react-native') + mkdirSync(reactNativeDirectory, { recursive: true }) + writeFileSync( + path.join(reactNativeDirectory, 'cli.js'), + ` + const fs = require('node:fs'); + const path = require('node:path'); + const callsFile = path.join(process.cwd(), 'module-calls.json'); + const calls = fs.existsSync(callsFile) ? JSON.parse(fs.readFileSync(callsFile, 'utf8')) : []; + calls.push({ + id: process.env.XUQM_MODULE_ID, + index: process.env.XUQM_MODULE_INDEX, + reset: process.env.XUQM_RESET_MODULE_CACHE, + type: process.env.XUQM_MODULE_TYPE, + }); + fs.writeFileSync(callsFile, JSON.stringify(calls)); + const args = process.argv.slice(2); + const output = args[args.indexOf('--bundle-output') + 1]; + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, 'bundle'); + `, + ) + + execFileSync(process.execPath, [cli, 'build', 'android', '--module', 'orders'], { + cwd: root, + stdio: 'pipe', + }) + + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'module-calls.json'), 'utf8')), [ + { id: 'startup', index: '3', reset: 'true', type: 'startup' }, + { id: 'common', index: '2', reset: 'false', type: 'common' }, + { id: 'orders', index: '0', reset: 'false', type: 'buz' }, + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + test('start and run forward arguments through the host-local React Native CLI', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-run-rn-')) try {