From 8e61fe7857af6a278913a92888b9f097ae7d7be1 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Jul 2026 10:44:56 +0800 Subject: [PATCH] refactor(update): generate host entries and unify project checks --- docs/插件脚手架.md | 22 +- packages/update/README.md | 10 +- packages/update/metro/index.js | 7 +- packages/update/package.json | 2 + .../update/schema/xuqm.modules.schema.json | 82 ++++++ packages/update/scripts/generated-entries.cjs | 245 ++++++++++++++++++ packages/update/scripts/project-check.mjs | 23 ++ packages/update/scripts/source-boundaries.mjs | 141 ++++++++++ packages/update/scripts/verify-package.mjs | 4 + packages/update/scripts/xuqm-config.mjs | 9 + packages/update/scripts/xuqm-rn.mjs | 44 +++- packages/update/scripts/xuqm-rn.test.mjs | 137 ++++++++++ 12 files changed, 710 insertions(+), 16 deletions(-) create mode 100644 packages/update/schema/xuqm.modules.schema.json create mode 100644 packages/update/scripts/generated-entries.cjs create mode 100644 packages/update/scripts/project-check.mjs create mode 100644 packages/update/scripts/source-boundaries.mjs diff --git a/docs/插件脚手架.md b/docs/插件脚手架.md index ff6d07c..de82f55 100644 --- a/docs/插件脚手架.md +++ b/docs/插件脚手架.md @@ -6,11 +6,15 @@ ```bash pnpm exec xuqm-rn init -pnpm xuqm:doctor +pnpm check ``` `init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。 +startup/common/app 与 Debug 插件的可重建胶水入口统一生成到 +`.xuqm/generated/entries`。该目录必须加入 `.gitignore`,更换设备或主动删除后由 +CLI/Metro 自动恢复;项目业务 bootstrap、页面和插件源码仍正常提交。 + 构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId` 保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。 ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析; @@ -42,28 +46,27 @@ pnpm exec xuqm-rn plugin create prescription - 只包含小写字母、数字和连字符 - 在 `xuqm.modules.json` 中唯一 -CLI 会生成: +CLI 会生成最小插件入口: ```text src/plugins/prescription/ bundle.ts - PrescriptionScreen.tsx ``` 并向 `xuqm.modules.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。 ## 插件入口 -生成的入口使用统一运行时定义: +生成的入口使用统一运行时定义,项目随后在该插件目录内实现自己的页面与导航: ```ts import { definePlugin } from '@xuqm/rn-update' -import { PrescriptionScreen } from './PrescriptionScreen' - definePlugin({ - id: 'prescription', - Component: PrescriptionScreen, + moduleId: 'prescription', + activate(context) { + context.emit('plugin:ready', { moduleId: 'prescription' }) + }, }) ``` @@ -80,7 +83,7 @@ pnpm exec xuqm-rn publish android --module prescription 构建全部模块并嵌入宿主: ```bash -pnpm build:android +pnpm exec xuqm-rn embed android ``` 生成 Android Release 安装包: @@ -99,3 +102,4 @@ Release 构建会先重新生成全部插件,不使用仓库内历史 bundle - 不得逐层传递 SDK 初始化、用户、token 或宿主运行时参数。 - 共通逻辑放在 common 或 update 运行时,不复制到各插件。 - 插件发布前必须通过 `xuqm-rn doctor`、宿主测试和 Release 构建。 +- `.xuqm` 不得保存人工源码或发布状态;删除后必须能够完整重建。 diff --git a/packages/update/README.md b/packages/update/README.md index bb8cb7b..3619768 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -10,7 +10,7 @@ Android 整包更新与 RN 多 Bundle 插件运行时。它依赖 `@xuqm/rn-comm pnpm add @xuqm/rn-common @xuqm/rn-update \ @react-native-async-storage/async-storage pnpm exec xuqm-rn init -pnpm run xuqm:doctor +pnpm check ``` `xuqm-rn init` 生成 schema v3 `xuqm.modules.json` 并补充最少脚本。版本只有两条明确来源: @@ -44,6 +44,11 @@ const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro') 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 配置。 ## 开发与打包 @@ -51,7 +56,8 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname)) - `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。 - `pnpm release:android`:重新构建并内嵌全部 startup/common/app/buz 后生成 Release AAB。 - `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。 -- `pnpm publish:android`:上传插件产物;SDK npm 制品发布只能通过 Jenkins。 +- `pnpm exec xuqm-rn publish android --module `:供受控 CI 上传插件产物; + SDK npm 制品发布只能通过 Jenkins。 `withXuqmModuleConfig()` 会让开发服务器公开当前工程标识;`xuqm-rn run` 在复用 已有 Metro 前必须校验该标识。若端口属于其他工程或不支持标识校验的旧服务,命令 diff --git a/packages/update/metro/index.js b/packages/update/metro/index.js index 04a29bf..46dc4dd 100644 --- a/packages/update/metro/index.js +++ b/packages/update/metro/index.js @@ -2,6 +2,7 @@ const fs = require('node:fs') const path = require('node:path') const { withXuqmConfig } = require('@xuqm/rn-common/metro') +const { ensureGeneratedEntries } = require('../scripts/generated-entries.cjs') const MODULE_RANGE_SIZE = 10_000_000 @@ -77,7 +78,9 @@ function createModuleSerializer(environment = process.env) { * 同一份 Metro 配置同时服务日常开发和插件构建。CLI 构建插件时注入模块上下文; * 普通 `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 moduleSerializer = createModuleSerializer() const configuredFilter = configured.serializer?.processModuleFilter @@ -95,7 +98,7 @@ function withXuqmModuleConfig(metroConfig) { response.setHeader('Content-Type', 'application/json; charset=utf-8') response.end( JSON.stringify({ - projectRoot: path.resolve(process.cwd()), + projectRoot, protocolVersion: 1, }), ) diff --git a/packages/update/package.json b/packages/update/package.json index 33cc48d..8fcdf46 100644 --- a/packages/update/package.json +++ b/packages/update/package.json @@ -12,12 +12,14 @@ "./plugin-assets": "./src/PluginAssets.ts", "./plugin-registry": "./src/PluginRegistry.ts", "./metro": "./metro/index.js", + "./schema/xuqm.modules.schema.json": "./schema/xuqm.modules.schema.json", "./package.json": "./package.json" }, "files": [ "src", "android", "metro", + "schema", "scripts", "templates", "react-native.config.js", diff --git a/packages/update/schema/xuqm.modules.schema.json b/packages/update/schema/xuqm.modules.schema.json new file mode 100644 index 0000000..60dc009 --- /dev/null +++ b/packages/update/schema/xuqm.modules.schema.json @@ -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" } + } +} diff --git a/packages/update/scripts/generated-entries.cjs b/packages/update/scripts/generated-entries.cjs new file mode 100644 index 0000000..e007642 --- /dev/null +++ b/packages/update/scripts/generated-entries.cjs @@ -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, +} diff --git a/packages/update/scripts/project-check.mjs b/packages/update/scripts/project-check.mjs new file mode 100644 index 0000000..7a7a79f --- /dev/null +++ b/packages/update/scripts/project-check.mjs @@ -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') +} diff --git a/packages/update/scripts/source-boundaries.mjs b/packages/update/scripts/source-boundaries.mjs new file mode 100644 index 0000000..8d41e36 --- /dev/null +++ b/packages/update/scripts/source-boundaries.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 +} diff --git a/packages/update/scripts/verify-package.mjs b/packages/update/scripts/verify-package.mjs index 39e7e8c..b10facd 100755 --- a/packages/update/scripts/verify-package.mjs +++ b/packages/update/scripts/verify-package.mjs @@ -32,7 +32,11 @@ const required = [ 'android/xuqm-bundles.gradle', 'scripts/xuqm-rn.mjs', 'scripts/xuqm-config.mjs', + 'scripts/generated-entries.cjs', + 'scripts/project-check.mjs', + 'scripts/source-boundaries.mjs', 'scripts/xuqm_release.mjs', + 'schema/xuqm.modules.schema.json', 'src/UpdateSDK.ts', 'src/NativeBundle.ts', 'src/NativeAppUpdate.ts', diff --git a/packages/update/scripts/xuqm-config.mjs b/packages/update/scripts/xuqm-config.mjs index aec1e62..f2fc420 100644 --- a/packages/update/scripts/xuqm-config.mjs +++ b/packages/update/scripts/xuqm-config.mjs @@ -73,6 +73,15 @@ export function validateConfig(config, root = process.cwd()) { if (!module.entry || !existsSync(path.resolve(root, module.entry))) { errors.push(`module entry not found for ${module.id}: ${module.entry ?? ''}`) } + 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') { errors.push(`moduleVersion must be a string for ${module.id}`) } else if (module.moduleVersion !== undefined && !semver.valid(module.moduleVersion)) { diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index ce4dc8d..f0d98a6 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -35,10 +35,14 @@ import { parseBugCollectOption, parsePackageOptions } from './package-options.mj import { validateReleaseConfig } from './release-config.mjs' import { createAndroidReleaseIdentity } from './release-identity.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 args = process.argv.slice(2) const command = args.shift() ?? 'help' +const scriptRequire = createRequire(import.meta.url) +const { ensureGeneratedEntries } = scriptRequire('./generated-entries.cjs') function fail(message) { console.error(`xuqm-rn: ${message}`) @@ -151,6 +155,7 @@ function init() { if (!existsSync(configFile)) { const pkg = JSON.parse(readFileSync(packageFile, 'utf8')) writeJson(configFile, { + $schema: './node_modules/@xuqm/rn-update/schema/xuqm.modules.schema.json', schemaVersion: 3, appId: pkg.name ?? 'react-native-app', packageName: pkg.name ?? 'react-native-app', @@ -179,17 +184,17 @@ function init() { } const pkg = JSON.parse(readFileSync(packageFile, 'utf8')) pkg.scripts ??= {} - pkg.scripts['xuqm:doctor'] ??= 'xuqm-rn doctor' pkg.scripts.start ??= 'xuqm-rn start' 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['publish:android'] ??= 'xuqm-rn publish android' writeJson(packageFile, pkg) console.log(`xuqm-rn: initialized. Edit ${CONFIG_FILE_NAME}, then run \`xuqm-rn doctor\`.`) } function doctor() { + ensureGeneratedEntries(root) const config = readConfig(root) const errors = validateConfig(config, root) const packageFile = path.join(root, 'package.json') @@ -208,6 +213,29 @@ function doctor() { 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) { if (!/^[a-z][a-z0-9-]*$/.test(moduleId ?? '')) { fail('usage: xuqm-rn plugin create ; id must match /^[a-z][a-z0-9-]*$/') @@ -661,6 +689,7 @@ async function packageApp(platform, optionArgs) { } function startMetro(optionArgs) { + doctor() runReactNative(['start', ...optionArgs]) } @@ -732,6 +761,7 @@ async function assertMetroBelongsToCurrentProject(optionArgs) { async function runApp(platform, optionArgs) { assertPlatform(platform) + doctor() await assertMetroBelongsToCurrentProject(optionArgs) runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs]) } @@ -742,6 +772,8 @@ function printHelp() { Commands: init Create the minimal host configuration doctor Validate host integration + check Validate SDK integration and project quality rules + fix Regenerate safe artifacts, then run check plugin create Create and register a plugin start Start Metro with a consistent child environment run Build/install a debug app (passes remaining options to RN CLI) @@ -764,6 +796,12 @@ try { case 'doctor': doctor() break + case 'check': + checkProject() + break + case 'fix': + fixProject() + break case 'plugin': { if (args.shift() !== 'create') fail('usage: xuqm-rn plugin create ') createPlugin(args.shift()) diff --git a/packages/update/scripts/xuqm-rn.test.mjs b/packages/update/scripts/xuqm-rn.test.mjs index 0932dea..4ec2138 100644 --- a/packages/update/scripts/xuqm-rn.test.mjs +++ b/packages/update/scripts/xuqm-rn.test.mjs @@ -18,9 +18,125 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' import { strFromU8, unzipSync } from 'fflate' +import { validateSourceBoundaries } from './source-boundaries.mjs' const cli = fileURLToPath(new URL('./xuqm-rn.mjs', 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', () => { 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', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-run-rn-')) 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') mkdirSync(reactNativeDirectory, { recursive: true }) writeFileSync(