// hidden child-process entry points. // // the CLI needs separate JS processes for a few jobs: a detached browser host // that outlives `rnx open`, playwright's own installer, and a blocking fetch // worker for maestro's synchronous `http` API. each used to run as // `process.execPath -e ''`, which cannot work once the CLI ships as a // compiled binary: there process.execPath is rnx, and rnx has no -e. a // shell-installed user has no node and no bun either, so there is nothing else // to reach for. the binary is the JS runtime, and it runs these itself. // // spawn them through rnxSelfInvocation() so the npm and standalone shapes take // the same path. bin.ts dispatches here straight from argv, before any CLI // startup work, which keeps a per-request worker (sync-http) cheap. import { existsSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import * as vm from 'node:vm' export const RNX_INTERNAL_COMMAND = '__internal' export const RNX_INTERNAL_CHILDREN = { playwrightProbe: 'playwright-probe', playwrightInstall: 'playwright-install', playwrightHost: 'playwright-host', syncHttp: 'sync-http', } as const /** require() rooted at the working directory, which is where a user's * node_modules lives. the compiled binary carries none of its own. */ function requireFromCwd(): NodeJS.Require { return createRequire(`${process.cwd()}/`) } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } /** what a one-shot child hands back to the caller that is blocked reading it. */ interface ChildReply { stdout: string code: number } export async function runInternalChild(args: string[]): Promise { const [name, ...rest] = args let reply: ChildReply | undefined try { switch (name) { case RNX_INTERNAL_CHILDREN.playwrightProbe: reply = playwrightProbe() break case RNX_INTERNAL_CHILDREN.playwrightInstall: playwrightInstall(rest) break case RNX_INTERNAL_CHILDREN.playwrightHost: await playwrightHost() break case RNX_INTERNAL_CHILDREN.syncHttp: reply = await syncHttp() break default: throw new Error(`unknown internal child: ${name ?? '(none)'}`) } } catch (error) { writeFileSync(2, `${error instanceof Error ? error.stack : String(error)}\n`) process.exit(1) } // the two one-shot children answer a caller that is blocked in spawnSync // until this process ends, so end it on the reply rather than whenever the // runtime decides nothing is left open. that is only safe because the write // goes through the file descriptor: through process.stdout a pipe write // completes asynchronously on macos, and exiting would truncate the json // being parsed on the other side. if (reply) { writeFileSync(1, reply.stdout) process.exit(reply.code) } // playwright's installer and the browser host are both still working when // they return here. they own the process from now on, and it ends when their // own work does. } /** report where the resolved playwright package expects its chromium and * whether that file is there. a separate process so the caller's environment * snapshot — PLAYWRIGHT_BROWSERS_PATH above all — is the only browser cache * this ever sees. */ function playwrightProbe(): ChildReply { const modulePath = process.env.SOOTSIM_PW_MODULE if (!modulePath) throw new Error('SOOTSIM_PW_MODULE is not set') const playwright: unknown = requireFromCwd()(modulePath) const chromium = isRecord(playwright) ? playwright.chromium : null if (!isRecord(chromium) || typeof chromium.executablePath !== 'function') { throw new Error('resolved package does not expose browser "chromium"') } const executablePath: unknown = chromium.executablePath() if (typeof executablePath !== 'string') { throw new Error('browser "chromium" reported no executable path') } return { stdout: JSON.stringify({ executablePath, exists: existsSync(executablePath) }), code: 0, } } /** run playwright's own CLI entry, the only thing allowed to install the * browser revision its API named. argv is shaped the way that CLI's argument * parser expects to receive it from node. it owns this process from here: * it sets the exit code and may exit outright. */ function playwrightInstall(args: string[]): void { const [cliPath, ...cliArgs] = args if (!cliPath) { throw new Error('internal playwright-install needs the package CLI path') } process.argv = [process.argv[0], cliPath, ...cliArgs] requireFromCwd()(cliPath) } /** the detached browser host. its source is CJS that resolves playwright from * an absolute path the driver hands it in the environment, launches chrome, * and keeps this process alive until the browser goes away. */ async function playwrightHost(): Promise { const { PLAYWRIGHT_SIM_HOST } = await import('./drivers/playwright-sim-host') const run: unknown = vm.runInThisContext( `(function(require){${PLAYWRIGHT_SIM_HOST}\n})`, { filename: 'rnx-playwright-host.js' }, ) if (typeof run !== 'function') throw new Error('browser host source is not runnable') run(requireFromCwd()) } /** one blocking fetch. maestro's flow `http` API is synchronous and node has * no synchronous fetch, so the caller round-trips each request through a * spawnSync of this: JSON request on stdin, JSON response on stdout. a failed * request is still a reply — the flow engine reads the error out of the same * json — so only the exit code separates the two. */ async function syncHttp(): Promise { const chunks: Buffer[] = [] for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)) try { const request: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) if (!isRecord(request) || typeof request.url !== 'string') { throw new Error('sync-http needs a JSON request with a url') } const response = await fetch(request.url, { method: typeof request.method === 'string' ? request.method : undefined, headers: isRecord(request.headers) ? readStringMap(request.headers) : undefined, body: typeof request.body === 'string' ? request.body : undefined, }) const body = await response.text() const headers: Record = {} for (const [key, value] of response.headers.entries()) { headers[key] = headers[key] ? `${headers[key]},${value}` : value } return { stdout: JSON.stringify({ ok: response.ok, status: response.status, body, headers }), code: 0, } } catch (error) { return { stdout: JSON.stringify({ __error: error instanceof Error ? error.message : String(error), }), code: 1, } } } function readStringMap(value: Record): Record { const result: Record = {} for (const [key, entry] of Object.entries(value)) { if (typeof entry === 'string') result[key] = entry } return result }