import { spawn, type ChildProcess } from "node:child_process"; import { createServer as createHttpServer, request as createHttpRequest, type IncomingMessage, type Server as HttpServer, type ServerResponse, } from "node:http"; import { request as createHttpsRequest } from "node:https"; import { existsSync, readFileSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { createServer as createTcpServer, type AddressInfo, type Server as TcpServer } from "node:net"; import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { SIDECAR_ENV, SIDECAR_MESSAGES, normalizeWebSidecarMessage, type SidecarStamp, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; import { createJsonIpcServer, type JsonIpcServerHandle, type SidecarRuntimeContext, } from "@open-design/sidecar"; const HOST = process.env.OD_HOST || "127.0.0.1"; if (process.env.OD_HOST != null && !/^[a-zA-Z0-9._\-:[\]@]+$/.test(process.env.OD_HOST)) { throw new Error(`OD_HOST contains invalid characters: ${process.env.OD_HOST}`); } const DAEMON_HOST = "127.0.0.1"; const STANDALONE_BACKEND_HOST = "127.0.0.1"; const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT; const WEB_DIST_DIR_ENV = SIDECAR_ENV.WEB_DIST_DIR; const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT; const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; const WEB_OUTPUT_MODE_ENV = "OD_WEB_OUTPUT_MODE"; const WEB_STANDALONE_ROOT_ENV = "OD_WEB_STANDALONE_ROOT"; const STANDALONE_PARENT_PID_ENV = "OD_STANDALONE_PARENT_PID"; const STANDALONE_STARTUP_TIMEOUT_ENV = "OD_STANDALONE_STARTUP_TIMEOUT_MS"; const SHUTDOWN_TIMEOUT_MS = 3000; const require = createRequire(import.meta.url); type NextApp = { close?: () => Promise; getRequestHandler(): (request: IncomingMessage, response: ServerResponse) => Promise; prepare(): Promise; }; type StandaloneBackend = { exitReason(): string | null; isRunning(): boolean; origin: string; stop(): Promise; }; function createNextApp(options: { dev: boolean; dir: string }): NextApp { const createNextServer = require("next") as (nextOptions: { dev: boolean; dir: string }) => NextApp; return createNextServer(options); } export type WebSidecarHandle = { status(): Promise; stop(): Promise; waitUntilStopped(): Promise; }; function resolveWebRoot(): string { let current = dirname(fileURLToPath(import.meta.url)); for (let depth = 0; depth < 8; depth += 1) { try { const packageJson = JSON.parse(readFileSync(join(current, "package.json"), "utf8")) as { name?: unknown }; if (packageJson.name === "@open-design/web") return current; } catch { // Keep walking until the package root is found. This must work from both // sidecar/*.ts under tsx and dist/sidecar/*.js in packaged installs. } const parent = dirname(current); if (parent === current) break; current = parent; } throw new Error("failed to resolve @open-design/web package root"); } function parsePort(value: string | undefined): number { if (value == null || value.trim().length === 0) return 0; const port = Number(value); if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error(`${WEB_PORT_ENV} must be an integer between 0 and 65535`); } return port; } function parsePositiveIntegerEnv(envName: string, defaultValue: number): number { const value = process.env[envName]; if (value == null || value.trim().length === 0) return defaultValue; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`${envName} must be a positive integer`); } return parsed; } function resolveStandaloneStartupTimeoutMs(): number { return parsePositiveIntegerEnv(STANDALONE_STARTUP_TIMEOUT_ENV, 35_000); } export function createStandaloneParentMonitorImport(parentPidEnv = STANDALONE_PARENT_PID_ENV): string { const source = ` const parentPid = Number(process.env[${JSON.stringify(parentPidEnv)}]); if (Number.isInteger(parentPid) && parentPid > 0) { const isParentAlive = () => { try { process.kill(parentPid, 0); return true; } catch { return false; } }; const timer = setInterval(() => { if (process.ppid === parentPid && isParentAlive()) return; process.exit(0); }, 1000); timer.unref?.(); } `; return `data:text/javascript,${encodeURIComponent(source)}`; } export function createStandaloneServerArgs(entryPath: string): string[] { return ["--import", createStandaloneParentMonitorImport(), entryPath]; } export function resolveStandaloneBackendOrigin(port: number): string { return `http://${STANDALONE_BACKEND_HOST}:${port}`; } export function createStandaloneBackendEnv(options: { baseEnv?: NodeJS.ProcessEnv; parentPid?: number; port: number; }): NodeJS.ProcessEnv { return { ...(options.baseEnv ?? process.env), HOSTNAME: STANDALONE_BACKEND_HOST, NODE_ENV: "production", PORT: String(options.port), [STANDALONE_PARENT_PID_ENV]: String(options.parentPid ?? process.pid), }; } function resolveWebDistDir(webRoot: string): string { const configured = process.env[WEB_DIST_DIR_ENV]; if (configured == null || configured.length === 0) return join(webRoot, ".next"); return isAbsolute(configured) ? configured : join(webRoot, configured); } function resolveConfiguredStandaloneRoot(): string | null { const configured = process.env[WEB_STANDALONE_ROOT_ENV]; if (configured == null || configured.length === 0) return null; return isAbsolute(configured) ? configured : join(process.cwd(), configured); } export function resolveStandaloneServerEntry( webRoot: string = resolveWebRoot(), standaloneRoot: string | null = resolveConfiguredStandaloneRoot(), ): string | null { const distDir = resolveWebDistDir(webRoot); const configuredRoot = standaloneRoot == null || standaloneRoot.length === 0 ? null : isAbsolute(standaloneRoot) ? standaloneRoot : join(process.cwd(), standaloneRoot); const candidates = [ ...(configuredRoot == null ? [] : [ join(configuredRoot, "apps", "web", "server.js"), join(configuredRoot, "server.js"), ]), join(distDir, "standalone", "apps", "web", "server.js"), join(distDir, "standalone", "server.js"), ]; return candidates.find((candidate) => existsSync(candidate)) ?? null; } function shouldUseStandaloneOutput(runtime: SidecarRuntimeContext): boolean { return runtime.mode !== "dev" && process.env[WEB_OUTPUT_MODE_ENV] === "standalone"; } function resolveDaemonOrigin(): string | null { const port = parsePort(process.env[DAEMON_PORT_ENV]); return port === 0 ? null : `http://${DAEMON_HOST}:${port}`; } function isDaemonProxyPathname(pathname: string): boolean { return ( pathname === "/api" || pathname.startsWith("/api/") || pathname === "/artifacts" || pathname.startsWith("/artifacts/") || pathname === "/frames" || pathname.startsWith("/frames/") ); } export function resolveDaemonProxyTarget( daemonOrigin: string, requestUrl: string | undefined, ): URL | null { const target = resolveHttpProxyTarget(daemonOrigin, requestUrl); if (target == null || !isDaemonProxyPathname(target.pathname)) return null; return target; } function resolveHttpProxyTarget( origin: string, requestUrl: string | undefined, ): URL | null { if (requestUrl == null) return null; let parsedRequestUrl: URL; try { parsedRequestUrl = new URL(requestUrl, `http://${HOST}`); } catch { return null; } return new URL(`${parsedRequestUrl.pathname}${parsedRequestUrl.search}`, origin); } export function normalizeDaemonProxyOriginHeader(options: { daemonOrigin: string; origin: string | undefined; webPort: number; }): string | undefined { if (options.origin == null || options.origin.length === 0) return options.origin; const schemes = ["http", "https"]; const loopbackHosts = ["127.0.0.1", "localhost", "[::1]", HOST]; const allowedWebOrigins = new Set( schemes.flatMap((scheme) => loopbackHosts.map((host) => `${scheme}://${host}:${options.webPort}`)), ); return allowedWebOrigins.has(options.origin) ? options.daemonOrigin : options.origin; } async function proxyHttpRequest( target: URL, request: IncomingMessage, response: ServerResponse, options: { daemonWebPort?: number } = {}, ): Promise { const proxyRequestFactory = target.protocol === "https:" ? createHttpsRequest : createHttpRequest; const headers = { ...request.headers, host: target.host }; if (options.daemonWebPort != null) { const origin = normalizeDaemonProxyOriginHeader({ daemonOrigin: target.origin, origin: typeof request.headers.origin === "string" ? request.headers.origin : undefined, webPort: options.daemonWebPort, }); if (origin == null || origin.length === 0) { delete headers.origin; } else { headers.origin = origin; } } await new Promise((resolveProxy) => { const proxyRequest = proxyRequestFactory( target, { headers, method: request.method, }, (proxyResponse) => { response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers); proxyResponse.pipe(response); proxyResponse.on("end", resolveProxy); }, ); proxyRequest.on("error", (error) => { if (!response.headersSent) { response.statusCode = 502; response.setHeader("content-type", "text/plain; charset=utf-8"); } response.end(error instanceof Error ? error.message : String(error)); resolveProxy(); }); request.pipe(proxyRequest); }); } async function prepareNextApp(app: { prepare(): Promise }, dir: string): Promise { const nextEnvPath = join(dir, "next-env.d.ts"); const previousNextEnv = await readFile(nextEnvPath, "utf8").catch(() => null); await app.prepare(); if (previousNextEnv == null) { await rm(nextEnvPath, { force: true }).catch(() => undefined); return; } await writeFile(nextEnvPath, previousNextEnv, "utf8").catch(() => undefined); } async function listen(server: HttpServer | TcpServer, port: number, host = HOST): Promise { await new Promise((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen({ host, port }, () => { server.off("error", rejectListen); resolveListen(); }); }); const address = server.address() as AddressInfo | string | null; if (address == null || typeof address === "string") { throw new Error("failed to resolve Next.js server address"); } return address.port; } async function closeServer(server: HttpServer | TcpServer): Promise { if (!server.listening) return; await new Promise((resolveClose, rejectClose) => { server.close((error) => (error == null ? resolveClose() : rejectClose(error))); }); } async function reserveTcpPort(host = HOST): Promise { const server = createTcpServer(); try { return await listen(server, 0, host); } finally { await closeServer(server).catch(() => undefined); } } async function waitForChildExit(child: ChildProcess): Promise { if (child.exitCode != null || child.signalCode != null) return; await new Promise((resolveExit) => { child.once("exit", () => resolveExit()); }); } async function stopStandaloneChild(child: ChildProcess): Promise { if (child.exitCode != null || child.signalCode != null) return; child.kill("SIGTERM"); let timeout: NodeJS.Timeout | undefined; try { await Promise.race([ waitForChildExit(child), new Promise((resolveTimeout) => { timeout = setTimeout(resolveTimeout, SHUTDOWN_TIMEOUT_MS); timeout.unref(); }), ]); } finally { if (timeout != null) clearTimeout(timeout); } if (child.exitCode == null && child.signalCode == null) { child.kill("SIGKILL"); await waitForChildExit(child).catch(() => undefined); } } async function probeStandaloneBackend(origin: string): Promise { return await new Promise((resolveProbe) => { const request = createHttpRequest(new URL("/", origin), { method: "HEAD", timeout: 800 }, (response) => { response.resume(); resolveProbe(true); }); request.on("timeout", () => { request.destroy(); resolveProbe(false); }); request.on("error", () => resolveProbe(false)); request.end(); }); } async function waitForStandaloneBackendReady( child: ChildProcess, origin: string, timeoutMs = resolveStandaloneStartupTimeoutMs(), ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (child.exitCode != null || child.signalCode != null) { const elapsedMs = Date.now() - startedAt; const likelyPortRace = elapsedMs <= 200; throw new Error( `standalone Next.js server exited before readiness after ${elapsedMs}ms: code=${child.exitCode} signal=${child.signalCode}` + (likelyPortRace ? "; the reserved startup port may have been claimed before the child process bound it, retry the launch" : ""), ); } if (await probeStandaloneBackend(origin)) return; await new Promise((resolveWait) => setTimeout(resolveWait, 150)); } throw new Error(`timed out after ${timeoutMs}ms waiting for standalone Next.js server at ${origin}; override with ${STANDALONE_STARTUP_TIMEOUT_ENV}`); } async function startStandaloneBackend(webRoot: string): Promise { const entryPath = resolveStandaloneServerEntry(webRoot); if (entryPath == null) { throw new Error(`missing Next.js standalone server under ${resolveWebDistDir(webRoot)}; rebuild with ${WEB_OUTPUT_MODE_ENV}=standalone`); } const port = await reserveTcpPort(STANDALONE_BACKEND_HOST); const origin = resolveStandaloneBackendOrigin(port); console.log(`[open-design web] starting standalone Next.js server from ${entryPath}`); const child = spawn(process.execPath, createStandaloneServerArgs(entryPath), { cwd: dirname(entryPath), env: createStandaloneBackendEnv({ port }), stdio: ["ignore", "inherit", "inherit"], ...(process.platform === "win32" ? { windowsHide: true } : {}), }); await new Promise((resolveSpawn, rejectSpawn) => { child.once("error", rejectSpawn); child.once("spawn", resolveSpawn); }); let standaloneRunning = true; let standaloneExitReason: string | null = null; child.once("exit", (code, signal) => { standaloneRunning = false; standaloneExitReason = `code=${code ?? "null"} signal=${signal ?? "null"}`; console.error(`[open-design web] standalone Next.js server exited ${standaloneExitReason}`); }); try { await waitForStandaloneBackendReady(child, origin); } catch (error) { await stopStandaloneChild(child).catch(() => undefined); throw error; } return { exitReason() { return standaloneExitReason; }, isRunning() { return standaloneRunning && child.exitCode == null && child.signalCode == null; }, origin, async stop() { await stopStandaloneChild(child); }, }; } async function settleShutdownTask(task: Promise | undefined): Promise { if (task == null) return; let timeout: NodeJS.Timeout | undefined; try { await Promise.race([ task.catch(() => undefined), new Promise((resolveTimeout) => { timeout = setTimeout(resolveTimeout, SHUTDOWN_TIMEOUT_MS); timeout.unref(); }), ]); } finally { if (timeout != null) clearTimeout(timeout); } } function stopThenExit(stop: () => Promise): void { const hardExit = setTimeout(() => process.exit(0), SHUTDOWN_TIMEOUT_MS + 1000); hardExit.unref(); void stop().finally(() => { clearTimeout(hardExit); process.exit(0); }); } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } function attachParentMonitor(stop: () => Promise): void { const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]); if (!Number.isInteger(parentPid) || parentPid <= 0) return; const timer = setInterval(() => { if (isProcessAlive(parentPid)) return; clearInterval(timer); stopThenExit(stop); }, 1000); timer.unref(); } async function createWebSidecarHandle( runtime: SidecarRuntimeContext, httpServer: HttpServer, closeRuntime: () => Promise | void, isRuntimeRunning?: () => boolean, ): Promise { const port = await listen(httpServer, parsePort(process.env[WEB_PORT_ENV])); const state: WebStatusSnapshot = { pid: process.pid, state: "running", updatedAt: new Date().toISOString(), url: `http://${HOST}:${port}`, }; let ipcServer: JsonIpcServerHandle | null = null; let stopped = false; let resolveStopped!: () => void; const stoppedPromise = new Promise((resolveStop) => { resolveStopped = resolveStop; }); function refreshRuntimeState(): void { if (stopped || isRuntimeRunning == null || isRuntimeRunning()) return; state.state = "stopped"; state.url = null; state.updatedAt = new Date().toISOString(); } async function stop(): Promise { if (stopped) return; stopped = true; state.state = "stopped"; state.updatedAt = new Date().toISOString(); await settleShutdownTask(ipcServer?.close()); await settleShutdownTask(closeServer(httpServer)); await settleShutdownTask(Promise.resolve().then(closeRuntime)); resolveStopped(); } attachParentMonitor(stop); ipcServer = await createJsonIpcServer({ socketPath: runtime.ipc, handler: async (message: unknown) => { const request = normalizeWebSidecarMessage(message); switch (request.type) { case SIDECAR_MESSAGES.STATUS: refreshRuntimeState(); return { ...state }; case SIDECAR_MESSAGES.SHUTDOWN: setImmediate(() => { stopThenExit(stop); }); return { accepted: true }; } }, }); for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { stopThenExit(stop); }); } return { async status() { refreshRuntimeState(); return { ...state }; }, stop, waitUntilStopped() { return stoppedPromise; }, }; } function createDaemonProxyHandler( daemonOrigin: string | null, fallback: (request: IncomingMessage, response: ServerResponse) => Promise, ): (request: IncomingMessage, response: ServerResponse) => void { return (request, response) => { const daemonProxyTarget = daemonOrigin == null ? null : resolveDaemonProxyTarget(daemonOrigin, request.url); if (daemonProxyTarget != null) { const localPort = request.socket.localPort; void proxyHttpRequest(daemonProxyTarget, request, response, { daemonWebPort: typeof localPort === "number" ? localPort : 0, }).catch((error: unknown) => { response.statusCode = 502; response.end(error instanceof Error ? error.message : String(error)); }); return; } void fallback(request, response).catch((error: unknown) => { response.statusCode = 500; response.end(error instanceof Error ? error.message : String(error)); }); }; } async function startRegularNextSidecar( runtime: SidecarRuntimeContext, webRoot: string, ): Promise { const app = createNextApp({ dev: process.env.OD_WEB_PROD !== "1" && runtime.mode === "dev", dir: webRoot }); await prepareNextApp(app, webRoot); const daemonOrigin = resolveDaemonOrigin(); const handleRequest = app.getRequestHandler(); const httpServer = createHttpServer(createDaemonProxyHandler(daemonOrigin, handleRequest)); return await createWebSidecarHandle(runtime, httpServer, async () => { await app.close?.(); }); } async function startStandaloneNextSidecar( runtime: SidecarRuntimeContext, webRoot: string, ): Promise { const daemonOrigin = resolveDaemonOrigin(); const backend = await startStandaloneBackend(webRoot); const httpServer = createHttpServer(createDaemonProxyHandler(daemonOrigin, async (request, response) => { if (!backend.isRunning()) { response.statusCode = 502; response.end(`standalone Next.js server is not running${backend.exitReason() == null ? "" : ` (${backend.exitReason()})`}`); return; } const target = resolveHttpProxyTarget(backend.origin, request.url); if (target == null) { response.statusCode = 400; response.end("invalid request URL"); return; } await proxyHttpRequest(target, request, response); })); try { return await createWebSidecarHandle(runtime, httpServer, backend.stop, backend.isRunning); } catch (error) { await backend.stop().catch(() => undefined); throw error; } } export async function startWebSidecar(runtime: SidecarRuntimeContext): Promise { const webRoot = resolveWebRoot(); if (shouldUseStandaloneOutput(runtime)) { return await startStandaloneNextSidecar(runtime, webRoot); } return await startRegularNextSidecar(runtime, webRoot); }