refactor(update): generate host entries and unify project checks
这个提交包含在:
父节点
28a636821a
当前提交
8e61fe7857
@ -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(
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户