/ electron / server-lifecycle.ts
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  }