refactor(update): generate host entries and unify project checks

这个提交包含在:
XuqmGroup 2026-07-29 10:44:56 +08:00
父节点 28a636821a
当前提交 8e61fe7857
共有 12 个文件被更改,包括 710 次插入16 次删除

查看文件

@ -6,11 +6,15 @@
```bash ```bash
pnpm exec xuqm-rn init pnpm exec xuqm-rn init
pnpm xuqm:doctor pnpm check
``` ```
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。 `init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。
startup/common/app 与 Debug 插件的可重建胶水入口统一生成到
`.xuqm/generated/entries`。该目录必须加入 `.gitignore`,更换设备或主动删除后由
CLI/Metro 自动恢复;项目业务 bootstrap、页面和插件源码仍正常提交。
构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId` 构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId`
保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。 保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。
ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析; ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析;
@ -42,28 +46,27 @@ pnpm exec xuqm-rn plugin create prescription
- 只包含小写字母、数字和连字符 - 只包含小写字母、数字和连字符
- 在 `xuqm.modules.json` 中唯一 - 在 `xuqm.modules.json` 中唯一
CLI 会生成: CLI 会生成最小插件入口
```text ```text
src/plugins/prescription/ src/plugins/prescription/
bundle.ts bundle.ts
PrescriptionScreen.tsx
``` ```
并向 `xuqm.modules.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。 并向 `xuqm.modules.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
## 插件入口 ## 插件入口
生成的入口使用统一运行时定义: 生成的入口使用统一运行时定义,项目随后在该插件目录内实现自己的页面与导航
```ts ```ts
import { definePlugin } from '@xuqm/rn-update' import { definePlugin } from '@xuqm/rn-update'
import { PrescriptionScreen } from './PrescriptionScreen'
definePlugin({ definePlugin({
id: 'prescription', moduleId: 'prescription',
Component: PrescriptionScreen, activate(context) {
context.emit('plugin:ready', { moduleId: 'prescription' })
},
}) })
``` ```
@ -80,7 +83,7 @@ pnpm exec xuqm-rn publish android --module prescription
构建全部模块并嵌入宿主: 构建全部模块并嵌入宿主:
```bash ```bash
pnpm build:android pnpm exec xuqm-rn embed android
``` ```
生成 Android Release 安装包: 生成 Android Release 安装包:
@ -99,3 +102,4 @@ Release 构建会先重新生成全部插件,不使用仓库内历史 bundle
- 不得逐层传递 SDK 初始化、用户、token 或宿主运行时参数。 - 不得逐层传递 SDK 初始化、用户、token 或宿主运行时参数。
- 共通逻辑放在 common 或 update 运行时,不复制到各插件。 - 共通逻辑放在 common 或 update 运行时,不复制到各插件。
- 插件发布前必须通过 `xuqm-rn doctor`、宿主测试和 Release 构建。 - 插件发布前必须通过 `xuqm-rn doctor`、宿主测试和 Release 构建。
- `.xuqm` 不得保存人工源码或发布状态;删除后必须能够完整重建。

查看文件

@ -10,7 +10,7 @@ Android 整包更新与 RN 多 Bundle 插件运行时。它依赖 `@xuqm/rn-comm
pnpm add @xuqm/rn-common @xuqm/rn-update \ pnpm add @xuqm/rn-common @xuqm/rn-update \
@react-native-async-storage/async-storage @react-native-async-storage/async-storage
pnpm exec xuqm-rn init pnpm exec xuqm-rn init
pnpm run xuqm:doctor pnpm check
``` ```
`xuqm-rn init` 生成 schema v3 `xuqm.modules.json` 并补充最少脚本。版本只有两条明确来源: `xuqm-rn init` 生成 schema v3 `xuqm.modules.json` 并补充最少脚本。版本只有两条明确来源:
@ -44,6 +44,11 @@ const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname)) module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
``` ```
启用自动入口生成的宿主把 startup/common/app 与 Debug 插件胶水写入
`.xuqm/generated`。该目录被 Git 忽略且不保存权威状态;`start`、`run`、`build`、
`package`、`doctor`、`check` 以及 Metro 配置都会在需要时幂等重建。业务启动逻辑仍
位于宿主受版本控制源码中,不会被隐藏到生成目录。
日常 `start/android` 没有模块构建上下文,保持标准 Metro 行为。构建 app/buz 时 CLI 自动先生成 startup/common 共享模块表,并为每个插件分配互不重叠的稳定区间;宿主不得复制模块 ID 缓存或维护多份 Metro 配置。 日常 `start/android` 没有模块构建上下文,保持标准 Metro 行为。构建 app/buz 时 CLI 自动先生成 startup/common 共享模块表,并为每个插件分配互不重叠的稳定区间;宿主不得复制模块 ID 缓存或维护多份 Metro 配置。
## 开发与打包 ## 开发与打包
@ -51,7 +56,8 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
- `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。 - `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。
- `pnpm release:android`:重新构建并内嵌全部 startup/common/app/buz 后生成 Release AAB。 - `pnpm release:android`:重新构建并内嵌全部 startup/common/app/buz 后生成 Release AAB。
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。 - `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
- `pnpm publish:android`上传插件产物;SDK npm 制品发布只能通过 Jenkins。 - `pnpm exec xuqm-rn publish android --module <id>`:供受控 CI 上传插件产物;
SDK npm 制品发布只能通过 Jenkins。
`withXuqmModuleConfig()` 会让开发服务器公开当前工程标识;`xuqm-rn run` 在复用 `withXuqmModuleConfig()` 会让开发服务器公开当前工程标识;`xuqm-rn run` 在复用
已有 Metro 前必须校验该标识。若端口属于其他工程或不支持标识校验的旧服务,命令 已有 Metro 前必须校验该标识。若端口属于其他工程或不支持标识校验的旧服务,命令

查看文件

@ -2,6 +2,7 @@ const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const { withXuqmConfig } = require('@xuqm/rn-common/metro') const { withXuqmConfig } = require('@xuqm/rn-common/metro')
const { ensureGeneratedEntries } = require('../scripts/generated-entries.cjs')
const MODULE_RANGE_SIZE = 10_000_000 const MODULE_RANGE_SIZE = 10_000_000
@ -77,7 +78,9 @@ function createModuleSerializer(environment = process.env) {
* 同一份 Metro 配置同时服务日常开发和插件构建CLI 构建插件时注入模块上下文 * 同一份 Metro 配置同时服务日常开发和插件构建CLI 构建插件时注入模块上下文
* 普通 `pnpm android/start` 没有这些变量因此保持标准 React Native 行为 * 普通 `pnpm android/start` 没有这些变量因此保持标准 React Native 行为
*/ */
function withXuqmModuleConfig(metroConfig) { function withXuqmModuleConfig(metroConfig, options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? process.cwd())
ensureGeneratedEntries(projectRoot)
const configured = withXuqmConfig(metroConfig) const configured = withXuqmConfig(metroConfig)
const moduleSerializer = createModuleSerializer() const moduleSerializer = createModuleSerializer()
const configuredFilter = configured.serializer?.processModuleFilter const configuredFilter = configured.serializer?.processModuleFilter
@ -95,7 +98,7 @@ function withXuqmModuleConfig(metroConfig) {
response.setHeader('Content-Type', 'application/json; charset=utf-8') response.setHeader('Content-Type', 'application/json; charset=utf-8')
response.end( response.end(
JSON.stringify({ JSON.stringify({
projectRoot: path.resolve(process.cwd()), projectRoot,
protocolVersion: 1, protocolVersion: 1,
}), }),
) )

查看文件

@ -12,12 +12,14 @@
"./plugin-assets": "./src/PluginAssets.ts", "./plugin-assets": "./src/PluginAssets.ts",
"./plugin-registry": "./src/PluginRegistry.ts", "./plugin-registry": "./src/PluginRegistry.ts",
"./metro": "./metro/index.js", "./metro": "./metro/index.js",
"./schema/xuqm.modules.schema.json": "./schema/xuqm.modules.schema.json",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"files": [ "files": [
"src", "src",
"android", "android",
"metro", "metro",
"schema",
"scripts", "scripts",
"templates", "templates",
"react-native.config.js", "react-native.config.js",

查看文件

@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://dev.xuqinmin.com/schema/xuqm.modules.schema.json",
"title": "Xuqm RN 模块配置",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"appId",
"packageName",
"mainModuleName",
"pluginVersion",
"appVersionRange",
"modules"
],
"properties": {
"$schema": { "type": "string" },
"schemaVersion": { "const": 3 },
"appId": { "type": "string", "minLength": 1 },
"packageName": { "type": "string", "minLength": 1 },
"iosBundleId": { "type": "string", "minLength": 1 },
"mainModuleName": { "type": "string", "minLength": 1 },
"pluginVersion": { "type": "string" },
"appVersionRange": { "type": "string" },
"manifestVersion": { "type": "integer", "minimum": 1 },
"outputDir": { "type": "string" },
"metroConfig": { "type": "string" },
"entryGeneration": {
"type": "object",
"additionalProperties": false,
"required": ["appComponent", "startupRegistry", "startupShell", "debugPluginLoader"],
"properties": {
"appComponent": { "type": "string" },
"startupRegistry": { "type": "string" },
"startupShell": { "type": "string" },
"debugPluginLoader": { "type": "string" },
"commonBootstrap": { "type": "string" },
"appJson": { "type": "string" }
}
},
"embeddedOutput": {
"type": "object",
"additionalProperties": false,
"properties": {
"android": { "type": "string" },
"ios": { "type": "string" }
}
},
"modules": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "type", "entry"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"type": { "enum": ["startup", "common", "app", "buz"] },
"entry": { "type": "string" },
"moduleVersion": { "type": "string" },
"appVersionRange": { "type": "string" },
"commonVersionRange": { "type": "string" },
"minNativeApiLevel": { "type": "integer", "minimum": 1 },
"metroConfig": { "type": "string" },
"ownershipRoots": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
},
"sourceMap": { "type": "boolean" },
"title": { "type": "string" },
"summary": { "type": "string" },
"description": { "type": "string" },
"accentColor": { "type": "string" },
"uniqueId": { "type": "string" },
"link": { "type": "string" }
},
"additionalProperties": true
}
},
"release": { "type": "object" }
}
}

查看文件

@ -0,0 +1,245 @@
'use strict'
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const GENERATOR_VERSION = 1
const GENERATED_DIRECTORY = '.xuqm/generated'
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function toImportSpecifier(fromDirectory, target) {
const relative = path.relative(fromDirectory, target).split(path.sep).join('/')
const specifier = relative.startsWith('.') ? relative : `./${relative}`
return specifier.replace(/\.(?:js|jsx|ts|tsx)$/, '')
}
function generatedEntryPath(moduleId) {
return `${GENERATED_DIRECTORY}/entries/${moduleId}.js`
}
function resolvedGenerationInput(projectRoot, config) {
const generation = config.entryGeneration
if (!generation) return null
const required = ['appComponent', 'startupRegistry', 'startupShell', 'debugPluginLoader']
const missing = required.filter(
key => typeof generation[key] !== 'string' || generation[key].trim() === '',
)
if (missing.length > 0) {
throw new Error(`entryGeneration 缺少字段:${missing.join(', ')}`)
}
const sourceFiles = {
appComponent: path.resolve(projectRoot, generation.appComponent),
startupRegistry: path.resolve(projectRoot, generation.startupRegistry),
startupShell: path.resolve(projectRoot, generation.startupShell),
debugPluginLoader: path.resolve(projectRoot, generation.debugPluginLoader),
...(generation.commonBootstrap
? { commonBootstrap: path.resolve(projectRoot, generation.commonBootstrap) }
: {}),
}
const missingSources = Object.entries(sourceFiles)
.filter(([, file]) => !fs.existsSync(file))
.map(([key, file]) => `${key}: ${path.relative(projectRoot, file)}`)
const appJson = path.resolve(projectRoot, generation.appJson ?? './app.json')
if (!fs.existsSync(appJson)) {
missingSources.push(`appJson: ${path.relative(projectRoot, appJson)}`)
}
if (missingSources.length > 0) {
throw new Error(`入口生成源文件不存在:\n- ${missingSources.join('\n- ')}`)
}
const generatedModules = config.modules
.filter(module => ['startup', 'common', 'app'].includes(module.type))
.map(module => ({ id: module.id, type: module.type }))
const debugPlugins = config.modules
.filter(module => module.type === 'buz')
.map(module => ({ id: module.id, entry: module.entry }))
return {
appJson,
debugPlugins,
generatedModules,
generation,
sourceFiles,
}
}
function expectedFiles(input) {
return [
...input.generatedModules.map(module => `entries/${module.id}.js`),
'entries/debugPlugins.js',
'manifest.json',
]
}
function generationHash(projectRoot, config, input) {
const hash = crypto.createHash('sha256')
hash.update(
JSON.stringify({
generatorVersion: GENERATOR_VERSION,
entryGeneration: config.entryGeneration,
modules: config.modules.map(module => ({
id: module.id,
type: module.type,
entry: module.entry,
})),
}),
)
// 生成入口只引用源码路径,不复制源码内容;业务源码保存不能造成生成目录反复写入。
hash.update(
JSON.stringify(
[input.appJson, ...Object.values(input.sourceFiles)]
.map(file => path.relative(projectRoot, file))
.sort(),
),
)
return hash.digest('hex')
}
function currentGenerationIsValid(target, inputHash, files) {
const manifestFile = path.join(target, 'manifest.json')
if (!fs.existsSync(manifestFile)) return false
try {
const manifest = readJson(manifestFile)
return (
manifest.generatorVersion === GENERATOR_VERSION &&
manifest.inputHash === inputHash &&
files.every(file => fs.existsSync(path.join(target, file)))
)
} catch {
return false
}
}
function writeGeneratedEntries(projectRoot, directory, config, input, inputHash) {
const entriesDirectory = path.join(directory, 'entries')
fs.mkdirSync(entriesDirectory, { recursive: true })
const importFromEntries = target => toImportSpecifier(entriesDirectory, target)
const byType = new Map(input.generatedModules.map(module => [module.type, module]))
const startup = byType.get('startup')
if (startup) {
fs.writeFileSync(
path.join(entriesDirectory, `${startup.id}.js`),
[
'// 此文件由 @xuqm/rn-update 自动生成,删除后会重建,请勿编辑。',
"import { AppRegistry } from 'react-native';",
`import StartupShell from ${JSON.stringify(importFromEntries(input.sourceFiles.startupShell))};`,
`const { name: appName } = require(${JSON.stringify(importFromEntries(input.appJson))});`,
'AppRegistry.registerComponent(appName, () => StartupShell);',
'',
].join('\n'),
)
}
const common = byType.get('common')
if (common) {
fs.writeFileSync(
path.join(entriesDirectory, `${common.id}.js`),
[
'// 此文件由 @xuqm/rn-update 自动生成,删除后会重建,请勿编辑。',
...(input.sourceFiles.commonBootstrap
? [`import ${JSON.stringify(importFromEntries(input.sourceFiles.commonBootstrap))};`]
: []),
'',
].join('\n'),
)
}
const app = byType.get('app')
if (app) {
fs.writeFileSync(
path.join(entriesDirectory, `${app.id}.js`),
[
'// 此文件由 @xuqm/rn-update 自动生成,删除后会重建,请勿编辑。',
`import App from ${JSON.stringify(importFromEntries(input.sourceFiles.appComponent))};`,
`import { registerAppComponent } from ${JSON.stringify(
importFromEntries(input.sourceFiles.startupRegistry),
)};`,
'registerAppComponent(App);',
'',
].join('\n'),
)
}
fs.writeFileSync(
path.join(entriesDirectory, 'debugPlugins.js'),
[
'// 此文件由 @xuqm/rn-update 自动生成,删除后会重建,请勿编辑。',
`import { registerDebugPluginLoader } from ${JSON.stringify(
importFromEntries(input.sourceFiles.debugPluginLoader),
)};`,
'',
...input.debugPlugins.flatMap(plugin => [
`registerDebugPluginLoader(${JSON.stringify(plugin.id)}, async () => {`,
` require(${JSON.stringify(importFromEntries(path.resolve(projectRoot, plugin.entry)))});`,
'});',
'',
]),
].join('\n'),
)
fs.writeFileSync(
path.join(directory, 'manifest.json'),
`${JSON.stringify(
{
schemaVersion: 1,
generatorVersion: GENERATOR_VERSION,
inputHash,
files: expectedFiles(input).filter(file => file !== 'manifest.json'),
},
null,
2,
)}\n`,
)
}
/**
* `.xuqm` 只保存可重建产物生成采用临时目录替换进程中断时不会暴露半套入口
*/
function ensureGeneratedEntries(projectRoot = process.cwd(), configOverride) {
const configFile = path.join(projectRoot, 'xuqm.modules.json')
if (!fs.existsSync(configFile)) return { generated: false, reason: 'config-missing' }
const config = configOverride ?? readJson(configFile)
const input = resolvedGenerationInput(projectRoot, config)
if (!input) return { generated: false, reason: 'generation-disabled' }
const target = path.join(projectRoot, GENERATED_DIRECTORY)
const files = expectedFiles(input)
const inputHash = generationHash(projectRoot, config, input)
if (currentGenerationIsValid(target, inputHash, files)) {
return { generated: false, inputHash, target }
}
const parent = path.dirname(target)
const temporary = path.join(
parent,
`generated.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`,
)
const backup = path.join(parent, `generated.old-${process.pid}`)
fs.mkdirSync(parent, { recursive: true })
fs.rmSync(temporary, { force: true, recursive: true })
fs.rmSync(backup, { force: true, recursive: true })
try {
writeGeneratedEntries(projectRoot, temporary, config, input, inputHash)
if (fs.existsSync(target)) fs.renameSync(target, backup)
fs.renameSync(temporary, target)
fs.rmSync(backup, { force: true, recursive: true })
} catch (error) {
fs.rmSync(temporary, { force: true, recursive: true })
if (!fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target)
throw error
}
return { generated: true, inputHash, target }
}
module.exports = {
GENERATED_DIRECTORY,
ensureGeneratedEntries,
generatedEntryPath,
}

查看文件

@ -0,0 +1,23 @@
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
function runProjectTool(projectRoot, relativeFile, args = []) {
const file = path.join(projectRoot, relativeFile)
if (!existsSync(file)) return false
execFileSync(process.execPath, [file, ...args], {
cwd: projectRoot,
env: process.env,
stdio: 'inherit',
})
return true
}
export function runProjectCheck(projectRoot) {
return runProjectTool(projectRoot, 'tools/quality/check.mjs')
}
export function runProjectFix(projectRoot) {
return runProjectTool(projectRoot, 'tools/quality/fix.mjs')
}

查看文件

@ -0,0 +1,141 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
const SOURCE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx']
function inside(file, directory) {
const relative = path.relative(directory, file)
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
}
function walk(directory) {
if (!existsSync(directory)) return []
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const absolute = path.join(directory, entry.name)
if (entry.isDirectory()) return walk(absolute)
return SOURCE_EXTENSIONS.includes(path.extname(entry.name)) ? [absolute] : []
})
}
function sourceSpecifiers(file) {
const source = readFileSync(file, 'utf8')
const result = []
for (const pattern of [
/(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g,
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
]) {
for (const match of source.matchAll(pattern)) result.push(match[1])
}
return result
}
function aliasMappings(projectRoot) {
const conventional = [
{ alias: '@app', root: path.join(projectRoot, 'src', 'app') },
{ alias: '@shared', root: path.join(projectRoot, 'src', 'shared') },
{ alias: '@plugins', root: path.join(projectRoot, 'src', 'plugins') },
]
const tsconfigFile = path.join(projectRoot, 'tsconfig.json')
if (!existsSync(tsconfigFile)) return conventional
let config
try {
config = JSON.parse(readFileSync(tsconfigFile, 'utf8'))
} catch {
// 带注释的 tsconfig 仍使用稳定约定别名;doctor 不应为了静态门禁要求宿主改格式。
return conventional
}
const paths = config.compilerOptions?.paths ?? {}
return [
...conventional,
...Object.entries(paths).flatMap(([pattern, targets]) => {
const alias = pattern.replace(/\/\*$/, '')
return (targets ?? []).map(target => ({
alias,
root: path.resolve(projectRoot, target.replace(/\/\*$/, '')),
}))
}),
]
}
function resolveSource(importer, specifier, aliases) {
let base
if (specifier.startsWith('.')) {
base = path.resolve(path.dirname(importer), specifier)
} else {
const mapping = aliases
.sort((left, right) => right.alias.length - left.alias.length)
.find(item => specifier === item.alias || specifier.startsWith(`${item.alias}/`))
if (!mapping) return null
const suffix = specifier === mapping.alias ? '' : specifier.slice(mapping.alias.length + 1)
base = path.join(mapping.root, suffix)
}
return [
base,
...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`),
...SOURCE_EXTENSIONS.map(extension => path.join(base, `index${extension}`)),
].find(existsSync)
}
/**
* 这些是所有多 Bundle 宿主都必须满足的交付边界不属于任何具体 App 的业务规则
*/
export function validateSourceBoundaries(projectRoot, config) {
if (!config.entryGeneration) return []
const aliases = aliasMappings(projectRoot)
const appRoot = path.dirname(path.resolve(projectRoot, config.entryGeneration.appComponent))
const commonRoots = (
config.modules.find(module => module.type === 'common')?.ownershipRoots ?? []
).map(root => path.resolve(projectRoot, root))
const pluginRoots = config.modules
.filter(module => module.type === 'buz')
.map(module => ({ id: module.id, root: path.dirname(path.resolve(projectRoot, module.entry)) }))
const violations = []
function inspect(importer, owner, isForbidden) {
for (const specifier of sourceSpecifiers(importer)) {
const dependency = resolveSource(importer, specifier, aliases)
if (!dependency || !isForbidden(dependency)) continue
violations.push(
`${owner}: ${path.relative(projectRoot, importer)} -> ${path.relative(
projectRoot,
dependency,
)}`,
)
}
}
for (const commonRoot of commonRoots) {
for (const importer of walk(commonRoot)) {
inspect(
importer,
'common 不得依赖宿主或插件',
dependency =>
inside(dependency, appRoot) ||
pluginRoots.some(plugin => inside(dependency, plugin.root)),
)
}
}
for (const plugin of pluginRoots) {
for (const importer of walk(plugin.root)) {
inspect(
importer,
`插件 ${plugin.id} 只能依赖自身、common 和三方库`,
dependency =>
inside(dependency, appRoot) ||
pluginRoots.some(
candidate => candidate.id !== plugin.id && inside(dependency, candidate.root),
),
)
}
}
for (const importer of walk(appRoot)) {
inspect(importer, '宿主不得静态导入插件', dependency =>
pluginRoots.some(plugin => inside(dependency, plugin.root)),
)
}
return violations
}

