dev-mcps/dev-server-mcp/src/dev-server-service.js

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;
}