- 插件包改为 .xuqm.zip 事务制品,manifest/bundle/资源/SHA-256 统一校验 - 原生层拆分为桥接编排、插件包暂存、底层存储三个单一职责类 - 状态查询改为纯读取,不再误触发安装;新增 NATIVE_BASELINE_INCOMPATIBLE 拒绝 - SemVer 解析拆分为独立原生类并补原生测试 - XWebView 收口唯一内核,错误态统一中文层与重试 - common 增加请求头/运行时契约收口与 http 测试
215 行
7.4 KiB
JavaScript
215 行
7.4 KiB
JavaScript
'use strict'
|
||
|
||
/**
|
||
* Metro 插件:自动发现 .xuqmconfig 配置文件。
|
||
*
|
||
* 宿主只需将平台下载的 .xuqmconfig 文件放入 src/assets/config/,
|
||
* SDK 自动读取并完成初始化,无需重命名或创建 .ts 包装文件。
|
||
*
|
||
* 对齐 Android SDK 的 ConfigFileReader 逻辑:
|
||
* 优先 config.xuqmconfig,否则取第一个 *.xuqmconfig。
|
||
*
|
||
* @param {import('@react-native/metro-config').MetroConfig} metroConfig
|
||
* @returns {import('@react-native/metro-config').MetroConfig}
|
||
*/
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const crypto = require('node:crypto')
|
||
|
||
const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config'
|
||
const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts')
|
||
const CONFIG_DIR_CANDIDATES = [
|
||
'src/assets/config',
|
||
'src/assets/xuqm',
|
||
'assets/config',
|
||
'assets/xuqm',
|
||
]
|
||
const CONFIG_MAGIC = 'XUQM-CONFIG-V1'
|
||
const CONFIG_PASSPHRASE = 'xuqm-config-file-v1.2026.internal'
|
||
const PBKDF2_ITERATIONS = 120_000
|
||
|
||
function decodeBase64Url(value) {
|
||
return Buffer.from(value, 'base64url')
|
||
}
|
||
|
||
/**
|
||
* 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置,
|
||
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。
|
||
*/
|
||
function decryptConfigAtBuildTime(content) {
|
||
const parts = content.trim().split('.')
|
||
if (parts.length !== 4 || parts[0] !== CONFIG_MAGIC) {
|
||
throw new Error('配置文件格式无效')
|
||
}
|
||
|
||
const salt = decodeBase64Url(parts[1])
|
||
const iv = decodeBase64Url(parts[2])
|
||
const encrypted = decodeBase64Url(parts[3])
|
||
if (salt.length === 0 || iv.length !== 12 || encrypted.length <= 16) {
|
||
throw new Error('配置文件加密参数无效')
|
||
}
|
||
|
||
const body = encrypted.subarray(0, -16)
|
||
const authTag = encrypted.subarray(-16)
|
||
const key = crypto.pbkdf2Sync(CONFIG_PASSPHRASE, salt, PBKDF2_ITERATIONS, 32, 'sha256')
|
||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
||
decipher.setAuthTag(authTag)
|
||
|
||
try {
|
||
const plaintext = Buffer.concat([decipher.update(body), decipher.final()])
|
||
return JSON.parse(plaintext.toString('utf8'))
|
||
} catch {
|
||
throw new Error('配置文件解密或内容校验失败')
|
||
}
|
||
}
|
||
|
||
function findConfigFile(projectRoot) {
|
||
for (const dir of CONFIG_DIR_CANDIDATES) {
|
||
const absDir = path.resolve(projectRoot, dir)
|
||
if (!fs.existsSync(absDir)) continue
|
||
|
||
const files = fs.readdirSync(absDir)
|
||
// 优先 config.xuqmconfig 或 config.xuqm
|
||
const preferred = files.find(f => f === 'config.xuqmconfig' || f === 'config.xuqm')
|
||
if (preferred) {
|
||
return path.join(absDir, preferred)
|
||
}
|
||
// 否则取第一个 .xuqmconfig
|
||
const fallback = files.find(f => f.endsWith('.xuqmconfig'))
|
||
if (fallback) {
|
||
return path.join(absDir, fallback)
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function applyBugCollect(metroConfig) {
|
||
// 不再自动链式 withBugCollect:基于 customSerializer 的 sourcemap 上传在 Metro 0.84
|
||
// 下不可靠(exports 限制无法获取默认 serializer,会破坏 dev 打包)。
|
||
// sourcemap 上传改由独立后处理脚本(react-native bundle 后单独上传)实现。
|
||
return metroConfig
|
||
}
|
||
|
||
/** 写入目标文件(仅在缺失或内容不一致时),返回是否发生写入。 */
|
||
function syncFile(content, destPath, label) {
|
||
try {
|
||
if (fs.existsSync(destPath) && fs.readFileSync(destPath, 'utf-8').trim() === content) {
|
||
return false
|
||
}
|
||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||
fs.writeFileSync(destPath, content, 'utf-8')
|
||
console.log(`[XuqmConfig] 已同步 .xuqmconfig → ${label}`)
|
||
return true
|
||
} catch (e) {
|
||
console.warn(`[XuqmConfig] 同步 .xuqmconfig 到 ${label} 失败:${e.message}`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 单点放置:宿主只在 RN 侧放置一份 .xuqmconfig,构建/运行时自动同步到
|
||
* 原生位置(Android assets/config,供 XuqmMergedProvider 自动初始化;iOS 资源目录)。
|
||
* 缺失或内容不一致才复制,幂等。
|
||
*/
|
||
function syncNativeConfig(projectRoot, content) {
|
||
const fileName = 'config.xuqmconfig'
|
||
|
||
// Android:原生 ContentProvider 扫描 assets/config 下的 .xuqmconfig
|
||
if (fs.existsSync(path.resolve(projectRoot, 'android'))) {
|
||
syncFile(
|
||
content,
|
||
path.resolve(projectRoot, 'android/app/src/main/assets/config', fileName),
|
||
'android assets/config',
|
||
)
|
||
}
|
||
|
||
// iOS:复制到 app 资源目录(需在 Xcode「Copy Bundle Resources」中引用一次)
|
||
const iosDir = path.resolve(projectRoot, 'ios')
|
||
if (fs.existsSync(iosDir)) {
|
||
try {
|
||
const appDir = fs
|
||
.readdirSync(iosDir, { withFileTypes: true })
|
||
.filter(d => d.isDirectory() && fs.existsSync(path.join(iosDir, d.name, 'Info.plist')))
|
||
.map(d => d.name)[0]
|
||
if (appDir) {
|
||
syncFile(content, path.join(iosDir, appDir, 'config', fileName), `ios ${appDir}/config`)
|
||
}
|
||
} catch {
|
||
// 忽略 iOS 同步异常,不阻断打包
|
||
}
|
||
}
|
||
}
|
||
|
||
function withXuqmConfig(metroConfig) {
|
||
const projectRoot = metroConfig.projectRoot ?? process.cwd()
|
||
const configFile = findConfigFile(projectRoot)
|
||
|
||
if (!configFile) {
|
||
// 未找到配置文件,返回原配置(autoInit 会静默跳过)
|
||
return applyBugCollect(metroConfig)
|
||
}
|
||
|
||
// 读取加密配置内容
|
||
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim()
|
||
|
||
// 单点放置:自动同步到原生 Android/iOS(缺失或不一致才复制)
|
||
syncNativeConfig(projectRoot, encryptedContent)
|
||
|
||
let resolvedConfig
|
||
try {
|
||
resolvedConfig = decryptConfigAtBuildTime(encryptedContent)
|
||
} catch (error) {
|
||
throw new Error(`[XuqmConfig] ${configFile}: ${error.message}`)
|
||
}
|
||
|
||
// 生成临时 TS 文件作为虚拟模块。这里只传输已解析对象,移动端不再解密。
|
||
// Metro 0.84+ only hashes files under projectRoot/watchFolders. Keep the
|
||
// generated transport module in the ignored package-manager cache instead
|
||
// of the system temp directory; the .xuqmconfig file remains the sole source.
|
||
const generatedDir = path.join(projectRoot, 'node_modules', '.cache', 'xuqm-sdk')
|
||
fs.mkdirSync(generatedDir, { recursive: true })
|
||
const virtualFile = path.join(generatedDir, 'autoinit-config.ts')
|
||
fs.writeFileSync(
|
||
virtualFile,
|
||
`// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const RESOLVED_CONFIG = ${JSON.stringify(resolvedConfig)} as const;\n`,
|
||
'utf-8',
|
||
)
|
||
|
||
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
|
||
const existingResolveRequest = metroConfig.resolver?.resolveRequest
|
||
const existingGetModulesRunBeforeMainModule =
|
||
metroConfig.serializer?.getModulesRunBeforeMainModule
|
||
|
||
return applyBugCollect({
|
||
...metroConfig,
|
||
resolver: {
|
||
...metroConfig.resolver,
|
||
resolveRequest: (context, moduleName, platform) => {
|
||
if (moduleName === VIRTUAL_MODULE_ID) {
|
||
return {
|
||
type: 'sourceFile',
|
||
filePath: virtualFile,
|
||
}
|
||
}
|
||
|
||
if (existingResolveRequest) {
|
||
return existingResolveRequest(context, moduleName, platform)
|
||
}
|
||
|
||
return context.resolveRequest(context, moduleName, platform)
|
||
},
|
||
},
|
||
serializer: {
|
||
...metroConfig.serializer,
|
||
getModulesRunBeforeMainModule: entryFilePath => {
|
||
const existingModules = existingGetModulesRunBeforeMainModule
|
||
? existingGetModulesRunBeforeMainModule(entryFilePath)
|
||
: []
|
||
return [...new Set([...existingModules, AUTO_INIT_MODULE])]
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
module.exports = { withXuqmConfig }
|