feat(update): centralize Metro module builds
这个提交包含在:
父节点
a2d3a27d80
当前提交
d677a9ee52
14
README.md
14
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。
|
||||
|
||||
|
||||
@ -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 精确版本接入
|
||||
|
||||
|
||||
@ -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']
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络诊断只能描述请求形态,不得复制请求/响应 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<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 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 }
|
||||
}
|
||||
|
||||
@ -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、支持热刷新。
|
||||
|
||||
99
packages/update/metro/index.js
普通文件
99
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,
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户