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