feat(update): centralize Metro module builds

这个提交包含在:
XuqmGroup 2026-07-18 08:59:40 +08:00
父节点 a2d3a27d80
当前提交 d677a9ee52
共有 18 个文件被更改,包括 421 次插入36 次删除

查看文件

@ -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。

查看文件

@ -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`:通过。
- common16 个测试通过。
- bugcollect4 个独立测试通过。
- common19 个测试通过。
- bugcollect5 个独立测试通过。
- xwebview4 个独立测试通过。
- update CLI9 个测试通过;release-set6 个测试通过。
- update CLI/Metro11 个测试通过;release-set6 个测试通过。
- 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 精确版本接入

查看文件

@ -10,6 +10,22 @@ type OnError = (error: unknown, meta?: Record<string, unknown>) => 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
}
}
},

查看文件

@ -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<string, unknown> }> = []
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/)
})

查看文件

@ -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']
}
/**
* / bodyheaders URL
* userIdsessionId
@ -56,3 +63,28 @@ export function createAxiosDiagnostic(error: AxiosError): SafeApiDiagnostic {
path: error.config?.url,
}
}
type ValidationCause = {
issues: z.ZodIssue[]
response: AxiosResponse<unknown>
}
function isValidationCause(value: unknown): value is ValidationCause {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<ValidationCause>
return Array.isArray(candidate.issues) && Boolean(candidate.response?.config)
}
/**
* RequestError.cause
* cause Axios configheaders
*/
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 }
}

查看文件

@ -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))
}

查看文件

@ -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'

查看文件

@ -15,7 +15,7 @@ export interface RequestOptions {
loadingDelay?: number
/** 请求标签,用于日志或错误追踪。*/
tag?: string
/** true = 在响应拦截器中打印完整响应。*/
/** true = 在响应拦截器中打印不含请求体、响应体和 headers 的安全摘要。*/
log?: boolean
}

查看文件

@ -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'

查看文件

@ -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<unknown> {
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/,
)
})

查看文件

@ -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、支持热刷新。

查看文件

@ -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,
}

查看文件

@ -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",

查看文件

@ -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 })
}
})

查看文件

@ -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) {

查看文件

@ -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' },

查看文件

@ -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)
}

查看文件

@ -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 {