381 lines
16 KiB
JavaScript
381 lines
16 KiB
JavaScript
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.
|
|
*
|
|
* <p>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;
|
|
}
|