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