import { type ChildProcess, spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { createServer } from 'node:net' import { join } from 'node:path' import killProcessTree from 'tree-kill' const SERVER_HOST = '127.0.0.1' const SERVER_READY_TIMEOUT_MS = 10_000 const SERVER_REQUEST_TIMEOUT_MS = 1_500 const SERVER_POLL_INTERVAL_MS = 250 const SERVER_PROBE_PATHS = ['/api/health', '/'] type SidecarState = { process: ChildProcess | null startup: Promise | null url: string | null } type SidecarRuntimeOptions = { devServerUrl: string isPackaged: boolean resourcesPath: string isQuitting: () => boolean onUnexpectedStop: (detail: string) => void } type SidecarRuntime = { resolveUrl: () => Promise stop: () => void lastResolvedUrl: string | null } const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) const isProcessAlive = (processToCheck: ChildProcess | null): processToCheck is ChildProcess => { if (!processToCheck || !processToCheck.pid) { return false } return processToCheck.exitCode === null && !processToCheck.killed } const getAvailablePort = (): Promise => new Promise((resolve, reject) => { const server = createServer() server.listen(0, () => { const addr = server.address() if (!addr || typeof addr === 'string') { server.close() reject(new Error('Failed to resolve port')) return } server.close(() => resolve(addr.port)) }) server.on('error', reject) }) const isServerReady = async (url: string): Promise => { for (const probePath of SERVER_PROBE_PATHS) { try { const probeUrl = new URL(probePath, `${url}/`) const response = await fetch(probeUrl, { method: 'GET', cache: 'no-store', signal: AbortSignal.timeout(SERVER_REQUEST_TIMEOUT_MS), }) if (response.status < 500) { if (probePath === '/api/health' && response.status === 404) { continue } return true } } catch { // Expected: probe request fails while server is still starting up } } return false } const waitForServer = async (url: string, isQuitting: () => boolean, processRef?: ChildProcess): Promise => { const start = Date.now() while (Date.now() - start < SERVER_READY_TIMEOUT_MS && !isQuitting()) { if (processRef && processRef.exitCode !== null) { return false } if (await isServerReady(url)) { return true } await sleep(SERVER_POLL_INTERVAL_MS) } return false } const resolveBinaryPath = (resourcesPath: string): string => { const binaryName = process.platform === 'win32' ? 'server.exe' : 'server' return join(resourcesPath, binaryName) } const formatUnexpectedStopMessage = ( isPackaged: boolean, code: number | null, signal: NodeJS.Signals | null, ): string => { if (isPackaged) { return 'The background service stopped unexpectedly. Please restart the app.' } return `Server process exited unexpectedly (code ${code ?? 'unknown'}, signal ${signal ?? 'none'}).` } export const createSidecarRuntime = (options: SidecarRuntimeOptions): SidecarRuntime => { const state: SidecarState = { process: null, startup: null, url: null, } const resetState = (processRef?: ChildProcess) => { if (processRef && state.process !== processRef) { return } state.process = null state.url = null } const stop = () => { const runningServer = state.process resetState() if (!runningServer?.pid || runningServer.exitCode !== null) { return } killProcessTree(runningServer.pid, 'SIGTERM', (error?: Error) => { if (error) { console.error('Failed to stop server process:', error) } }) } const attachLifecycleHandlers = (processRef: ChildProcess) => { processRef.on('error', (error) => { if (state.process !== processRef) { return } const hadReadyServer = state.url !== null resetState(processRef) if (!options.isQuitting() && hadReadyServer) { options.onUnexpectedStop('The background service crashed unexpectedly. Please restart the app.') return } console.error('Failed to start server process:', error) }) processRef.on('exit', (code, signal) => { if (state.process !== processRef) { return } const hadReadyServer = state.url !== null resetState(processRef) if (!options.isQuitting() && hadReadyServer) { options.onUnexpectedStop(formatUnexpectedStopMessage(options.isPackaged, code, signal)) } }) } const startPackagedServer = async (): Promise => { if (state.url && isProcessAlive(state.process)) { return state.url } if (state.startup) { return state.startup } state.startup = (async () => { const binaryPath = resolveBinaryPath(options.resourcesPath) if (!existsSync(binaryPath)) { throw new Error(`Sidecar server binary is missing: ${binaryPath}`) } if (options.isQuitting()) { throw new Error('Application is shutting down.') } const port = await getAvailablePort() const nextServerUrl = `http://${SERVER_HOST}:${port}` const processRef = spawn(binaryPath, [], { env: { ...process.env, HOST: SERVER_HOST, PORT: String(port), }, stdio: 'ignore', windowsHide: true, }) processRef.unref() state.process = processRef attachLifecycleHandlers(processRef) const ready = await waitForServer(nextServerUrl, options.isQuitting, processRef) if (ready && isProcessAlive(processRef)) { state.url = nextServerUrl return nextServerUrl } const failureReason = processRef.exitCode !== null ? `The service exited early (code ${processRef.exitCode}).` : `The service did not respond at ${nextServerUrl} within 10 seconds.` stop() throw new Error(failureReason) })().finally(() => { state.startup = null }) return state.startup } const resolveUrl = async (): Promise => { if (options.isPackaged) { return startPackagedServer() } const ready = await waitForServer(options.devServerUrl, options.isQuitting) if (!ready) { throw new Error('Dev server not responding. Run `bun dev` in apps/server first.') } state.url = options.devServerUrl return options.devServerUrl } return { resolveUrl, stop, get lastResolvedUrl() { return state.url }, } }