server-lifecycle.ts
1 import { ChildProcess, spawn } from 'node:child_process' 2 import fs from 'node:fs' 3 import http from 'node:http' 4 import path from 'node:path' 5 import { RuntimePaths } from './paths' 6 import { findFreePort } from './free-port' 7 8 const DEFAULT_PORT = 3456 9 const HEALTH_PATH = '/api/healthz' 10 const READY_TIMEOUT_MS = 300_000 11 const POLL_INTERVAL_MS = 250 12 const SHUTDOWN_GRACE_MS = 5_000 13 const LOG_MAX_BYTES = 1_048_576 14 15 export interface ServerHandle { 16 url: string 17 port: number 18 wsPort: number 19 process: ChildProcess 20 stop: () => Promise<void> 21 } 22 23 export interface StartOptions { 24 paths: RuntimePaths 25 logFile?: string 26 onStderr?: (chunk: string) => void 27 onStdout?: (chunk: string) => void 28 onExit?: (code: number | null, signal: NodeJS.Signals | null) => void 29 } 30 31 export async function startEmbeddedServer(opts: StartOptions): Promise<ServerHandle> { 32 const port = await findFreePort(DEFAULT_PORT) 33 const wsPort = await findFreePort(port + 1) 34 35 const env: NodeJS.ProcessEnv = { 36 ...process.env, 37 NODE_ENV: 'production', 38 PORT: String(port), 39 WS_PORT: String(wsPort), 40 HOSTNAME: '127.0.0.1', 41 SWARMCLAW_HOME: opts.paths.swarmclawHome, 42 DATA_DIR: opts.paths.dataDir, 43 WORKSPACE_DIR: opts.paths.workspaceDir, 44 BROWSER_PROFILES_DIR: opts.paths.browserProfilesDir, 45 ELECTRON_RUN_AS_NODE: '1', 46 } 47 delete env.ELECTRON_NO_ATTACH_CONSOLE 48 49 const logStream = opts.logFile ? openLogStream(opts.logFile) : null 50 51 const child = spawn(process.execPath, [opts.paths.standaloneEntry], { 52 cwd: path.dirname(opts.paths.standaloneEntry), 53 env, 54 stdio: ['ignore', 'pipe', 'pipe'], 55 }) 56 57 child.stdout?.setEncoding('utf8') 58 child.stderr?.setEncoding('utf8') 59 child.stdout?.on('data', (c: string) => { 60 logStream?.write(c) 61 opts.onStdout?.(c) 62 }) 63 child.stderr?.on('data', (c: string) => { 64 logStream?.write(c) 65 opts.onStderr?.(c) 66 }) 67 child.on('exit', (code, signal) => { 68 logStream?.end(`\n[swarmclaw] server exited code=${code ?? 'null'} signal=${signal ?? 'none'}\n`) 69 opts.onExit?.(code, signal) 70 }) 71 72 const url = `http://127.0.0.1:${port}` 73 await waitForReady(url, child) 74 75 return { 76 url, 77 port, 78 wsPort, 79 process: child, 80 stop: () => stopServer(child), 81 } 82 } 83 84 function openLogStream(logFile: string): fs.WriteStream { 85 fs.mkdirSync(path.dirname(logFile), { recursive: true }) 86 try { 87 const stat = fs.statSync(logFile) 88 if (stat.size > LOG_MAX_BYTES) fs.truncateSync(logFile, 0) 89 } catch { 90 // file did not exist — will be created on first write 91 } 92 const stream = fs.createWriteStream(logFile, { flags: 'a', encoding: 'utf8' }) 93 stream.write(`\n[swarmclaw] --- server launch ${new Date().toISOString()} ---\n`) 94 return stream 95 } 96 97 export function tailLogFile(logFile: string, bytes = 4096): string { 98 try { 99 const fd = fs.openSync(logFile, 'r') 100 try { 101 const stat = fs.fstatSync(fd) 102 const toRead = Math.min(bytes, stat.size) 103 const buf = Buffer.alloc(toRead) 104 fs.readSync(fd, buf, 0, toRead, stat.size - toRead) 105 return buf.toString('utf8') 106 } finally { 107 fs.closeSync(fd) 108 } 109 } catch { 110 return '' 111 } 112 } 113 114 async function waitForReady(url: string, child: ChildProcess): Promise<void> { 115 const deadline = Date.now() + READY_TIMEOUT_MS 116 const healthUrl = `${url}${HEALTH_PATH}` 117 118 while (Date.now() < deadline) { 119 if (child.exitCode !== null) { 120 throw new Error(`standalone server exited with code ${child.exitCode} before becoming ready`) 121 } 122 const ok = await probe(healthUrl).catch(() => false) 123 if (ok) return 124 await wait(POLL_INTERVAL_MS) 125 } 126 throw new Error(`standalone server did not become ready within ${READY_TIMEOUT_MS}ms`) 127 } 128 129 function probe(url: string): Promise<boolean> { 130 return new Promise((resolve) => { 131 const req = http.get(url, (res) => { 132 res.resume() 133 resolve((res.statusCode ?? 0) < 500) 134 }) 135 req.setTimeout(1500, () => { 136 req.destroy() 137 resolve(false) 138 }) 139 req.on('error', () => resolve(false)) 140 }) 141 } 142 143 function wait(ms: number): Promise<void> { 144 return new Promise((resolve) => setTimeout(resolve, ms)) 145 } 146 147 function stopServer(child: ChildProcess): Promise<void> { 148 return new Promise((resolve) => { 149 if (child.exitCode !== null || child.killed) { 150 resolve() 151 return 152 } 153 const killTimer = setTimeout(() => { 154 try { 155 child.kill('SIGKILL') 156 } catch { 157 // already gone 158 } 159 }, SHUTDOWN_GRACE_MS) 160 161 child.once('exit', () => { 162 clearTimeout(killTimer) 163 resolve() 164 }) 165 166 try { 167 child.kill('SIGTERM') 168 } catch { 169 clearTimeout(killTimer) 170 resolve() 171 } 172 }) 173 }