// rnx metro plugin for dev shell and bundle module identity annotation. // shell assets come from an engine runtime under ~/.rnx/runtimes/, fetched // by `rnx runtime install`. ordinary localhost requests // follow active; a version-owned localhost origin stays on its exact directory // when active changes, so one loaded shell cannot mix runtime chunk trees. // // usage in metro.config.js: // const { withRNX } = require('rnxsim/metro') // module.exports = withRNX(getDefaultConfig(__dirname)) import fs from 'fs' import { createRequire } from 'module' import { randomUUID } from 'node:crypto' import path from 'path' import { tagRnxBundleForPlugin } from './dev-server-open' import { handleReplacementModuleRequest, isReplacementModuleRequestUrl, } from './host/replacement-module-handler' import { appendRNXMetroModuleIdentityFooter, RNX_METRO_MODULE_IDENTITY_VERSION, toRNXDevelopmentBundleUrl, toRNXProductionBundleUrl, type RNXMetroModuleIdentityMetadata, } from './metro-production-bundle' import { isRootRuntimeAssetPath, resolveRuntimeFilePath, resolveRuntimeRootForRequestHost, serveRuntimeFile, SOOTSIM_RUNTIME_MISSING_MESSAGE, } from './runtime-assets' const prefix = '/__soot' export { toRNXDevelopmentBundleUrl, toRNXProductionBundleUrl } type MetroSerializerModule = { path: string dependencies?: ReadonlyMap< string, { absolutePath?: string data?: { name?: string } } > } type MetroSerializerGraph = { dependencies?: ReadonlyMap } type MetroSerializerOptions = { createModuleId?: (modulePath: string) => number dev?: boolean [key: string]: unknown } type MetroSerializerResult = string | { code: string; map: string } type MetroSerializer = ( entryPoint: string, preModules: readonly unknown[], graph: MetroSerializerGraph, options: MetroSerializerOptions, ) => MetroSerializerResult | Promise type MetroConfigWithServer = { projectRoot?: string serializer?: { customSerializer?: MetroSerializer [key: string]: unknown } server?: { enhanceMiddleware?: (middleware: unknown, server: unknown) => unknown port?: number [key: string]: unknown } } function readCallableMetroExport( moduleValue: unknown, label: string, ): (...args: readonly unknown[]) => unknown { const callable = typeof moduleValue === 'function' ? moduleValue : typeof moduleValue === 'object' && moduleValue !== null ? Reflect.get(moduleValue, 'default') : undefined if (typeof callable !== 'function') { throw new Error(`rnxsim: Metro ${label} module did not export a function`) } return (...args) => Reflect.apply(callable, undefined, args) } function createMetroDefaultSerializer(projectRoot: string): MetroSerializer { const projectRequire = createRequire(path.join(projectRoot, 'package.json')) const metroRoot = path.dirname(projectRequire.resolve('metro/package.json')) const baseJSBundle = readCallableMetroExport( projectRequire(path.join(metroRoot, 'src/DeltaBundler/Serializers/baseJSBundle.js')), 'baseJSBundle', ) const bundleToString = readCallableMetroExport( projectRequire(path.join(metroRoot, 'src/lib/bundleToString.js')), 'bundleToString', ) return (entryPoint, preModules, graph, options) => { const serialized = bundleToString( baseJSBundle(entryPoint, preModules, graph, options), ) const code = typeof serialized === 'object' && serialized !== null ? Reflect.get(serialized, 'code') : undefined if (typeof code !== 'string') { throw new Error('rnxsim: Metro bundleToString did not return bundle code') } return code } } // the only thing rnx adds to an ordinary Metro bundle. production needs the // footer because Metro drops the fourth `__d` argument when `dev` is false; // development keeps that verbose name and receives the same identity without // changing resolution, transforms, or the trailing source map. function appendRNXModulePaths( result: MetroSerializerResult, graph: MetroSerializerGraph, options: MetroSerializerOptions, projectRoot: string, ): MetroSerializerResult { const dependencies = graph.dependencies const createModuleId = options.createModuleId if (!dependencies || !createModuleId) { return result } const modulePaths: Record = {} for (const module of dependencies.values()) { modulePaths[String(createModuleId(module.path))] = path .relative(projectRoot, module.path) .replaceAll(path.sep, '/') } if (Object.keys(modulePaths).length !== dependencies.size) { throw new Error('rnxsim: bundle module IDs were not unique') } const specifiersByModuleId = new Map>() for (const module of dependencies.values()) { for (const [dependencyKey, dependency] of module.dependencies ?? []) { const specifier = dependency.data?.name ?? dependencyKey if ( specifier.length === 0 || specifier.startsWith('.') || specifier.startsWith('/') || typeof dependency.absolutePath !== 'string' ) { continue } const moduleId = String(createModuleId(dependency.absolutePath)) if (!Object.prototype.hasOwnProperty.call(modulePaths, moduleId)) continue const found = specifiersByModuleId.get(moduleId) if (found) found.add(specifier) else specifiersByModuleId.set(moduleId, new Set([specifier])) } } const logicalSpecifiers = Object.fromEntries( [...specifiersByModuleId] .sort(([left], [right]) => Number(left) - Number(right)) .map(([moduleId, specifiers]) => [moduleId, [...specifiers].sort()]), ) const identity: RNXMetroModuleIdentityMetadata = { version: RNX_METRO_MODULE_IDENTITY_VERSION, identitySource: 'rnx-plugin', modulePaths, logicalSpecifiers, } return typeof result === 'string' ? appendRNXMetroModuleIdentityFooter(result, identity) : { ...result, code: appendRNXMetroModuleIdentityFooter(result.code, identity) } } export function withRNX>(config: T): T { const cfgProjectRoot = (config as MetroConfigWithServer).projectRoot || process.cwd() const configuredBundleUrl = '/index.bundle?platform=ios&dev=true&hot=true&minify=false' const cfgServer = (config as MetroConfigWithServer).server const port = resolveMetroPort(cfgServer) const metroServerId = randomUUID() const bundleUrl = tagRnxBundleForPlugin( configuredBundleUrl, `http://localhost:${port}`, metroServerId, cfgProjectRoot, ) const cfgSerializer = (config as MetroConfigWithServer).serializer const existingEnhance = cfgServer?.enhanceMiddleware const existingSerializer = cfgSerializer?.customSerializer let defaultSerializer: MetroSerializer | undefined const serializerConfig = { serializer: { ...cfgSerializer, customSerializer: async ( entryPoint: string, preModules: readonly unknown[], graph: MetroSerializerGraph, serializerOptions: MetroSerializerOptions, ) => { const serializer = existingSerializer || (defaultSerializer ||= createMetroDefaultSerializer(cfgProjectRoot)) const result = await serializer(entryPoint, preModules, graph, serializerOptions) return appendRNXModulePaths(result, graph, serializerOptions, cfgProjectRoot) }, }, } const serverConfig = { ...cfgServer, enhanceMiddleware: (metroMiddleware: any, metroServer: any) => { let middleware = metroMiddleware if (existingEnhance) { middleware = existingEnhance(metroMiddleware, metroServer) } let connect: any try { connect = require('connect') } catch { console.warn('[rnx] connect not available') return middleware } const server = connect() server.use((req: any, res: any, next: any) => { if (isReplacementModuleRequestUrl(req.url)) { void handleReplacementModuleRequest(req, res, { projectRoot: cfgProjectRoot, requestToken: metroServerId, requireLoopbackOrigin: true, }).then((handled) => { if (!handled) next() }) return } const url = req.url || '' const pathname = url.split('?')[0] const runtimeRoot = resolveRuntimeRootForRequestHost(req.headers?.host) if (!runtimeRoot) { if (pathname === prefix || pathname === prefix + '/') { res.statusCode = 500 res.end(SOOTSIM_RUNTIME_MISSING_MESSAGE) return } next() return } // root-relative runtime assets emitted by the shell and engine vite // builds. if a user app has its own /assets/foo request, we only // intercept when that exact file exists in the runtime; otherwise // metro handles it normally. if (isRootRuntimeAssetPath(pathname)) { const fullPath = resolveRuntimeFilePath(runtimeRoot, pathname) if (fullPath) { serveRuntimeFile(req, res, fullPath) return } } // /__soot/* — serve runtime assets if (pathname.startsWith(prefix + '/')) { const fullPath = resolveRuntimeFilePath( runtimeRoot, pathname.slice(prefix.length), ) if (fullPath) { serveRuntimeFile(req, res, fullPath) return } } // serve HTML shell for /__soot and extensionless in-shell routes. if ( pathname === prefix || pathname === prefix + '/' || pathname.startsWith(prefix + '/') ) { const ext = path.extname(pathname) if (!ext) { const htmlPath = path.join(runtimeRoot, 'index.html') let html = fs.readFileSync(htmlPath, 'utf8') const script = `` html = html.includes('') ? html.replace('', `${script}`) : `${html}${script}` res.setHeader('content-type', 'text/html') res.end(html) return } } next() }) server.use(middleware) console.log(`[rnx] serving at ${prefix}/`) return server }, } return { ...config, ...serializerConfig, server: serverConfig, } as T } function resolveMetroPort(server: MetroConfigWithServer['server']): number { const inlinePort = process.argv.find((arg) => arg.startsWith('--port='))?.slice(7) const portIndex = process.argv.indexOf('--port') const splitPort = portIndex >= 0 ? process.argv[portIndex + 1] : undefined const rawPort = inlinePort || splitPort || process.env.RCT_METRO_PORT || server?.port || 8081 const port = Number(rawPort) if (!Number.isInteger(port) || port <= 0) { throw new Error(`rnx: Metro port must be a positive integer, got ${rawPort}`) } return port } export default withRNX