XuqmGroup-RNSDK/packages/update/scripts/generated-entries.cjs

246 行
8.2 KiB
JavaScript

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