235 行
8.6 KiB
JavaScript
235 行
8.6 KiB
JavaScript
'use strict'
|
||
|
||
/**
|
||
* Metro 插件:自动发现 .xuqmconfig 配置文件。
|
||
*
|
||
* 宿主只需将平台下载的 .xuqmconfig 文件放入 src/assets/config/,
|
||
* SDK 自动读取并完成初始化,无需重命名或创建 .ts 包装文件。
|
||
*
|
||
* 目录内没有配置时保持 common-only;唯一一个 *.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/rn-common/auto-init-config'
|
||
const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts')
|
||
const CONFIG_DIRECTORY_RELATIVE_PATH = 'src/assets/config'
|
||
const CONFIG_MAGIC = 'XUQM-CONFIG-V2'
|
||
const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
|
||
const PBKDF2_ITERATIONS = 120_000
|
||
const TRUSTED_CONFIG_KEYS = require('./trusted-config-keys.json')
|
||
|
||
function decodeBase64Url(value) {
|
||
return Buffer.from(value, 'base64url')
|
||
}
|
||
|
||
/**
|
||
* 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置,
|
||
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。
|
||
*/
|
||
function canonicalJson(value) {
|
||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
||
if (value && typeof value === 'object') {
|
||
return `{${Object.keys(value)
|
||
.filter(key => value[key] !== null && value[key] !== undefined)
|
||
.sort()
|
||
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
||
.join(',')}}`
|
||
}
|
||
return JSON.stringify(value)
|
||
}
|
||
|
||
function assertConfigPayload(config, plaintext) {
|
||
const allowedFields = new Set([
|
||
'schemaVersion',
|
||
'configId',
|
||
'revision',
|
||
'issuedAt',
|
||
'expiresAt',
|
||
'appKey',
|
||
'appName',
|
||
'packageName',
|
||
'iosBundleId',
|
||
'harmonyBundleName',
|
||
'serverUrl',
|
||
'signingKey',
|
||
])
|
||
const unknownField = Object.keys(config).find(field => !allowedFields.has(field))
|
||
if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`)
|
||
if (config.schemaVersion !== 2) throw new Error('配置 schemaVersion 必须为 2')
|
||
if (
|
||
typeof config.configId !== 'string' ||
|
||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||
config.configId,
|
||
)
|
||
) {
|
||
throw new Error('配置 configId 不是有效 UUID')
|
||
}
|
||
if (!Number.isInteger(config.revision) || config.revision <= 0) {
|
||
throw new Error('配置 revision 必须为正整数')
|
||
}
|
||
for (const field of ['issuedAt', 'appKey', 'appName', 'serverUrl', 'signingKey']) {
|
||
if (typeof config[field] !== 'string' || !config[field].trim()) {
|
||
throw new Error(`配置 ${field} 不能为空`)
|
||
}
|
||
}
|
||
if (!config.issuedAt.endsWith('Z') || Number.isNaN(Date.parse(config.issuedAt))) {
|
||
throw new Error('配置 issuedAt 必须为 RFC3339 UTC 时间')
|
||
}
|
||
if (config.expiresAt !== undefined) {
|
||
const expiresAt = Date.parse(config.expiresAt)
|
||
if (!config.expiresAt.endsWith('Z') || Number.isNaN(expiresAt)) {
|
||
throw new Error('配置 expiresAt 必须为 RFC3339 UTC 时间')
|
||
}
|
||
if (expiresAt <= Date.now()) throw new Error('配置文件已过期,请从租户平台重新生成')
|
||
}
|
||
if (canonicalJson(config) !== plaintext) {
|
||
throw new Error('配置正文不是规范 canonical JSON')
|
||
}
|
||
}
|
||
|
||
function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) {
|
||
const parts = content.trim().split('.')
|
||
if (parts.length !== 6 || parts[0] !== CONFIG_MAGIC) {
|
||
throw new Error('仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件')
|
||
}
|
||
|
||
const keyId = parts[1]
|
||
const publicKeyBase64 = trustedKeys[keyId]
|
||
if (!publicKeyBase64) throw new Error(`配置签名 keyId 不受信任:${keyId}`)
|
||
const unsignedToken = parts.slice(0, 5).join('.')
|
||
const signatureValid = crypto.verify(
|
||
null,
|
||
Buffer.from(unsignedToken, 'ascii'),
|
||
{
|
||
key: Buffer.from(publicKeyBase64, 'base64'),
|
||
format: 'der',
|
||
type: 'spki',
|
||
},
|
||
decodeBase64Url(parts[5]),
|
||
)
|
||
if (!signatureValid) throw new Error('配置文件签名验证失败')
|
||
|
||
const salt = decodeBase64Url(parts[2])
|
||
const iv = decodeBase64Url(parts[3])
|
||
const encrypted = decodeBase64Url(parts[4])
|
||
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()])
|
||
const text = plaintext.toString('utf8')
|
||
const config = JSON.parse(text)
|
||
assertConfigPayload(config, text)
|
||
return config
|
||
} catch {
|
||
throw new Error('配置文件解密或内容校验失败')
|
||
}
|
||
}
|
||
|
||
function findConfigFile(projectRoot) {
|
||
const configDirectory = path.resolve(projectRoot, CONFIG_DIRECTORY_RELATIVE_PATH)
|
||
if (!fs.existsSync(configDirectory)) return null
|
||
const candidates = fs
|
||
.readdirSync(configDirectory, { withFileTypes: true })
|
||
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.xuqmconfig'))
|
||
.map(entry => path.join(configDirectory, entry.name))
|
||
.sort()
|
||
if (candidates.length === 0) return null
|
||
if (candidates.length > 1) {
|
||
throw new Error(
|
||
`[XuqmConfig] ${configDirectory}: 只能存在一个 .xuqmconfig 文件,当前发现 ${candidates.length} 个`,
|
||
)
|
||
}
|
||
return candidates[0]
|
||
}
|
||
|
||
function applyBugCollect(metroConfig) {
|
||
// 不再自动链式 withBugCollect:基于 customSerializer 的 sourcemap 上传在 Metro 0.84
|
||
// 下不可靠(exports 限制无法获取默认 serializer,会破坏 dev 打包)。
|
||
// sourcemap 上传改由独立后处理脚本(react-native bundle 后单独上传)实现。
|
||
return metroConfig
|
||
}
|
||
|
||
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()
|
||
|
||
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;\nexport const BUILD_OPTIONS = ${JSON.stringify({ bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform' })} as const;\n`,
|
||
'utf-8',
|
||
)
|
||
|
||
// 拦截默认空实现,将 @xuqm/rn-common/auto-init-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 = { decryptConfigAtBuildTime, findConfigFile, withXuqmConfig }
|