import { spawn, spawnSync } from "node:child_process"; import { resolveServiceCwd } from "./config.js"; import { isUnresolved, substitute } from "./templates.js"; import { assertInstanceKey, prepareInstanceWorkspace, recordInstall, removeInstanceWorkspace } from "./instance-workspace.js"; const DEFAULT_LOG_LIMIT = 256 * 1024; const SHARED = ""; function appendLog(instance, stream, chunk) { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); instance.logs += `[${stream}] ${text}`; const bytes = Buffer.byteLength(instance.logs); if (bytes > instance.logLimit) instance.logs = Buffer.from(instance.logs).subarray(bytes - instance.logLimit).toString("utf8"); } function cleanEnvironment(extra, { port, npmRegistry, npmCache, home, childPath, proxy, javaHome }) { const resolved = {}; for (const [name, value] of Object.entries(extra ?? {})) resolved[name] = substitute(value, { port }); return { PATH: childPath, HOME: home, TMPDIR: home, NODE_ENV: "development", NO_COLOR: "1", ...(npmRegistry ? { npm_config_registry: npmRegistry } : {}), ...(npmCache ? { npm_config_cache: npmCache } : {}), // A JVM finds its own home through JAVA_HOME before it looks at PATH, and ./mvnw refuses to // run without one of the two. Forwarded rather than hardcoded: the image decides where it is. ...(javaHome ? { JAVA_HOME: javaHome } : {}), ...proxyEnvironment(proxy), ...resolved }; } /** * The only route out of this container, handed to every child. * *
Both spellings, because the ecosystems disagree: curl and pip read the lowercase names, npm * and many Node tools the uppercase ones. Maven reads neither - a JVM takes its proxy from system * properties - so a Maven service has to carry -Dhttps.proxyHost in the arguments the operator * declares. That is not an oversight to fix here: the arguments are operator-owned on purpose. */ function proxyEnvironment(proxy) { if (!proxy?.url) return {}; const values = { HTTP_PROXY: proxy.url, HTTPS_PROXY: proxy.url, http_proxy: proxy.url, https_proxy: proxy.url }; if (proxy.noProxy) { values.NO_PROXY = proxy.noProxy; values.no_proxy = proxy.noProxy; } return values; } function publicStatus(definition, instance) { // Without a running instance there is no port, so a port-templated URL is not an address yet. // Reporting null says that; reporting the template would invite a caller to try it. const resolvedUrl = substitute(definition.publicUrl, { port: instance?.port }); return { service: definition.id, key: instance?.key || null, status: instance?.status ?? "stopped", pid: instance?.child?.pid ?? null, port: instance?.port ?? null, startedAt: instance?.startedAt ?? null, exitedAt: instance?.exitedAt ?? null, exitCode: instance?.exitCode ?? null, signal: instance?.signal ?? null, cwd: instance?.clientCwd ?? definition.clientCwd, publicUrl: isUnresolved(resolvedUrl) ? null : (resolvedUrl ?? null) }; } async function healthCheck(url, timeoutMs) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { redirect: "error", signal: controller.signal }); return response.status < 500; } catch { return false; } finally { clearTimeout(timer); } } export class DevServerService { constructor({ services, logLimit = DEFAULT_LOG_LIMIT, workspaceRoot = null, instancesRoot = null, portAllocator = null, maxInstances = 4, npmRegistry = null, npmCache = null, workspaceLimits = null, instanceHome = "/tmp/dev-server", childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null }) { this.services = services; this.instances = new Map(); this.logLimit = logLimit; this.workspaceRoot = workspaceRoot; this.instancesRoot = instancesRoot; this.portAllocator = portAllocator; this.maxInstances = maxInstances; this.npmRegistry = npmRegistry; this.npmCache = npmCache; this.workspaceLimits = workspaceLimits; this.instanceHome = instanceHome; this.childPath = childPath; this.proxy = proxy; this.javaHome = javaHome; } definition(service) { if (typeof service !== "string" || !this.services.has(service)) throw new Error("Unknown service"); return this.services.get(service); } /** * The key an instance is filed under: the execution for a per-execution service, and a single * shared slot for the operator's fixed ones. A caller that supplies no key can only ever reach * the shared slot, so a missing header cannot silently land on someone else's instance. */ slot(definition, key) { if (definition.workspaceMode !== "execution") return SHARED; if (key === undefined || key === null || key === "") throw new Error(`Service ${definition.id} runs per execution and needs an execution key`); return assertInstanceKey(key); } handle(definition, key) { return `${definition.id}\u0000${this.slot(definition, key)}`; } list({ key } = {}) { return { services: [...this.services.values()].map((definition) => { // A definition the caller cannot address is still worth listing - the model has to know // the service exists - but its state is only ever the caller's own. let instance = null; try { instance = this.instances.get(this.handle(definition, key)) ?? null; } catch { instance = null; } return { ...publicStatus(definition, instance), workspaceMode: definition.workspaceMode }; }) }; } status({ service, key }) { const definition = this.definition(service); return publicStatus(definition, this.instances.get(this.handle(definition, key))); } runningCount() { let running = 0; for (const instance of this.instances.values()) if (instance.child) running++; return running; } async start({ service, key }) { const definition = this.definition(service); const handle = this.handle(definition, key); const slot = this.slot(definition, key); const current = this.instances.get(handle); if (current && ["starting", "running", "stopping"].includes(current.status)) throw new Error("Service is already active"); if (this.runningCount() >= this.maxInstances) { throw new Error(`Already running ${this.maxInstances} services: raise DEV_SERVER_MAX_INSTANCES or stop one`); } const instance = { key: slot, status: "starting", startedAt: new Date().toISOString(), exitedAt: null, exitCode: null, signal: null, logs: current?.logs ?? "", logLimit: this.logLimit, child: null, port: null, cwd: null, home: null, clientCwd: definition.clientCwd }; this.instances.set(handle, instance); try { const workspace = definition.workspaceMode === "execution" ? this.prepare(definition, slot, instance) : { root: definition.cwd, cwd: definition.cwd, home: this.instanceHome }; instance.cwd = workspace.cwd; instance.home = workspace.home ?? null; if (definition.needsPort) instance.port = await this.portAllocator.take(handle); await this.spawnChild(definition, instance); await this.awaitHealth(definition, instance, handle); instance.status = "running"; return publicStatus(definition, instance); } catch (error) { instance.status = "stopped"; instance.exitedAt = new Date().toISOString(); if (instance.port !== null) { this.portAllocator?.release(instance.port); instance.port = null; } appendLog(instance, "error", `${error instanceof Error ? error.message : error}\n`); throw error; } } /** Refreshes the execution's copy and installs its dependencies if the lockfile moved. */ prepare(definition, slot, instance) { if (!this.workspaceRoot || !this.instancesRoot) throw new Error("This worker is not configured for per-execution services"); const prepared = prepareInstanceWorkspace({ workspaceRoot: this.workspaceRoot, instancesRoot: this.instancesRoot, key: slot, limits: this.workspaceLimits }); appendLog(instance, "worker", `copied ${prepared.entries} entries (${prepared.bytes} bytes) for execution ${slot}\n`); const cwd = resolveServiceCwd(definition.relativeCwd, prepared.target, definition.id); instance.clientCwd = definition.clientCwd; if (definition.install && !prepared.installIsCurrent) { this.install(definition, instance, cwd, prepared.target); } else if (definition.install) { appendLog(instance, "worker", "dependencies already match the lockfile, install skipped\n"); } return { root: prepared.target, cwd, home: prepared.home }; } install(definition, instance, cwd, target) { // Which npm and node ran the install decides which platform-specific optional dependencies // land in node_modules. Get that wrong and the install still exits 0, while the dev server // dies on a missing native module - a stack trace that says nothing about the real cause. One // line here turns that hour of confusion into a glance. appendLog(instance, "worker", `installing dependencies with ${toolchain(cwd, this.childPath)}: ${definition.install.command} ${definition.install.args.join(" ")}\n`); const started = Date.now(); const result = spawnSyncCapped(definition.install, cwd, cleanEnvironment(definition.env, { port: instance.port, npmRegistry: this.npmRegistry, npmCache: this.npmCache, home: instance.home ?? this.instanceHome, childPath: this.childPath, proxy: this.proxy, javaHome: this.javaHome }), instance); if (result.status !== 0) { throw new Error(`Dependency install failed with exit code ${result.status ?? "signal " + result.signal}`); } recordInstall(target); appendLog(instance, "worker", `dependencies installed in ${Date.now() - started} ms\n`); } async spawnChild(definition, instance) { const args = definition.args.map((argument) => substitute(argument, { port: instance.port })); const child = spawn(definition.command, args, { cwd: instance.cwd, env: cleanEnvironment(definition.env, { port: instance.port, npmRegistry: this.npmRegistry, npmCache: this.npmCache, home: instance.home ?? this.instanceHome, childPath: this.childPath, proxy: this.proxy, javaHome: this.javaHome }), shell: false, detached: true, stdio: ["ignore", "pipe", "pipe"] }); instance.child = child; child.stdout.on("data", (chunk) => appendLog(instance, "stdout", chunk)); child.stderr.on("data", (chunk) => appendLog(instance, "stderr", chunk)); child.once("error", (error) => appendLog(instance, "error", Buffer.from(`${error.message}\n`))); child.once("exit", (code, signal) => { instance.status = "stopped"; instance.exitCode = code; instance.signal = signal; instance.exitedAt = new Date().toISOString(); instance.child = null; if (instance.port !== null) { this.portAllocator?.release(instance.port); } }); await new Promise((resolve, reject) => { const onSpawn = () => { cleanup(); resolve(); }; const onError = (error) => { cleanup(); reject(error); }; const cleanup = () => { child.off("spawn", onSpawn); child.off("error", onError); }; child.once("spawn", onSpawn); child.once("error", onError); }); } async awaitHealth(definition, instance, handle) { if (!definition.healthUrl) return; const url = substitute(definition.healthUrl, { port: instance.port }); const deadline = Date.now() + definition.startupTimeoutMs; while (Date.now() < deadline && instance.child) { if (await healthCheck(url, 1000)) return; await new Promise((resolve) => setTimeout(resolve, 200)); } if (!instance.child || !(await healthCheck(url, 1000))) { await this.stopHandle(definition, handle).catch(() => {}); throw new Error(`Service did not answer ${url} before the startup timeout`); } } async stop({ service, key }) { const definition = this.definition(service); return this.stopHandle(definition, this.handle(definition, key)); } async stopHandle(definition, handle) { const instance = this.instances.get(handle); if (!instance?.child) return publicStatus(definition, instance); instance.status = "stopping"; const child = instance.child; const gracefulExit = new Promise((resolve) => child.once("exit", resolve)); try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); } await Promise.race([ gracefulExit, new Promise((resolve) => setTimeout(resolve, definition.shutdownTimeoutMs)) ]); if (instance.child) { const forcedExit = new Promise((resolve) => child.once("exit", resolve)); try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } await Promise.race([forcedExit, new Promise((resolve) => setTimeout(resolve, 2000))]); } this.portAllocator?.releaseAllOf(handle); return publicStatus(definition, instance); } async restart({ service, key }) { await this.stop({ service, key }); // Deliberately a full start: it re-copies the tree, so a restart after an edit serves the new // code. A restart that served the old code would be the worst defect this tool could have. return this.start({ service, key }); } /** Stops the instance and throws its copy away. The only way an execution's disk is reclaimed. */ async discard({ service, key }) { const definition = this.definition(service); const handle = this.handle(definition, key); const slot = this.slot(definition, key); await this.stopHandle(definition, handle); this.instances.delete(handle); if (definition.workspaceMode === "execution" && this.instancesRoot) removeInstanceWorkspace(this.instancesRoot, slot); return { service, key: slot || null, status: "discarded" }; } logs({ service, key, tailBytes = 4096 }) { const definition = this.definition(service); if (!Number.isInteger(tailBytes) || tailBytes < 1 || tailBytes > this.logLimit) throw new Error(`tailBytes must be between 1 and ${this.logLimit}`); const logs = this.instances.get(this.handle(definition, key))?.logs ?? ""; const buffer = Buffer.from(logs); return { service, logs: buffer.subarray(Math.max(0, buffer.length - tailBytes)).toString("utf8"), truncated: buffer.length > tailBytes }; } async close() { await Promise.all([...this.instances.keys()].map((handle) => { const definition = this.services.get(handle.split("\u0000")[0]); return definition ? this.stopHandle(definition, handle).catch(() => {}) : Promise.resolve(); })); } } /** The npm and node the sanitised PATH actually resolves to, as one readable line. */ function toolchain(cwd, childPath) { const ask = (command, args) => { const result = spawnSync(command, args, { cwd, env: { PATH: childPath }, encoding: "utf8", timeout: 10000 }); return (result.stdout ?? "").trim() || "unknown"; }; return `npm ${ask("npm", ["-v"])} on node ${ask("node", ["-v"])}`; } /** * Runs the install synchronously, so no caller can start the dev server on half-installed * dependencies, and folds its output into the same log the model reads. */ function spawnSyncCapped(install, cwd, env, instance) { const result = spawnSync(install.command, install.args, { cwd, env, shell: false, timeout: install.timeoutMs, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" }); if (result.stdout) appendLog(instance, "install", result.stdout); if (result.stderr) appendLog(instance, "install", result.stderr); if (result.error) throw result.error; return result; }