查看文件

@ -32,7 +32,11 @@ const required = [
'android/xuqm-bundles.gradle', 'android/xuqm-bundles.gradle',
'scripts/xuqm-rn.mjs', 'scripts/xuqm-rn.mjs',
'scripts/xuqm-config.mjs', 'scripts/xuqm-config.mjs',
'scripts/generated-entries.cjs',
'scripts/project-check.mjs',
'scripts/source-boundaries.mjs',
'scripts/xuqm_release.mjs', 'scripts/xuqm_release.mjs',
'schema/xuqm.modules.schema.json',
'src/UpdateSDK.ts', 'src/UpdateSDK.ts',
'src/NativeBundle.ts', 'src/NativeBundle.ts',
'src/NativeAppUpdate.ts', 'src/NativeAppUpdate.ts',

查看文件

@ -73,6 +73,15 @@ export function validateConfig(config, root = process.cwd()) {
if (!module.entry || !existsSync(path.resolve(root, module.entry))) { if (!module.entry || !existsSync(path.resolve(root, module.entry))) {
errors.push(`module entry not found for ${module.id}: ${module.entry ?? '<missing>'}`) errors.push(`module entry not found for ${module.id}: ${module.entry ?? '<missing>'}`)
} }
if (
config.entryGeneration &&
['startup', 'common', 'app'].includes(module.type) &&
module.entry !== `./.xuqm/generated/entries/${module.id}.js`
) {
errors.push(
`generated ${module.type} entry must be ./.xuqm/generated/entries/${module.id}.js`,
)
}
if (module.moduleVersion !== undefined && typeof module.moduleVersion !== 'string') { if (module.moduleVersion !== undefined && typeof module.moduleVersion !== 'string') {
errors.push(`moduleVersion must be a string for ${module.id}`) errors.push(`moduleVersion must be a string for ${module.id}`)
} else if (module.moduleVersion !== undefined && !semver.valid(module.moduleVersion)) { } else if (module.moduleVersion !== undefined && !semver.valid(module.moduleVersion)) {

查看文件

@ -35,10 +35,14 @@ import { parseBugCollectOption, parsePackageOptions } from './package-options.mj
import { validateReleaseConfig } from './release-config.mjs' import { validateReleaseConfig } from './release-config.mjs'
import { createAndroidReleaseIdentity } from './release-identity.mjs' import { createAndroidReleaseIdentity } from './release-identity.mjs'
import { sanitizeSourceMapFile } from './source-map.mjs' import { sanitizeSourceMapFile } from './source-map.mjs'
import { runProjectCheck, runProjectFix } from './project-check.mjs'
import { validateSourceBoundaries } from './source-boundaries.mjs'
const root = process.cwd() const root = process.cwd()
const args = process.argv.slice(2) const args = process.argv.slice(2)
const command = args.shift() ?? 'help' const command = args.shift() ?? 'help'
const scriptRequire = createRequire(import.meta.url)
const { ensureGeneratedEntries } = scriptRequire('./generated-entries.cjs')
function fail(message) { function fail(message) {
console.error(`xuqm-rn: ${message}`) console.error(`xuqm-rn: ${message}`)
@ -151,6 +155,7 @@ function init() {
if (!existsSync(configFile)) { if (!existsSync(configFile)) {
const pkg = JSON.parse(readFileSync(packageFile, 'utf8')) const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
writeJson(configFile, { writeJson(configFile, {
$schema: './node_modules/@xuqm/rn-update/schema/xuqm.modules.schema.json',
schemaVersion: 3, schemaVersion: 3,
appId: pkg.name ?? 'react-native-app', appId: pkg.name ?? 'react-native-app',
packageName: pkg.name ?? 'react-native-app', packageName: pkg.name ?? 'react-native-app',
@ -179,17 +184,17 @@ function init() {
} }
const pkg = JSON.parse(readFileSync(packageFile, 'utf8')) const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
pkg.scripts ??= {} pkg.scripts ??= {}
pkg.scripts['xuqm:doctor'] ??= 'xuqm-rn doctor'
pkg.scripts.start ??= 'xuqm-rn start' pkg.scripts.start ??= 'xuqm-rn start'
pkg.scripts.android ??= 'xuqm-rn run android --extra-params "-PUSE_METRO=true"' pkg.scripts.android ??= 'xuqm-rn run android --extra-params "-PUSE_METRO=true"'
pkg.scripts['build:android'] ??= 'xuqm-rn embed android' pkg.scripts.ios ??= 'xuqm-rn run ios'
pkg.scripts.check ??= 'xuqm-rn check'
pkg.scripts['release:android'] ??= 'xuqm-rn package android' pkg.scripts['release:android'] ??= 'xuqm-rn package android'
pkg.scripts['publish:android'] ??= 'xuqm-rn publish android'
writeJson(packageFile, pkg) writeJson(packageFile, pkg)
console.log(`xuqm-rn: initialized. Edit ${CONFIG_FILE_NAME}, then run \`xuqm-rn doctor\`.`) console.log(`xuqm-rn: initialized. Edit ${CONFIG_FILE_NAME}, then run \`xuqm-rn doctor\`.`)
} }
function doctor() { function doctor() {
ensureGeneratedEntries(root)
const config = readConfig(root) const config = readConfig(root)
const errors = validateConfig(config, root) const errors = validateConfig(config, root)
const packageFile = path.join(root, 'package.json') const packageFile = path.join(root, 'package.json')
@ -208,6 +213,29 @@ function doctor() {
return config return config
} }
function checkProject() {
const config = doctor()
const boundaryViolations = validateSourceBoundaries(root, config)
if (boundaryViolations.length > 0) {
fail(`source boundary check failed:\n- ${boundaryViolations.join('\n- ')}`)
}
const projectCheckRan = runProjectCheck(root)
console.log(
`xuqm-rn: check passed (${config.modules.length} modules${
projectCheckRan ? ', project rules included' : ''
}).`,
)
}
function fixProject() {
ensureGeneratedEntries(root)
const projectFixRan = runProjectFix(root)
if (!projectFixRan) {
console.log('xuqm-rn: no project fix hook; generated entries are current.')
}
checkProject()
}
function createPlugin(moduleId) { function createPlugin(moduleId) {
if (!/^[a-z][a-z0-9-]*$/.test(moduleId ?? '')) { if (!/^[a-z][a-z0-9-]*$/.test(moduleId ?? '')) {
fail('usage: xuqm-rn plugin create <moduleId>; id must match /^[a-z][a-z0-9-]*$/') fail('usage: xuqm-rn plugin create <moduleId>; id must match /^[a-z][a-z0-9-]*$/')
@ -661,6 +689,7 @@ async function packageApp(platform, optionArgs) {
} }
function startMetro(optionArgs) { function startMetro(optionArgs) {
doctor()
runReactNative(['start', ...optionArgs]) runReactNative(['start', ...optionArgs])
} }
@ -732,6 +761,7 @@ async function assertMetroBelongsToCurrentProject(optionArgs) {
async function runApp(platform, optionArgs) { async function runApp(platform, optionArgs) {
assertPlatform(platform) assertPlatform(platform)
doctor()
await assertMetroBelongsToCurrentProject(optionArgs) await assertMetroBelongsToCurrentProject(optionArgs)
runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs]) runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs])
} }
@ -742,6 +772,8 @@ function printHelp() {
Commands: Commands:
init Create the minimal host configuration init Create the minimal host configuration
doctor Validate host integration doctor Validate host integration
check Validate SDK integration and project quality rules
fix Regenerate safe artifacts, then run check
plugin create <id> Create and register a plugin plugin create <id> Create and register a plugin
start Start Metro with a consistent child environment start Start Metro with a consistent child environment
run <platform> Build/install a debug app (passes remaining options to RN CLI) run <platform> Build/install a debug app (passes remaining options to RN CLI)
@ -764,6 +796,12 @@ try {
case 'doctor': case 'doctor':
doctor() doctor()
break break
case 'check':
checkProject()
break
case 'fix':
fixProject()
break
case 'plugin': { case 'plugin': {
if (args.shift() !== 'create') fail('usage: xuqm-rn plugin create <moduleId>') if (args.shift() !== 'create') fail('usage: xuqm-rn plugin create <moduleId>')
createPlugin(args.shift()) createPlugin(args.shift())

查看文件

@ -18,9 +18,125 @@ import path from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { strFromU8, unzipSync } from 'fflate' import { strFromU8, unzipSync } from 'fflate'
import { validateSourceBoundaries } from './source-boundaries.mjs'
const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url)) const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url))
const workspaceRequire = createRequire(import.meta.url) const workspaceRequire = createRequire(import.meta.url)
const { ensureGeneratedEntries } = workspaceRequire('./generated-entries.cjs')
test('generated entries are disposable and rebuilt from tracked configuration', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-generated-'))
try {
mkdirSync(path.join(root, 'src', 'app', 'host'), { recursive: true })
mkdirSync(path.join(root, 'src', 'plugins', 'feature'), { recursive: true })
writeFileSync(path.join(root, 'app.json'), JSON.stringify({ name: 'Fixture' }))
writeFileSync(path.join(root, 'src', 'app', 'App.tsx'), 'export default function App() {}')
writeFileSync(
path.join(root, 'src', 'app', 'host', 'StartupShell.tsx'),
'export default function StartupShell() {}',
)
writeFileSync(path.join(root, 'src', 'app', 'host', 'startupRegistry.ts'), '')
writeFileSync(path.join(root, 'src', 'app', 'host', 'pluginHostRuntime.ts'), '')
writeFileSync(path.join(root, 'src', 'app', 'host', 'commonBundleBootstrap.ts'), '')
writeFileSync(path.join(root, 'src', 'plugins', 'feature', 'bundle.ts'), '')
const config = {
schemaVersion: 3,
appId: 'fixture',
packageName: 'com.example.fixture',
mainModuleName: 'Fixture',
pluginVersion: '1.0.0',
appVersionRange: '>=1.0.0 <2.0.0',
entryGeneration: {
appComponent: './src/app/App.tsx',
startupRegistry: './src/app/host/startupRegistry.ts',
startupShell: './src/app/host/StartupShell.tsx',
debugPluginLoader: './src/app/host/pluginHostRuntime.ts',
commonBootstrap: './src/app/host/commonBundleBootstrap.ts',
},
modules: [
{
id: 'startup',
type: 'startup',
entry: './.xuqm/generated/entries/startup.js',
},
{
id: 'common',
type: 'common',
entry: './.xuqm/generated/entries/common.js',
},
{ id: 'app', type: 'app', entry: './.xuqm/generated/entries/app.js' },
{
id: 'feature',
type: 'buz',
entry: './src/plugins/feature/bundle.ts',
},
],
}
writeFileSync(path.join(root, 'xuqm.modules.json'), JSON.stringify(config))
const first = ensureGeneratedEntries(root)
assert.equal(first.generated, true)
const generatedRoot = path.join(root, '.xuqm', 'generated')
const firstAppEntry = readFileSync(path.join(generatedRoot, 'entries', 'app.js'), 'utf8')
assert.match(firstAppEntry, /registerAppComponent/)
assert.match(
readFileSync(path.join(generatedRoot, 'entries', 'debugPlugins.js'), 'utf8'),
/registerDebugPluginLoader\("feature"/,
)
assert.equal(ensureGeneratedEntries(root).generated, false)
rmSync(path.join(root, '.xuqm'), { recursive: true, force: true })
assert.equal(ensureGeneratedEntries(root).generated, true)
assert.equal(readFileSync(path.join(generatedRoot, 'entries', 'app.js'), 'utf8'), firstAppEntry)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('generic source boundary check rejects host imports from a plugin', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-boundaries-'))
try {
mkdirSync(path.join(root, 'src', 'app'), { recursive: true })
mkdirSync(path.join(root, 'src', 'shared'), { recursive: true })
mkdirSync(path.join(root, 'src', 'plugins', 'feature'), { recursive: true })
writeFileSync(path.join(root, 'src', 'app', 'App.tsx'), 'export default function App() {}')
writeFileSync(path.join(root, 'src', 'app', 'private.ts'), 'export const secret = 1')
writeFileSync(
path.join(root, 'src', 'plugins', 'feature', 'bundle.ts'),
"import { secret } from '@app/private'\nvoid secret\n",
)
writeFileSync(
path.join(root, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
paths: {
'@app/*': ['./src/app/*'],
},
},
}),
)
const violations = validateSourceBoundaries(root, {
entryGeneration: { appComponent: './src/app/App.tsx' },
modules: [
{
id: 'common',
type: 'common',
entry: './common.ts',
ownershipRoots: ['./src/shared'],
},
{
id: 'feature',
type: 'buz',
entry: './src/plugins/feature/bundle.ts',
},
],
})
assert.equal(violations.length, 1)
assert.match(violations[0], /插件 feature/)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('Gradle models and forwards the exact embedded release identity', () => { test('Gradle models and forwards the exact embedded release identity', () => {
const gradle = readFileSync( const gradle = readFileSync(
@ -529,6 +645,27 @@ test('building one buz first rebuilds startup and common module maps', () => {
test('start and run forward arguments through the host-local React Native CLI', () => { test('start and run forward arguments through the host-local React Native CLI', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-run-rn-')) const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-run-rn-'))
try { try {
writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({
name: 'xuqm-cli-run-rn-fixture',
version: '1.0.0',
dependencies: { '@xuqm/rn-update': '0.0.0-test' },
}),
)
writeFileSync(path.join(root, 'app.js'), 'export default null\n')
writeFileSync(
path.join(root, 'xuqm.modules.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture',
packageName: 'com.example.fixture',
mainModuleName: 'Fixture',
pluginVersion: '1.0.0',
appVersionRange: '>=1.0.0 <2.0.0',
modules: [{ id: 'app', type: 'app', entry: './app.js' }],
}),
)
const reactNativeDirectory = path.join(root, 'node_modules', 'react-native') const reactNativeDirectory = path.join(root, 'node_modules', 'react-native')
mkdirSync(reactNativeDirectory, { recursive: true }) mkdirSync(reactNativeDirectory, { recursive: true })
writeFileSync( writeFileSync(