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