// sootsim plugin for One/Vite — serves the installed sootsim runtime at /__soot/ // from ~/.rnx/runtimes/. version-owned localhost origins remain // bound to that directory across an active-runtime update. // // usage in vite.config.ts: // import { rnxPlugin } from 'rnxsim/vite' // export default { plugins: [one(), rnxPlugin()] } import fs from 'fs' import { randomUUID } from 'node:crypto' import path from 'path' import { DEFAULT_SOOTSIM_BRIDGE_PORT } from './bridge-constants' import { launchRnxDesktop, resolveRnxDesktopAppName, resolveRnxDesktopLaunchTarget, tagRnxBundleForPlugin, type RnxDesktopOpenOption, } from './dev-server-open' import { isDaemonLockfileFresh, readDaemonLockfile } from './home-paths' import { handleReplacementModuleRequest } from './host/replacement-module-handler' import { isRootRuntimeAssetPath, resolveRuntimeFilePath, resolveRuntimeRootForRequestHost, serveRuntimeFile, SOOTSIM_RUNTIME_MISSING_MESSAGE, } from './runtime-assets' import type { Plugin } from 'vite' export interface RnxPluginOptions { // custom bundle URL (default: auto-detect from One's metro) bundleUrl?: string // path prefix (default: '/__soot') prefix?: string // disable sootsim enabled?: boolean // launch the sootsim simulator loading this server's bundle once the dev // server is listening. skipped when CI or RNX_NO_OPEN is set. open?: RnxDesktopOpenOption } export function rnxPlugin(options: RnxPluginOptions = {}): Plugin { const prefix = options.prefix || '/__soot' return { name: 'sootsim-one', // use-latest-callback ships a CJS main (module.exports, no default) with // an esm.mjs shim that re-imports it. react-native-bottom-tabs // default-imports it from source served live, so vite hands the browser // the raw CJS file and the import fails with 'does not provide an export // named default' before anything mounts. forcing it through dep // optimization lets esbuild's CJS interop answer the default import, and // every bare import of it (including the nested copy's) resolves to the // one pre-bundled instance. config() { return { optimizeDeps: { include: ['use-latest-callback'] } } }, configureServer(server) { if (options.enabled === false) return const bundleUrl = options.bundleUrl || '/node_modules/one/metro-entry.bundle?platform=ios&dev=true&minify=false' const appName = resolveRnxDesktopAppName(options.open, server.config.root) const replacementToken = randomUUID() const configuredPort = server.config.server.port || 8081 const listeningPort = (): number => { const address = server.httpServer?.address() return address && typeof address === 'object' ? address.port : configuredPort } const bundleForPort = (port: number): string => tagRnxBundleForPlugin( bundleUrl, `http://localhost:${port}`, replacementToken, server.config.root, appName, ) // build the shell HTML, injecting the bundle URL (so main.tsx loads this // app's bundle) AND window.__sootsimBridgePort. the bridge-port injection // mirrors the dev-stack bridge-host: without it the engine's // resolveBridgePort() falls back to a location-derived guess that mis-maps // a non-Contrast app port (the mobile template's :4400 -> 9068) and the sim // can't reach the bridge. resolved per-request from the daemon lockfile // (the CLI's `open` writes/uses it before the shell loads) so the engine // targets the SAME bridge the CLI does; default port otherwise. const buildShellHtml = (runtimeRoot: string): string => { const html = fs.readFileSync(path.join(runtimeRoot, 'index.html'), 'utf8') const lock = readDaemonLockfile() const bridgePort = lock && isDaemonLockfileFresh(lock) && lock.bridgePort > 0 ? lock.bridgePort : DEFAULT_SOOTSIM_BRIDGE_PORT // inject the bridge port + a default bundle (only when the URL has none, // so the CLI's resolved bundle wins), and PRESERVE the existing query. // preserving the query is essential: `rnx open` stamps the open URL // with `inspectOpen=` and matches the registered sim by that // token (waitForSimMatch). the old code forced `?bundle=` only and // stripped inspectOpen, so the sim registered but never matched and // `open` timed out after 60s. const inject = `` return html.replace('', inject + '') } // mirror Set-Cookie into a readable header for sootsim's fetch wrapper server.middlewares.use((req, res, next) => { const origWriteHead = res.writeHead.bind(res) res.writeHead = function (statusCode: number, ...args: unknown[]) { const setCookie = res.getHeader('set-cookie') if (setCookie) { const value = Array.isArray(setCookie) ? setCookie.join(', ') : String(setCookie) res.setHeader('x-sootsim-set-cookie', value) res.setHeader('access-control-expose-headers', 'x-sootsim-set-cookie') } return (origWriteHead as (...args: unknown[]) => typeof res)( statusCode, ...args, ) } as typeof res.writeHead next() }) // serve a minimal /__server-scan describing THIS vite dev server, so a // sootsim shell loaded from /__soot can resolve this app's native // bundle. dev-bundle-resolution's resolvePortViaServerScan fetches // `/__server-scan` RELATIVE to the shell origin; when the shell is loaded // from this target origin, that hits here. without it the request SPA- // falls-back to 404 and the bundle never resolves (rnx open hangs). // we describe only this server (the shell drives THIS app) — no localhost- // wide scan, so the published plugin pulls in no shell-only scanner // internals. shape matches DiscoveredServer enough for the resolver. server.middlewares.use((req, res, next) => { if ((req.url || '').split('?')[0] !== '/__server-scan') return next() const scanPort = listeningPort() const pluginBundleUrl = bundleForPort(scanPort) const absoluteBundleUrl = pluginBundleUrl.startsWith('http') ? pluginBundleUrl : `http://localhost:${scanPort}${pluginBundleUrl}` res.setHeader('content-type', 'application/json') res.end( JSON.stringify([ { port: scanPort, framework: 'one', bundleUrl: absoluteBundleUrl, ...(appName ? { projectName: appName } : {}), lastSeen: Date.now(), }, ]), ) }) server.middlewares.use((req, res, next) => { void handleReplacementModuleRequest(req, res, { projectRoot: server.config.root, requestToken: replacementToken, requireLoopbackOrigin: true, }).then((handled) => { if (!handled) next() }) }) server.middlewares.use((req, res, next) => { const url = req.url || '' const pathname = url.split('?')[0] const runtimeRoot = resolveRuntimeRootForRequestHost(req.headers.host) if (!runtimeRoot) { if ( pathname === prefix || pathname === prefix + '/' || pathname.startsWith(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. only intercept when the asset exists in the active runtime. if (isRootRuntimeAssetPath(pathname)) { const fullPath = resolveRuntimeFilePath(runtimeRoot, pathname) if (fullPath) { serveRuntimeFile(req, res, fullPath) return } } // serve the sootsim HTML shell — inject bundle URL + bridge port if (pathname === prefix || pathname === prefix + '/') { res.setHeader('content-type', 'text/html') res.end(buildShellHtml(runtimeRoot)) return } // serve runtime files under /__soot/*. if (pathname.startsWith(prefix + '/')) { const fullPath = resolveRuntimeFilePath( runtimeRoot, pathname.slice(prefix.length), ) if (fullPath) { serveRuntimeFile(req, res, fullPath) return } // extensionless in-shell routes should still load the shell. const ext = path.extname(pathname) if (!ext) { res.setHeader('content-type', 'text/html') res.end(buildShellHtml(runtimeRoot)) return } } next() }) const port = configuredPort const sootsimUrl = `http://localhost:${port}${prefix}/` console.log(`[rnx] serving at ${sootsimUrl}`) if (appName && !process.env.CI && !process.env.RNX_NO_OPEN) { server.httpServer?.once('listening', () => { const openPort = listeningPort() launchRnxDesktop( resolveRnxDesktopLaunchTarget(openPort, bundleForPort(openPort)), appName, ) }) } }, } } export const sootsimPlugin = rnxPlugin export default rnxPlugin // the `.swift` transform is opt-in per project: an app that never imports a // `.swift` file has no reason to watch for one, and an app that ships a native // ios/ tree (every swift file in it is a pod or an xcode target, not app // source) would otherwise rebuild the wrong package on every edit. a local // vite app registers `swiftPlugin` in both the bundler that builds its native // bundle and the main dev server that serves the artifact; a One app on the // rolldown native engine registers `swiftRemotePlugin` through // `native.bundlerOptions.plugins` instead, which submits to // `/api/swift-compile` and needs no local toolchain and no serving. export { swiftPlugin, type SwiftPluginOptions } from './vite-plugin-swift.ts' export { swiftRemotePlugin, type SwiftRemotePluginOptions, } from './rolldown-plugin-swift.ts'