chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/** Generic supervisor for embedded services (9router, CLIProxyAPI, future). */
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getServiceRow, updateServiceField, setToolStatus } from "@/lib/db/versionManager";
|
||||
import { RingBuffer } from "./ringBuffer";
|
||||
import { HealthChecker } from "./healthCheck";
|
||||
import { decidePreSpawn, probeBeforeSpawn } from "./portProbe";
|
||||
import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } from "./types";
|
||||
|
||||
const CRASH_FAST_THRESHOLD_MS = 5_000;
|
||||
|
||||
export class ServiceSupervisor extends EventEmitter {
|
||||
private state: ServiceState = "stopped";
|
||||
private health: HealthState = "unknown";
|
||||
private pid: number | null = null;
|
||||
private startedAt: string | null = null;
|
||||
private lastError: string | null = null;
|
||||
private childProcess: ChildProcess | null = null;
|
||||
private readonly buffer: RingBuffer;
|
||||
private readonly checker: HealthChecker;
|
||||
private operationLock: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly config: ServiceConfig) {
|
||||
super();
|
||||
this.buffer = new RingBuffer(config.logsBufferBytes);
|
||||
this.checker = new HealthChecker(config.healthUrl, config.healthIntervalMs, (h) => {
|
||||
this.health = h;
|
||||
this.emit("stateChange", this.getStatus());
|
||||
});
|
||||
}
|
||||
|
||||
getRingBuffer(): RingBuffer {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
getStatus(): ServiceStatus {
|
||||
return {
|
||||
tool: this.config.tool,
|
||||
state: this.state,
|
||||
pid: this.pid,
|
||||
port: this.config.port,
|
||||
health: this.health,
|
||||
startedAt: this.startedAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<ServiceStatus> {
|
||||
return this.withLock(async () => {
|
||||
if (this.state === "running" || this.state === "starting") {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const row = await getServiceRow(this.config.tool);
|
||||
if (row && row.logsBufferPath) {
|
||||
this.buffer.setFlushPath(row.logsBufferPath);
|
||||
}
|
||||
|
||||
this.setState("starting");
|
||||
this.lastError = null;
|
||||
|
||||
// Pre-spawn probe (#6205): avoid a raw EADDRINUSE crash when a prior
|
||||
// instance is still holding the port. A healthy instance is adopted; a
|
||||
// held-but-unhealthy port surfaces a clear error instead of a stack.
|
||||
// Opt-in per ServiceConfig so the default spawn path is unchanged.
|
||||
if (this.config.probeBeforeSpawn) {
|
||||
const probe = await probeBeforeSpawn(this.config.healthUrl(), this.config.port);
|
||||
const decision = decidePreSpawn(probe, this.config.port);
|
||||
|
||||
if (decision.action === "adopt") {
|
||||
// Something healthy already serves this port — treat it as running
|
||||
// rather than spawning a duplicate that would die with EADDRINUSE.
|
||||
this.checker.start();
|
||||
this.startedAt = new Date().toISOString();
|
||||
this.setState("running");
|
||||
await setToolStatus(this.config.tool, "running");
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
if (decision.action === "error") {
|
||||
this.lastError = sanitizeErrorMessage(decision.message);
|
||||
this.setState("error");
|
||||
await setToolStatus(this.config.tool, "error", undefined, this.lastError);
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
const { command, args, env, cwd } = this.config.spawnArgs();
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
cwd,
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
this.childProcess = child;
|
||||
this.pid = child.pid ?? null;
|
||||
|
||||
if (this.pid) {
|
||||
await setToolStatus(this.config.tool, "starting", this.pid);
|
||||
}
|
||||
|
||||
const processLine = (stream: "stdout" | "stderr", raw: Buffer) => {
|
||||
const lines = raw.toString("utf8").split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const logLine: LogLine = { ts: Date.now(), stream, line };
|
||||
this.buffer.push(logLine);
|
||||
this.emit("log", logLine);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => processLine("stdout", chunk));
|
||||
child.stderr?.on("data", (chunk: Buffer) => processLine("stderr", chunk));
|
||||
|
||||
const spawnTime = Date.now();
|
||||
child.once("exit", (code, signal) => {
|
||||
void this.handleExit(code, signal, spawnTime);
|
||||
});
|
||||
|
||||
this.startedAt = new Date().toISOString();
|
||||
this.checker.start();
|
||||
|
||||
await this.waitForHealthy();
|
||||
|
||||
this.setState("running");
|
||||
await setToolStatus(this.config.tool, "running", this.pid ?? undefined);
|
||||
|
||||
return this.getStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<ServiceStatus> {
|
||||
return this.withLock(async () => {
|
||||
if (this.state === "stopped" || this.state === "stopping") {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
this.setState("stopping");
|
||||
this.checker.stop();
|
||||
|
||||
await this.killChild();
|
||||
|
||||
this.pid = null;
|
||||
this.childProcess = null;
|
||||
this.startedAt = null;
|
||||
this.setState("stopped");
|
||||
await setToolStatus(this.config.tool, "stopped");
|
||||
|
||||
return this.getStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async restart(): Promise<ServiceStatus> {
|
||||
await this.stop();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
private async withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let resolve!: () => void;
|
||||
const next = new Promise<void>((r) => (resolve = r));
|
||||
const current = this.operationLock;
|
||||
this.operationLock = current.then(() => next);
|
||||
|
||||
await current;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForHealthy(): Promise<void> {
|
||||
const timeoutMs = this.config.healthIntervalMs * 3;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (this.checker.getHealth() === "healthy") return;
|
||||
if (this.state === "error") throw new Error(this.lastError ?? "Service failed to start");
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
// Timeout reached without a healthy probe. Surface this so callers /
|
||||
// dashboards do not see "running" + "unknown" health silently. We do not
|
||||
// throw — the service may still be initializing — but we DO record a
|
||||
// degraded marker so /status returns it and operators can act.
|
||||
this.lastError = sanitizeErrorMessage(
|
||||
`Health probe did not succeed within ${timeoutMs}ms — service may still be initializing`
|
||||
);
|
||||
this.emit("healthDegraded", {
|
||||
tool: this.config.tool,
|
||||
timeoutMs,
|
||||
lastHealth: this.checker.getHealth(),
|
||||
});
|
||||
}
|
||||
|
||||
private async killChild(): Promise<void> {
|
||||
const child = this.childProcess;
|
||||
if (!child || child.killed) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed) child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, this.config.stopTimeoutMs);
|
||||
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleExit(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
spawnTime: number
|
||||
): Promise<void> {
|
||||
this.checker.stop();
|
||||
this.pid = null;
|
||||
this.childProcess = null;
|
||||
|
||||
if (this.state === "stopping" || this.state === "stopped") return;
|
||||
|
||||
const fastCrash = Date.now() - spawnTime < CRASH_FAST_THRESHOLD_MS;
|
||||
const reason = signal ? `killed by signal ${signal}` : `exited with code ${code ?? "unknown"}`;
|
||||
const msg = fastCrash ? `Fast crash (${reason})` : reason;
|
||||
|
||||
this.lastError = sanitizeErrorMessage(msg);
|
||||
this.setState("error");
|
||||
await setToolStatus(this.config.tool, "error", undefined, this.lastError);
|
||||
}
|
||||
|
||||
private setState(state: ServiceState): void {
|
||||
this.state = state;
|
||||
this.emit("stateChange", this.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { encrypt, decrypt } from "@/lib/db/encryption";
|
||||
import { getServiceRow, updateServiceField } from "@/lib/db/versionManager";
|
||||
|
||||
export function generateServiceApiKey(prefix = "nr"): string {
|
||||
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export class ServiceApiKeyDecryptError extends Error {
|
||||
constructor(tool: string) {
|
||||
super(
|
||||
`Stored API key for service '${tool}' could not be decrypted. ` +
|
||||
`STORAGE_ENCRYPTION_KEY may have changed, the row may be corrupted, ` +
|
||||
`or it was written on a different machine. Reinstall the service ` +
|
||||
`or rotate the key via POST /api/services/${tool}/rotate-key.`
|
||||
);
|
||||
this.name = "ServiceApiKeyDecryptError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateApiKey(tool: string): Promise<string> {
|
||||
const row = await getServiceRow(tool);
|
||||
if (row?.apiKey) {
|
||||
const decrypted = decrypt(row.apiKey);
|
||||
if (decrypted) return decrypted;
|
||||
// Fail loud: silently regenerating would mint a new key the embedded
|
||||
// service has never been told, causing every request to 401 with no
|
||||
// operator-facing signal.
|
||||
throw new ServiceApiKeyDecryptError(tool);
|
||||
}
|
||||
const prefix = tool === "9router" ? "nr" : tool === "mux" ? "mx" : "cp";
|
||||
const key = generateServiceApiKey(prefix);
|
||||
await updateServiceField(tool, "apiKey", encrypt(key) ?? key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function maskApiKey(plainKey: string): string {
|
||||
const last4 = plainKey.slice(-4);
|
||||
return `nr_••••••••${last4}`;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { markAllUnavailable } from "@/lib/db/serviceModels";
|
||||
import { registerSupervisor, getSupervisor } from "./registry";
|
||||
import { ServiceSupervisor } from "./ServiceSupervisor";
|
||||
import { resolveSpawnArgs as nineRouterSpawnArgs } from "./installers/ninerouter";
|
||||
import {
|
||||
resolveSpawnArgs as cliproxySpawnArgs,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from "./installers/cliproxy";
|
||||
import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux";
|
||||
import {
|
||||
resolveSpawnArgs as bifrostSpawnArgs,
|
||||
BIFROST_DEFAULT_PORT,
|
||||
} from "./installers/bifrost";
|
||||
import { getOrCreateApiKey } from "./apiKey";
|
||||
import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync";
|
||||
import type { ServiceStatus } from "./types";
|
||||
|
||||
const NINEROUTER_PORT = parseInt(process.env.NINEROUTER_PORT ?? "20130", 10);
|
||||
const CLIPROXY_PORT = parseInt(process.env.CLIPROXYAPI_PORT ?? String(CLIPROXY_DEFAULT_PORT), 10);
|
||||
const MUX_PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10);
|
||||
const BIFROST_PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT_PORT), 10);
|
||||
|
||||
type ServiceEntry = {
|
||||
tool: string;
|
||||
port: number;
|
||||
healthPath: string;
|
||||
healthIntervalMs: number;
|
||||
stopTimeoutMs: number;
|
||||
logsBufferBytes: number;
|
||||
needsApiKey: boolean;
|
||||
};
|
||||
|
||||
const SERVICES: ServiceEntry[] = [
|
||||
{
|
||||
tool: "9router",
|
||||
port: NINEROUTER_PORT,
|
||||
healthPath: "/api/health",
|
||||
healthIntervalMs: 2_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
tool: "cliproxy",
|
||||
port: CLIPROXY_PORT,
|
||||
healthPath: "/v1/models",
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: false,
|
||||
},
|
||||
{
|
||||
tool: "mux",
|
||||
port: MUX_PORT,
|
||||
healthPath: "/health",
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
tool: "bifrost",
|
||||
port: BIFROST_PORT,
|
||||
healthPath: "/v1/models",
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: false,
|
||||
},
|
||||
];
|
||||
|
||||
function buildSpawnArgsFactory(
|
||||
cfg: ServiceEntry,
|
||||
apiKey: string
|
||||
): () => ReturnType<typeof nineRouterSpawnArgs> {
|
||||
if (cfg.tool === "9router") {
|
||||
return () => nineRouterSpawnArgs(apiKey, cfg.port);
|
||||
}
|
||||
if (cfg.tool === "mux") {
|
||||
return () => muxSpawnArgs(apiKey, cfg.port);
|
||||
}
|
||||
if (cfg.tool === "bifrost") {
|
||||
return () => bifrostSpawnArgs(cfg.port);
|
||||
}
|
||||
return () => cliproxySpawnArgs(cfg.port);
|
||||
}
|
||||
|
||||
export async function bootstrapEmbeddedServices(): Promise<void> {
|
||||
for (const cfg of SERVICES) {
|
||||
if (getSupervisor(cfg.tool)) continue;
|
||||
|
||||
const row = await getVersionManagerTool(cfg.tool);
|
||||
if (!row || row.status === "not_installed") continue;
|
||||
|
||||
const apiKey = cfg.needsApiKey
|
||||
? await getOrCreateApiKey(cfg.tool).catch(() => "placeholder")
|
||||
: "";
|
||||
|
||||
const supervisor = new ServiceSupervisor({
|
||||
tool: cfg.tool,
|
||||
port: cfg.port,
|
||||
spawnArgs: buildSpawnArgsFactory(cfg, apiKey),
|
||||
healthUrl: () => `http://127.0.0.1:${cfg.port}${cfg.healthPath}`,
|
||||
healthIntervalMs: cfg.healthIntervalMs,
|
||||
stopTimeoutMs: cfg.stopTimeoutMs,
|
||||
logsBufferBytes: cfg.logsBufferBytes,
|
||||
// #6205: embedded services bind a fixed port — probe before spawning so
|
||||
// an orphaned prior instance yields adopt/clear-error instead of a raw
|
||||
// EADDRINUSE crash.
|
||||
probeBeforeSpawn: true,
|
||||
});
|
||||
|
||||
registerSupervisor(supervisor);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${cfg.port}`;
|
||||
supervisor.on("stateChange", (status: ServiceStatus) => {
|
||||
if (status.state === "running") {
|
||||
scheduleServiceModelSync(cfg.tool, baseUrl, apiKey);
|
||||
} else if (status.state === "stopped" || status.state === "error") {
|
||||
stopServiceModelSync(cfg.tool);
|
||||
markAllUnavailable(cfg.tool);
|
||||
}
|
||||
});
|
||||
|
||||
if (row.autoStart) {
|
||||
supervisor.start().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[Services] Auto-start failed for ${cfg.tool}: ${msg}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Pure helpers for the embedded-service reverse proxy path handling.
|
||||
*
|
||||
* Kept dependency-free so the behavior can be unit-tested without pulling in
|
||||
* the registry / DB / htmlRewriter that `reverseProxy.ts` imports.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map the `[[...path]]` catch-all segments to an upstream request path.
|
||||
*
|
||||
* The segment-less panel root (`/embed/`, matched only because the route is an
|
||||
* OPTIONAL catch-all — #6205) yields an empty segment array, which must map to
|
||||
* `"/"` so the embedded service serves its index page instead of `/undefined`.
|
||||
*
|
||||
* @param pathSegments The catch-all segments, e.g. `["ui", "index.html"]` or `[]`.
|
||||
*/
|
||||
export function toUpstreamPath(pathSegments: string[]): string {
|
||||
return pathSegments.length > 0 ? "/" + pathSegments.join("/") : "/";
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* WebSocket reverse proxy for embedded service UIs.
|
||||
*
|
||||
* Runs a lightweight HTTP server (port EMBED_WS_PROXY_PORT, default 20131)
|
||||
* that accepts WebSocket upgrade requests and tunnels them to the matching
|
||||
* embedded service.
|
||||
*
|
||||
* URL pattern: WebSocket connect to host:20131/[name]/[...path]
|
||||
* [name] → resolved via the services registry (e.g. "9router")
|
||||
* [...path] → forwarded verbatim to the upstream WS endpoint
|
||||
*
|
||||
* Security:
|
||||
* - Target host is always 127.0.0.1 and port comes from the registry — never
|
||||
* from user input. No SSRF risk.
|
||||
* - Server binds to 127.0.0.1 only (loopback) unless EMBED_WS_PROXY_HOST
|
||||
* is set explicitly. The OmniRoute LOCAL_ONLY rule is enforced at the
|
||||
* dashboard layer; the proxy itself is loopback-only as defence-in-depth.
|
||||
* - Max 50 concurrent connections per service. The 51st request receives 503.
|
||||
* - Idle timeout: 5 minutes without any data → both sockets are destroyed.
|
||||
* - Hop-by-hop headers cookie/authorization/origin are stripped from the
|
||||
* upgrade request; Authorization is replaced by Bearer <serviceApiKey>.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
import { getSupervisor } from "./registry";
|
||||
import { getOrCreateApiKey } from "./apiKey";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 20131;
|
||||
|
||||
/** Maximum concurrent WebSocket bridges per service name. */
|
||||
const MAX_CONNECTIONS_PER_SERVICE = 50;
|
||||
|
||||
/** Idle timeout in milliseconds (5 minutes). */
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Headers to strip from the client upgrade request (case-insensitive). */
|
||||
const STRIPPED_HEADERS = new Set(["cookie", "authorization", "origin"]);
|
||||
|
||||
declare global {
|
||||
var __omnirouteEmbedWsStarted: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks active client sockets per service name.
|
||||
* Used to enforce MAX_CONNECTIONS_PER_SERVICE.
|
||||
*/
|
||||
const activeConnections = new Map<string, Set<net.Socket>>();
|
||||
|
||||
/** Regex that matches /<name>/<path> or /<name> */
|
||||
const PATH_RE = /^\/([^/?#]+)(\/.*)?$/;
|
||||
|
||||
function writeError(socket: net.Socket, status: number, message: string): void {
|
||||
if (!socket.writable || socket.destroyed) return;
|
||||
const body = Buffer.from(JSON.stringify({ error: message }), "utf8");
|
||||
const lines = [
|
||||
`HTTP/1.1 ${status} ${http.STATUS_CODES[status] ?? "Error"}`,
|
||||
"Connection: close",
|
||||
"Content-Type: application/json; charset=utf-8",
|
||||
`Content-Length: ${body.length}`,
|
||||
"",
|
||||
"",
|
||||
];
|
||||
socket.write(lines.join("\r\n"));
|
||||
socket.end(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a client socket into the per-service active set.
|
||||
* Returns false (and writes 503) if the limit is already reached.
|
||||
*/
|
||||
function registerConnection(name: string, socket: net.Socket): boolean {
|
||||
let set = activeConnections.get(name);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
activeConnections.set(name, set);
|
||||
}
|
||||
if (set.size >= MAX_CONNECTIONS_PER_SERVICE) {
|
||||
writeError(
|
||||
socket,
|
||||
503,
|
||||
`Service '${name}' connection limit reached (max ${MAX_CONNECTIONS_PER_SERVICE})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
set.add(socket);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove a client socket from the per-service active set. */
|
||||
function unregisterConnection(name: string, socket: net.Socket): void {
|
||||
activeConnections.get(name)?.delete(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the filtered header list for the upstream upgrade request.
|
||||
* Strips cookie, authorization, and origin; rewrites host; injects Bearer token.
|
||||
*/
|
||||
function buildUpstreamHeaders(rawHeaders: string[], port: number, apiKey: string): string[] {
|
||||
const lines: string[] = [];
|
||||
let wroteHost = false;
|
||||
|
||||
for (let i = 0; i < rawHeaders.length; i += 2) {
|
||||
const headerName = rawHeaders[i];
|
||||
const headerValue = rawHeaders[i + 1] ?? "";
|
||||
const lower = headerName.toLowerCase();
|
||||
|
||||
if (lower === "host") {
|
||||
lines.push(`Host: 127.0.0.1:${port}`);
|
||||
wroteHost = true;
|
||||
} else if (!STRIPPED_HEADERS.has(lower)) {
|
||||
lines.push(`${headerName}: ${headerValue}`);
|
||||
}
|
||||
// cookie / authorization / origin are intentionally dropped here
|
||||
}
|
||||
|
||||
if (!wroteHost) lines.push(`Host: 127.0.0.1:${port}`);
|
||||
|
||||
// Always inject the service API key regardless of what the client sent
|
||||
lines.push(`Authorization: Bearer ${apiKey}`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
|
||||
const rawUrl = req.url ?? "/";
|
||||
const match = PATH_RE.exec(rawUrl.split("?")[0]);
|
||||
|
||||
if (!match) {
|
||||
writeError(socket, 400, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
const [, name, rest = "/"] = match;
|
||||
const supervisor = getSupervisor(name);
|
||||
|
||||
if (!supervisor) {
|
||||
writeError(socket, 404, `Service '${name}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, port } = supervisor.getStatus();
|
||||
if (state !== "running") {
|
||||
writeError(socket, 503, `Service '${name}' is not running (state: ${state})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce max concurrent connections per service
|
||||
if (!registerConnection(name, socket)) {
|
||||
// writeError already written inside registerConnection
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up connection tracking when the client socket closes
|
||||
socket.once("close", () => unregisterConnection(name, socket));
|
||||
socket.once("error", () => unregisterConnection(name, socket));
|
||||
|
||||
// Fetch the service API key (never cached — key may rotate)
|
||||
const apiKey = await getOrCreateApiKey(name);
|
||||
|
||||
// Rebuild the search string if present
|
||||
const search = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
|
||||
const upstreamPath = `${rest}${search}`;
|
||||
|
||||
const upstream = net.connect(port, "127.0.0.1");
|
||||
|
||||
// Idle timeout: reset on any data in either direction; destroy both on expiry
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function resetIdleTimer(): void {
|
||||
if (idleTimer !== null) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
upstream.destroy();
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function clearIdleTimer(): void {
|
||||
if (idleTimer !== null) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
upstream.once("connect", () => {
|
||||
const requestLine = `${req.method ?? "GET"} ${upstreamPath} HTTP/${req.httpVersion}`;
|
||||
const headerLines = buildUpstreamHeaders(req.rawHeaders, port, apiKey);
|
||||
upstream.write(`${requestLine}\r\n${headerLines.join("\r\n")}\r\n\r\n`);
|
||||
if (head.length > 0) upstream.write(head);
|
||||
|
||||
// Start idle timer once the tunnel is live
|
||||
resetIdleTimer();
|
||||
|
||||
socket.on("data", resetIdleTimer);
|
||||
upstream.on("data", resetIdleTimer);
|
||||
|
||||
socket.pipe(upstream);
|
||||
upstream.pipe(socket);
|
||||
});
|
||||
|
||||
upstream.on("error", () => {
|
||||
clearIdleTimer();
|
||||
writeError(socket, 502, "Upstream connection error");
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
clearIdleTimer();
|
||||
upstream.destroy();
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
clearIdleTimer();
|
||||
upstream.destroy();
|
||||
});
|
||||
|
||||
upstream.on("close", () => {
|
||||
clearIdleTimer();
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the bind host for the embed WS proxy.
|
||||
*
|
||||
* `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST`
|
||||
* so a single env var exposes BOTH WebSocket sockets (the Live dashboard server
|
||||
* on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy
|
||||
* or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1
|
||||
* even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was
|
||||
* permanently "disconnected" in headless deployments (#5110). Defaults to
|
||||
* loopback for safety when neither is set.
|
||||
*/
|
||||
export function resolveEmbedWsHost(): string {
|
||||
return process.env.EMBED_WS_PROXY_HOST ?? process.env.LIVE_WS_HOST ?? DEFAULT_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the embed WebSocket proxy server.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function initEmbedWsProxy(): void {
|
||||
if (globalThis.__omnirouteEmbedWsStarted) return;
|
||||
|
||||
const host = resolveEmbedWsHost();
|
||||
const port = parseInt(process.env.EMBED_WS_PROXY_PORT ?? String(DEFAULT_PORT), 10);
|
||||
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(426, "Upgrade Required", { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "upgrade_required", message: "Use WebSocket." }));
|
||||
});
|
||||
|
||||
server.on("upgrade", (req: IncomingMessage, socket: net.Socket, head: Buffer) => {
|
||||
proxyUpgrade(req, socket, head).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
writeError(socket, 500, `Internal proxy error: ${msg}`);
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.warn(`[EmbedWsProxy] Port ${port} is already in use — embed WS proxy disabled.`);
|
||||
return;
|
||||
}
|
||||
console.warn("[EmbedWsProxy] Failed to start:", err.message);
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
globalThis.__omnirouteEmbedWsStarted = true;
|
||||
console.log(`[EmbedWsProxy] Listening on ${host}:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Exported for testing ────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
activeConnections,
|
||||
registerConnection,
|
||||
unregisterConnection,
|
||||
buildUpstreamHeaders,
|
||||
MAX_CONNECTIONS_PER_SERVICE,
|
||||
IDLE_TIMEOUT_MS,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Periodic health-check poller for embedded services. */
|
||||
|
||||
import type { HealthState } from "./types";
|
||||
|
||||
const HEALTH_FETCH_TIMEOUT_MS = 5_000;
|
||||
const FAILURE_THRESHOLD = 3;
|
||||
|
||||
export type OnHealthChange = (health: HealthState) => void;
|
||||
|
||||
export class HealthChecker {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private consecutiveFailures = 0;
|
||||
private currentHealth: HealthState = "unknown";
|
||||
private active = false;
|
||||
|
||||
constructor(
|
||||
private readonly healthUrl: () => string,
|
||||
private readonly intervalMs: number,
|
||||
private readonly onChange: OnHealthChange
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.active) return;
|
||||
this.active = true;
|
||||
this.consecutiveFailures = 0;
|
||||
this.currentHealth = "unknown";
|
||||
this.timer = setInterval(() => void this.poll(), this.intervalMs);
|
||||
// Don't keep the process alive solely for the health poller.
|
||||
(this.timer as { unref?: () => void })?.unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.active = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.setHealth("unknown");
|
||||
}
|
||||
|
||||
getHealth(): HealthState {
|
||||
return this.currentHealth;
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.active) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), HEALTH_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(this.healthUrl(), { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
this.consecutiveFailures = 0;
|
||||
this.setHealth("healthy");
|
||||
} else {
|
||||
this.recordFailure();
|
||||
}
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
this.recordFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private recordFailure(): void {
|
||||
this.consecutiveFailures++;
|
||||
if (this.consecutiveFailures >= FAILURE_THRESHOLD) {
|
||||
this.setHealth("unhealthy");
|
||||
}
|
||||
}
|
||||
|
||||
private setHealth(health: HealthState): void {
|
||||
if (health === this.currentHealth) return;
|
||||
this.currentHealth = health;
|
||||
this.onChange(health);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* HTML rewriter for the embedded-service reverse proxy.
|
||||
*
|
||||
* Rewrites an HTML document so that absolute-path URLs point through the
|
||||
* OmniRoute proxy prefix instead of directly to the embedded service's port.
|
||||
*
|
||||
* What it does:
|
||||
* - Inserts `<base href="${publicPrefix}/">` as the first child of `<head>`.
|
||||
* - Rewrites path-absolute URLs (starting with `/` but NOT `//`) in selected
|
||||
* attributes: <a href>, <link href>, <script src>, <img src/srcset>,
|
||||
* <form action>, <iframe src>, <source src/srcset>.
|
||||
*
|
||||
* What it does NOT do (known v1 limitations):
|
||||
* - CSS `url(...)` rewriting (too complex, CSS parser not included).
|
||||
* - JS `window.location` rewriting (client-side navigation may break).
|
||||
* - Multi-URL `srcset` values (comma-separated) — skipped when a comma
|
||||
* is detected.
|
||||
*/
|
||||
|
||||
import { parse, serialize } from "parse5";
|
||||
import type { DefaultTreeAdapterMap } from "parse5";
|
||||
|
||||
type Document = DefaultTreeAdapterMap["document"];
|
||||
type Element = DefaultTreeAdapterMap["element"];
|
||||
type Node = DefaultTreeAdapterMap["node"];
|
||||
type Attr = { name: string; value: string; namespace?: string; prefix?: string };
|
||||
|
||||
// Matches /foo but not //foo, http://, https://, mailto:, javascript:, #, etc.
|
||||
const ABS_PATH_RE = /^\/(?!\/)/;
|
||||
|
||||
// Schemes that should NOT be rewritten — leave them as-is.
|
||||
const SKIP_SCHEMES = ["http:", "https:", "mailto:", "javascript:", "data:", "blob:", "ftp:"];
|
||||
|
||||
const REWRITABLE_ATTRS: Record<string, string[]> = {
|
||||
a: ["href"],
|
||||
link: ["href"],
|
||||
script: ["src"],
|
||||
img: ["src", "srcset"],
|
||||
form: ["action"],
|
||||
iframe: ["src"],
|
||||
source: ["src", "srcset"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite an HTML string so all path-absolute URLs are prefixed with
|
||||
* `publicPrefix`, and a `<base href>` is injected into `<head>`.
|
||||
*
|
||||
* @param html Raw HTML from the upstream service.
|
||||
* @param publicPrefix The proxy prefix path (e.g. "/dashboard/providers/services/9router/embed").
|
||||
* Trailing slash is stripped internally.
|
||||
*/
|
||||
export function rewriteHtml(html: string, publicPrefix: string): string {
|
||||
const prefix = publicPrefix.endsWith("/") ? publicPrefix.slice(0, -1) : publicPrefix;
|
||||
|
||||
const doc = parse(html) as Document;
|
||||
|
||||
const headEl = findOrCreateHead(doc);
|
||||
injectBase(headEl, `${prefix}/`);
|
||||
visitNode(doc, prefix);
|
||||
|
||||
return serialize(doc);
|
||||
}
|
||||
|
||||
// ─── private helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the <head> element in the document. parse5 always produces a full tree
|
||||
* (html → head + body) even for partial HTML, so this should always succeed.
|
||||
* Falls back to creating a minimal <head> if somehow absent.
|
||||
*/
|
||||
function findOrCreateHead(doc: Document): Element {
|
||||
for (const child of doc.childNodes) {
|
||||
if (isElement(child) && child.tagName === "html") {
|
||||
for (const htmlChild of child.childNodes) {
|
||||
if (isElement(htmlChild) && htmlChild.tagName === "head") {
|
||||
return htmlChild;
|
||||
}
|
||||
}
|
||||
// <head> missing inside <html> — create one and prepend
|
||||
const head = makeElement("head");
|
||||
child.childNodes.unshift(head);
|
||||
(head as Element & { parentNode: Node }).parentNode = child;
|
||||
return head;
|
||||
}
|
||||
}
|
||||
|
||||
// Completely bare fragment — unlikely from parse5, but be safe.
|
||||
// Find/create <html> too.
|
||||
const htmlEl = makeElement("html");
|
||||
const head = makeElement("head");
|
||||
head.childNodes = [];
|
||||
(head as Element & { parentNode: Node }).parentNode = htmlEl;
|
||||
htmlEl.childNodes = [head as Node];
|
||||
(htmlEl as Element & { parentNode: Node }).parentNode = doc as unknown as Node;
|
||||
doc.childNodes.push(htmlEl as unknown as Node);
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend `<base href="...">` to `<head>`, but only if one doesn't already
|
||||
* exist (avoid double-inject on re-proxied pages).
|
||||
*/
|
||||
function injectBase(head: Element, baseHref: string): void {
|
||||
// If a <base> already exists, update its href and exit.
|
||||
for (const child of head.childNodes) {
|
||||
if (isElement(child) && child.tagName === "base") {
|
||||
const hrefAttr = child.attrs.find((a) => a.name === "href");
|
||||
if (hrefAttr) {
|
||||
hrefAttr.value = baseHref;
|
||||
} else {
|
||||
child.attrs.push({ name: "href", value: baseHref });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No <base> found — create one and prepend.
|
||||
const baseEl = makeElement("base");
|
||||
baseEl.attrs = [{ name: "href", value: baseHref }];
|
||||
baseEl.childNodes = [];
|
||||
(baseEl as Element & { parentNode: Node }).parentNode = head;
|
||||
head.childNodes.unshift(baseEl as unknown as Node);
|
||||
}
|
||||
|
||||
/** Recursively walk the parse5 tree and rewrite matching attrs. */
|
||||
function visitNode(node: Node, prefix: string): void {
|
||||
if (isElement(node)) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const rewritable = REWRITABLE_ATTRS[tag];
|
||||
if (rewritable) {
|
||||
for (const attr of node.attrs as Attr[]) {
|
||||
if (rewritable.includes(attr.name)) {
|
||||
attr.value = rewriteUrl(attr.value, prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const children = (node as { childNodes?: Node[] }).childNodes;
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
visitNode(child, prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a single URL value:
|
||||
* - Path-absolute URLs starting with `/` (but not `//`) → prefix + url
|
||||
* - All other values (relative, external, mailto:, srcset with commas) → unchanged
|
||||
*/
|
||||
function rewriteUrl(value: string, prefix: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
|
||||
// Skip multi-URL srcset (contains comma + space pattern) — too complex for v1
|
||||
if (trimmed.includes(",")) return value;
|
||||
|
||||
// Skip known schemes
|
||||
for (const scheme of SKIP_SCHEMES) {
|
||||
if (trimmed.toLowerCase().startsWith(scheme)) return value;
|
||||
}
|
||||
|
||||
// Rewrite path-absolute URLs only
|
||||
if (ABS_PATH_RE.test(trimmed)) {
|
||||
return `${prefix}${trimmed}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isElement(node: Node): node is Element {
|
||||
return (node as Element).attrs !== undefined && (node as Element).tagName !== undefined;
|
||||
}
|
||||
|
||||
function makeElement(tag: string): Element {
|
||||
return {
|
||||
nodeName: tag,
|
||||
tagName: tag,
|
||||
attrs: [],
|
||||
namespaceURI: "http://www.w3.org/1999/xhtml",
|
||||
childNodes: [],
|
||||
parentNode: null as unknown as Node,
|
||||
} as unknown as Element;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/db/core";
|
||||
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { runNpm, InstallError } from "./utils";
|
||||
|
||||
export const BIFROST_PACKAGE = "@maximhq/bifrost";
|
||||
export const BIFROST_DEFAULT_PORT = 8080;
|
||||
export const BIFROST_INSTALL_DIR = path.join(DATA_DIR, "services", "bifrost");
|
||||
|
||||
export interface InstallResult {
|
||||
installedVersion: string;
|
||||
installPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SpawnArgs {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// In-memory latest-version cache, 1h TTL
|
||||
let latestVersionCache: { value: string; expiresAt: number } | null = null;
|
||||
const VERSION_CACHE_TTL_MS = 3_600_000;
|
||||
|
||||
function getInstalledPkgPath(): string {
|
||||
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "package.json");
|
||||
}
|
||||
|
||||
function getBinPath(): string {
|
||||
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "bin.js");
|
||||
}
|
||||
|
||||
function getInstalledVersionSync(): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getInstalledPkgPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstalledVersion(): Promise<string | null> {
|
||||
return getInstalledVersionSync();
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runNpm(["view", BIFROST_PACKAGE, "version"], { timeoutMs: 30_000 });
|
||||
const version = stdout.trim();
|
||||
if (version) {
|
||||
latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
|
||||
}
|
||||
return version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function install(version = "latest"): Promise<InstallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Create install dir + minimal package.json (idempotent)
|
||||
fs.mkdirSync(BIFROST_INSTALL_DIR, { recursive: true });
|
||||
const hostPkgPath = path.join(BIFROST_INSTALL_DIR, "package.json");
|
||||
if (!fs.existsSync(hostPkgPath)) {
|
||||
fs.writeFileSync(
|
||||
hostPkgPath,
|
||||
JSON.stringify(
|
||||
{ name: "omniroute-bifrost-host", version: "0.0.0", private: true, dependencies: {} },
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
await runNpm(
|
||||
["install", `${BIFROST_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
|
||||
// `--prefix` via `prefix` (→ npm_config_prefix env) so paths with spaces survive Windows shell
|
||||
{ cwd: BIFROST_INSTALL_DIR, prefix: BIFROST_INSTALL_DIR }
|
||||
);
|
||||
|
||||
const installedVersion = await getInstalledVersion();
|
||||
if (!installedVersion) {
|
||||
throw new InstallError(
|
||||
"Could not read installed version from node_modules/@maximhq/bifrost/package.json",
|
||||
"Bifrost instalado mas versão não pôde ser lida.",
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool: "bifrost",
|
||||
installedVersion,
|
||||
binaryPath: getBinPath(),
|
||||
status: "stopped",
|
||||
port: BIFROST_DEFAULT_PORT,
|
||||
});
|
||||
|
||||
// Invalidate cache so next getLatestVersion() re-fetches
|
||||
latestVersionCache = null;
|
||||
|
||||
return {
|
||||
installedVersion,
|
||||
installPath: BIFROST_INSTALL_DIR,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function update(): Promise<InstallResult> {
|
||||
return install("latest");
|
||||
}
|
||||
|
||||
export function resolveSpawnArgs(port: number): SpawnArgs {
|
||||
const binPath = getBinPath();
|
||||
// Pin transport version to the installed npm version for reproducibility (spec §2b)
|
||||
const transportVersion = getInstalledVersionSync() ?? "latest";
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
binPath,
|
||||
"-port",
|
||||
String(port),
|
||||
"-host",
|
||||
"127.0.0.1",
|
||||
"-app-dir",
|
||||
BIFROST_INSTALL_DIR,
|
||||
"-log-level",
|
||||
"warn",
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
BIFROST_TRANSPORT_VERSION: transportVersion,
|
||||
},
|
||||
cwd: BIFROST_INSTALL_DIR,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* CLIProxyAPI installer adapter for the ServiceSupervisor framework.
|
||||
*
|
||||
* Wraps the existing binaryManager/releaseChecker infra (GitHub release download,
|
||||
* checksum verify, symlink) and exposes the same interface as ninerouter.ts so that
|
||||
* bootstrap.ts can treat both services uniformly.
|
||||
*
|
||||
* Binary location: $DATA_DIR/bin/cliproxyapi (symlink → versioned dir)
|
||||
* Config: $DATA_DIR/services/cliproxy/config.yaml
|
||||
* DB row: version_manager WHERE tool = 'cliproxy'
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/db/core";
|
||||
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { getLatestRelease } from "@/lib/versionManager/releaseChecker.ts";
|
||||
import { installVersion, getCurrentBinaryPath } from "@/lib/versionManager/binaryManager.ts";
|
||||
|
||||
export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
const BIN_DIR = path.join(DATA_DIR, "bin");
|
||||
const CONFIG_DIR = path.join(DATA_DIR, "services", "cliproxy");
|
||||
|
||||
let latestVersionCache: { value: string; expiresAt: number } | null = null;
|
||||
const VERSION_CACHE_TTL_MS = 3_600_000;
|
||||
|
||||
export interface InstallResult {
|
||||
installedVersion: string;
|
||||
installPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SpawnArgs {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
/** Reads the installed version from the symlink target directory name. */
|
||||
export async function getInstalledVersion(): Promise<string | null> {
|
||||
const binaryPath = await getCurrentBinaryPath(DATA_DIR);
|
||||
if (!binaryPath) return null;
|
||||
const dirName = path.basename(path.dirname(binaryPath));
|
||||
const m = dirName.match(/^cliproxyapi-(.+)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
try {
|
||||
const release = await getLatestRelease();
|
||||
latestVersionCache = { value: release.version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
|
||||
return release.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install CLIProxyAPI from GitHub releases.
|
||||
* Upserts the version_manager row with tool='cliproxy'.
|
||||
*/
|
||||
export async function install(version = "latest"): Promise<InstallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
const targetVersion = version === "latest" ? (await getLatestRelease()).version : version;
|
||||
|
||||
const binaryPath = await installVersion(targetVersion, DATA_DIR);
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool: "cliproxy",
|
||||
installedVersion: targetVersion,
|
||||
binaryPath,
|
||||
status: "stopped",
|
||||
port: CLIPROXY_DEFAULT_PORT,
|
||||
});
|
||||
|
||||
latestVersionCache = null;
|
||||
|
||||
return {
|
||||
installedVersion: targetVersion,
|
||||
installPath: BIN_DIR,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function update(): Promise<InstallResult> {
|
||||
return install("latest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build spawn args for ServiceSupervisor.start().
|
||||
*
|
||||
* Writes config.yaml synchronously — safe because the destination is a
|
||||
* DATA_DIR-controlled path (no user input) and the content is static.
|
||||
* ServiceSupervisor calls spawnArgs() synchronously just before spawn(), so
|
||||
* async file I/O is not available here.
|
||||
*/
|
||||
export function resolveSpawnArgs(port: number): SpawnArgs {
|
||||
const symlinkPath = path.join(BIN_DIR, "cliproxyapi");
|
||||
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
const configPath = path.join(CONFIG_DIR, "config.yaml");
|
||||
fs.writeFileSync(configPath, `port: ${port}\nhost: 127.0.0.1\nlog_level: warn\n`, "utf8");
|
||||
|
||||
return {
|
||||
command: symlinkPath,
|
||||
args: ["-c", configPath],
|
||||
env: { ...process.env },
|
||||
cwd: CONFIG_DIR,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Mux (coder/mux) installer adapter for the ServiceSupervisor framework.
|
||||
*
|
||||
* Mux (https://github.com/coder/mux) is a local agent-orchestration daemon
|
||||
* ("AI agent orchestration") published on npm as the `mux` package, with a
|
||||
* documented headless server mode: `mux server --host <host> --port <port>`.
|
||||
* It is installed the same way as 9Router — `npm install` into a
|
||||
* DATA_DIR-scoped directory via `runNpm` (Hard Rule #13: no shell
|
||||
* interpolation, array args + `env` option only) — never a git-clone+build.
|
||||
*
|
||||
* Binary location: $DATA_DIR/services/mux/node_modules/mux/dist/cli/index.js
|
||||
* Data dir: $DATA_DIR/services/mux/data (MUX_HOME — mux's own state)
|
||||
* DB row: version_manager WHERE tool = 'mux'
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/db/core";
|
||||
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { runNpm, InstallError } from "./utils";
|
||||
|
||||
export const MUX_PACKAGE = "mux";
|
||||
export const MUX_DEFAULT_PORT = 8322;
|
||||
export const MUX_INSTALL_DIR = path.join(DATA_DIR, "services", "mux");
|
||||
|
||||
export interface InstallResult {
|
||||
installedVersion: string;
|
||||
installPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SpawnArgs {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// In-memory latest-version cache, 1h TTL — mirrors ninerouter.ts.
|
||||
let latestVersionCache: { value: string; expiresAt: number } | null = null;
|
||||
const VERSION_CACHE_TTL_MS = 3_600_000;
|
||||
|
||||
function getServerPath(): string {
|
||||
return path.join(MUX_INSTALL_DIR, "node_modules", "mux", "dist", "cli", "index.js");
|
||||
}
|
||||
|
||||
function getInstalledPkgPath(): string {
|
||||
return path.join(MUX_INSTALL_DIR, "node_modules", "mux", "package.json");
|
||||
}
|
||||
|
||||
export async function getInstalledVersion(): Promise<string | null> {
|
||||
try {
|
||||
const raw = fs.readFileSync(getInstalledPkgPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runNpm(["view", MUX_PACKAGE, "version"], { timeoutMs: 30_000 });
|
||||
const version = stdout.trim();
|
||||
if (version) {
|
||||
latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
|
||||
}
|
||||
return version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install Mux from npm.
|
||||
* Upserts the version_manager row with tool='mux'.
|
||||
*/
|
||||
export async function install(version = "latest"): Promise<InstallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Create install dir + minimal package.json (idempotent) — same shape as ninerouter.ts.
|
||||
fs.mkdirSync(MUX_INSTALL_DIR, { recursive: true });
|
||||
const hostPkgPath = path.join(MUX_INSTALL_DIR, "package.json");
|
||||
if (!fs.existsSync(hostPkgPath)) {
|
||||
fs.writeFileSync(
|
||||
hostPkgPath,
|
||||
JSON.stringify(
|
||||
{ name: "omniroute-mux-host", version: "0.0.0", private: true, dependencies: {} },
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
await runNpm(
|
||||
["install", `${MUX_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
|
||||
// `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an
|
||||
// argv path so an install dir with spaces survives the Windows shell (#5379).
|
||||
{ cwd: MUX_INSTALL_DIR, prefix: MUX_INSTALL_DIR }
|
||||
);
|
||||
|
||||
const installedVersion = await getInstalledVersion();
|
||||
if (!installedVersion) {
|
||||
throw new InstallError(
|
||||
"Could not read installed version from node_modules/mux/package.json",
|
||||
"Mux instalado mas versão não pôde ser lida.",
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool: "mux",
|
||||
installedVersion,
|
||||
binaryPath: getServerPath(),
|
||||
status: "stopped",
|
||||
port: MUX_DEFAULT_PORT,
|
||||
});
|
||||
|
||||
// Invalidate cache so next getLatestVersion() re-fetches
|
||||
latestVersionCache = null;
|
||||
|
||||
return {
|
||||
installedVersion,
|
||||
installPath: MUX_INSTALL_DIR,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function update(): Promise<InstallResult> {
|
||||
return install("latest");
|
||||
}
|
||||
|
||||
export async function uninstall(): Promise<void> {
|
||||
const nmDir = path.join(MUX_INSTALL_DIR, "node_modules");
|
||||
if (fs.existsSync(nmDir)) {
|
||||
fs.rmSync(nmDir, { recursive: true, force: true });
|
||||
}
|
||||
await upsertVersionManagerTool({
|
||||
tool: "mux",
|
||||
status: "not_installed",
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build spawn args for ServiceSupervisor.start().
|
||||
*
|
||||
* Mux binds to 127.0.0.1 explicitly (never 0.0.0.0) — the dashboard route is
|
||||
* already loopback-gated (Hard Rule #17), and this is defense-in-depth since
|
||||
* Mux orchestrates AI agents that can execute shell commands on the host.
|
||||
* The bearer token is passed via `MUX_SERVER_AUTH_TOKEN` (mux's documented env
|
||||
* form), never as a CLI arg, so it never appears in `ps`/process listings.
|
||||
*/
|
||||
export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs {
|
||||
const serverPath = getServerPath();
|
||||
// MUX_ROOT is mux's documented override for its home/config/data directory
|
||||
// (defaults to ~/.mux otherwise) — scope it under DATA_DIR like every other
|
||||
// embedded service instead of leaking into the OS-user home directory.
|
||||
const muxRoot = path.join(MUX_INSTALL_DIR, "data");
|
||||
fs.mkdirSync(muxRoot, { recursive: true });
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [serverPath, "server", "--host", "127.0.0.1", "--port", String(port)],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
MUX_ROOT: muxRoot,
|
||||
MUX_SERVER_AUTH_TOKEN: apiKey,
|
||||
},
|
||||
cwd: MUX_INSTALL_DIR,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Stub for `src/lib/services/installers/ninerouter.ts` activated by
|
||||
* `OMNIROUTE_BUILD_PROFILE=minimal`. The 9router install / spawn helpers are
|
||||
* removed from the built bundle. See SECURITY.md and
|
||||
* docs/security/SOCKET_DEV_FINDINGS.md.
|
||||
*/
|
||||
import { featureDisabledError } from "@/lib/build-profile/featureDisabled";
|
||||
|
||||
const FEATURE = "9router-installer";
|
||||
|
||||
export async function installNinerouter(): Promise<never> {
|
||||
throw featureDisabledError(FEATURE);
|
||||
}
|
||||
|
||||
export function resolveSpawnArgs(_apiKey: string, _port: number): never {
|
||||
throw featureDisabledError(FEATURE);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/db/core";
|
||||
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { runNpm, InstallError } from "./utils";
|
||||
|
||||
export const NINEROUTER_PACKAGE = "9router";
|
||||
export const NINEROUTER_INSTALL_DIR = path.join(DATA_DIR, "services", "9router");
|
||||
|
||||
export interface InstallResult {
|
||||
installedVersion: string;
|
||||
installPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SpawnArgs {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// In-memory latest-version cache, 1h TTL
|
||||
let latestVersionCache: { value: string; expiresAt: number } | null = null;
|
||||
const VERSION_CACHE_TTL_MS = 3_600_000;
|
||||
|
||||
function getServerPath(): string {
|
||||
return path.join(NINEROUTER_INSTALL_DIR, "node_modules", "9router", "app", "server.js");
|
||||
}
|
||||
|
||||
function getInstalledPkgPath(): string {
|
||||
return path.join(NINEROUTER_INSTALL_DIR, "node_modules", "9router", "package.json");
|
||||
}
|
||||
|
||||
export async function getInstalledVersion(): Promise<string | null> {
|
||||
try {
|
||||
const raw = fs.readFileSync(getInstalledPkgPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runNpm(["view", NINEROUTER_PACKAGE, "version"], { timeoutMs: 30_000 });
|
||||
const version = stdout.trim();
|
||||
if (version) {
|
||||
latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
|
||||
}
|
||||
return version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function install(version = "latest"): Promise<InstallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Create install dir + minimal package.json (idempotent)
|
||||
fs.mkdirSync(NINEROUTER_INSTALL_DIR, { recursive: true });
|
||||
const hostPkgPath = path.join(NINEROUTER_INSTALL_DIR, "package.json");
|
||||
if (!fs.existsSync(hostPkgPath)) {
|
||||
fs.writeFileSync(
|
||||
hostPkgPath,
|
||||
JSON.stringify(
|
||||
{ name: "omniroute-9router-host", version: "0.0.0", private: true, dependencies: {} },
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
await runNpm(
|
||||
["install", `${NINEROUTER_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
|
||||
// `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an
|
||||
// argv path so an install dir with spaces survives the Windows shell (#5379).
|
||||
{ cwd: NINEROUTER_INSTALL_DIR, prefix: NINEROUTER_INSTALL_DIR }
|
||||
);
|
||||
|
||||
const installedVersion = await getInstalledVersion();
|
||||
if (!installedVersion) {
|
||||
throw new InstallError(
|
||||
"Could not read installed version from node_modules/9router/package.json",
|
||||
"9router instalado mas versão não pôde ser lida.",
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool: "9router",
|
||||
installedVersion,
|
||||
binaryPath: getServerPath(),
|
||||
status: "stopped",
|
||||
});
|
||||
|
||||
// Invalidate cache so next getLatestVersion() re-fetches
|
||||
latestVersionCache = null;
|
||||
|
||||
return {
|
||||
installedVersion,
|
||||
installPath: NINEROUTER_INSTALL_DIR,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function update(): Promise<InstallResult> {
|
||||
return install("latest");
|
||||
}
|
||||
|
||||
export async function uninstall(): Promise<void> {
|
||||
const nmDir = path.join(NINEROUTER_INSTALL_DIR, "node_modules");
|
||||
if (fs.existsSync(nmDir)) {
|
||||
fs.rmSync(nmDir, { recursive: true, force: true });
|
||||
}
|
||||
await upsertVersionManagerTool({
|
||||
tool: "9router",
|
||||
status: "not_installed",
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs {
|
||||
const serverPath = getServerPath();
|
||||
// Next.js standalone dir ships its own node_modules — include them in NODE_PATH
|
||||
// so native addons (better-sqlite3) resolve correctly.
|
||||
const standaloneDir = path.dirname(serverPath);
|
||||
const bundledNm = path.join(standaloneDir, "node_modules");
|
||||
const existingNodePath = process.env.NODE_PATH ?? "";
|
||||
const nodePath = [bundledNm, existingNodePath].filter(Boolean).join(path.delimiter);
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--max-old-space-size=6144", serverPath],
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// API_KEY_SECRET is the HMAC secret 9router uses to generate/validate API keys.
|
||||
// OmniRoute generates this secret and can derive valid keys from it.
|
||||
API_KEY_SECRET: apiKey,
|
||||
DATA_DIR: path.join(NINEROUTER_INSTALL_DIR, "data"),
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: nodePath,
|
||||
// Embedded mode: skip MITM proxy and cloud tunnel startup
|
||||
DISABLE_MITM: "true",
|
||||
DISABLE_TUNNEL: "true",
|
||||
},
|
||||
cwd: standaloneDir,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Installer utilities — safe execFile wrapper for npm operations.
|
||||
*
|
||||
* Hard rule #13: never string-interpolate runtime values into shell commands.
|
||||
* All npm invocations use execFile() with an explicit args array, never exec().
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000; // 5 min — npm install can be slow
|
||||
|
||||
export interface NpmRunResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class InstallError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly friendly: string,
|
||||
public readonly httpStatus: number = 500
|
||||
) {
|
||||
super(message);
|
||||
this.name = "InstallError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify raw npm/OS errors into user-friendly messages. */
|
||||
function classifyError(
|
||||
err: NodeJS.ErrnoException & { stdout?: string; stderr?: string }
|
||||
): InstallError {
|
||||
const raw = sanitizeErrorMessage(err.message);
|
||||
const stderr = err.stderr ?? "";
|
||||
|
||||
if (err.code === "EACCES") {
|
||||
return new InstallError(
|
||||
raw,
|
||||
"Sem permissão para instalar. Verifique as permissões da pasta de dados.",
|
||||
403
|
||||
);
|
||||
}
|
||||
if (err.code === "ENOENT" && err.message.includes("npm")) {
|
||||
return new InstallError(
|
||||
raw,
|
||||
"Node.js/npm não está disponível no PATH. Instale Node ≥22.22.2.",
|
||||
500
|
||||
);
|
||||
}
|
||||
if (err.code === "ENOSPC" || stderr.includes("ENOSPC")) {
|
||||
return new InstallError(raw, "Espaço em disco insuficiente.", 507);
|
||||
}
|
||||
if (
|
||||
err.signal === "SIGTERM" ||
|
||||
err.code === "ETIMEDOUT" ||
|
||||
(err as Error & { killed?: boolean }).killed
|
||||
) {
|
||||
return new InstallError(raw, "Instalação demorou demais. Tente novamente.", 504);
|
||||
}
|
||||
if (
|
||||
stderr.includes("ENOTFOUND") ||
|
||||
stderr.includes("network") ||
|
||||
stderr.includes("ECONNREFUSED") ||
|
||||
stderr.includes("ERR_INVALID_URL")
|
||||
) {
|
||||
return new InstallError(
|
||||
raw,
|
||||
"Falha de rede ao instalar. Verifique a conexão e tente novamente.",
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
return new InstallError(raw, `Falha na instalação: ${raw}`, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a user-supplied service version (npm dist-tag or semver). Constrained
|
||||
* to letters, digits and `. _ + -`, with a leading alphanumeric, so the value can
|
||||
* never carry shell metacharacters once `runNpm` runs under a shell on Windows
|
||||
* (see `buildNpmExecOptions`). Accepts `latest`, `next`, `1.2.3`, `1.2.3-beta.1`,
|
||||
* `1.2.3+build.5`; rejects `latest && calc`, `$(id)`, spaces, leading `-`, etc.
|
||||
*/
|
||||
export const SERVICE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
||||
|
||||
export interface NpmExecOptions {
|
||||
cwd?: string;
|
||||
timeout: number;
|
||||
env: NodeJS.ProcessEnv;
|
||||
maxBuffer: number;
|
||||
shell?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `execFile` options for {@link runNpm}.
|
||||
*
|
||||
* On Windows, npm is `npm.cmd` (a batch wrapper). Node 24 refuses to `execFile`
|
||||
* a `.cmd` without a shell (nodejs/node#52554 — manifests as `spawn EINVAL`, see
|
||||
* issue #5379), so we enable `shell` on win32 only.
|
||||
*
|
||||
* Enabling the shell means the shell — not `execFile` — splits the command line,
|
||||
* so NO runtime value may be interpolated into argv (Hard Rule #13). The install
|
||||
* prefix (a DATA_DIR path that can legitimately contain spaces, e.g.
|
||||
* `C:\Users\John Doe\.omniroute\…`) is therefore exported as the
|
||||
* `npm_config_prefix` environment variable — npm's documented env form of
|
||||
* `--prefix` — never as an argv entry. With the prefix moved to the environment
|
||||
* and the version constrained by {@link SERVICE_VERSION_PATTERN}, every remaining
|
||||
* argv entry is a static, metacharacter-free flag.
|
||||
*/
|
||||
export function buildNpmExecOptions(
|
||||
platform: NodeJS.Platform,
|
||||
options: { cwd?: string; timeoutMs: number; prefix?: string }
|
||||
): NpmExecOptions {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
if (options.prefix) {
|
||||
env.npm_config_prefix = options.prefix;
|
||||
}
|
||||
const execOptions: NpmExecOptions = {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeoutMs,
|
||||
env,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10 MB for npm output
|
||||
};
|
||||
if (platform === "win32") {
|
||||
execOptions.shell = true;
|
||||
}
|
||||
return execOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs npm with the given args array. Never uses shell interpolation: argv holds
|
||||
* only static flags, and any install prefix is passed via `options.prefix`
|
||||
* (exported as `npm_config_prefix`), not as an argv path. See
|
||||
* {@link buildNpmExecOptions} for the Windows/Node-24 shell handling.
|
||||
*/
|
||||
export function runNpm(
|
||||
args: string[],
|
||||
options: { cwd?: string; timeoutMs?: number; prefix?: string } = {}
|
||||
): Promise<NpmRunResult> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
// On Windows, npm is npm.cmd; on Unix it's npm.
|
||||
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
npmBin,
|
||||
args,
|
||||
buildNpmExecOptions(process.platform, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs,
|
||||
prefix: options.prefix,
|
||||
}),
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const classified = classifyError(
|
||||
Object.assign(err, { stdout, stderr }) as NodeJS.ErrnoException & {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
);
|
||||
reject(classified);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Model sync job for embedded services.
|
||||
*
|
||||
* Fetches /v1/models from a running service instance and persists the list
|
||||
* in the key_value table so the model catalog can expose them without calling
|
||||
* the service on every request. Also stamps last_sync_at in version_manager.
|
||||
*
|
||||
* Schedule: runs once when the service reaches "running" state, then every
|
||||
* SYNC_INTERVAL_MS. Stops automatically when the service stops.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getServiceModels, saveServiceModels, type ServiceModel } from "@/lib/db/serviceModels";
|
||||
import { updateVersionManagerTool } from "@/lib/db/versionManager";
|
||||
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const activeTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
/**
|
||||
* Fetch the model list from a running service and persist it.
|
||||
* Returns the number of models synced, or -1 on failure.
|
||||
*/
|
||||
export async function syncServiceModels(
|
||||
tool: string,
|
||||
baseUrl: string,
|
||||
apiKey: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[ModelSync:${tool}] /v1/models returned HTTP ${res.status}`);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const data: unknown = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
||||
const models = (data as unknown[])
|
||||
.filter(
|
||||
(m): m is ServiceModel =>
|
||||
typeof m === "object" &&
|
||||
m !== null &&
|
||||
typeof (m as Record<string, unknown>).id === "string"
|
||||
)
|
||||
.map((m) => ({
|
||||
...m,
|
||||
// Prefix the model id with the tool name so downstream routing and the
|
||||
// executor strip it back off (e.g. "9router/cx/gpt-5-mini").
|
||||
id: m.id.startsWith(`${tool}/`) ? m.id : `${tool}/${m.id}`,
|
||||
}));
|
||||
|
||||
saveServiceModels(tool, models);
|
||||
await updateVersionManagerTool(tool, { lastSyncAt: new Date().toISOString() });
|
||||
|
||||
console.log(`[ModelSync:${tool}] synced ${models.length} model(s)`);
|
||||
return models.length;
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
console.warn(`[ModelSync:${tool}] fetch failed: ${msg}`);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic model sync for a service.
|
||||
* Idempotent — calling again while already running is a no-op.
|
||||
*/
|
||||
export function scheduleServiceModelSync(
|
||||
tool: string,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
intervalMs = SYNC_INTERVAL_MS
|
||||
): void {
|
||||
if (activeTimers.has(tool)) return;
|
||||
|
||||
// First sync immediately (non-blocking)
|
||||
syncServiceModels(tool, baseUrl, apiKey).catch(() => {});
|
||||
|
||||
const timer = setInterval(() => {
|
||||
syncServiceModels(tool, baseUrl, apiKey).catch(() => {});
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
|
||||
activeTimers.set(tool, timer);
|
||||
console.log(`[ModelSync:${tool}] scheduler started (interval ${intervalMs / 1000}s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic sync for a service.
|
||||
*/
|
||||
export function stopServiceModelSync(tool: string): void {
|
||||
const timer = activeTimers.get(tool);
|
||||
if (!timer) return;
|
||||
clearInterval(timer);
|
||||
activeTimers.delete(tool);
|
||||
console.log(`[ModelSync:${tool}] scheduler stopped`);
|
||||
}
|
||||
|
||||
/** Re-export read path so consumers don't need to import two modules. */
|
||||
export { getServiceModels };
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Pre-spawn port/health probe for embedded services (#6205).
|
||||
*
|
||||
* Before the supervisor spawns a service child, it probes the service's port
|
||||
* and health endpoint. This turns two failure modes into graceful outcomes
|
||||
* instead of a raw `EADDRINUSE` stack trace crashing the child:
|
||||
*
|
||||
* - A healthy prior instance is already answering → ADOPT it (skip spawn).
|
||||
* - The port is held but nothing healthy answers → surface a CLEAR error.
|
||||
* - The port is free → SPAWN normally.
|
||||
*
|
||||
* `decidePreSpawn` is a pure function so the decision logic is unit-testable
|
||||
* without binding a real port or spawning a process.
|
||||
*/
|
||||
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
/** Result of probing the service before spawning. */
|
||||
export interface PreSpawnProbe {
|
||||
/** true when the service's healthUrl answered with a 2xx. */
|
||||
healthy: boolean;
|
||||
/** true when something is already listening on the service's port. */
|
||||
portInUse: boolean;
|
||||
}
|
||||
|
||||
/** Outcome of the pre-spawn decision. */
|
||||
export type PreSpawnDecision =
|
||||
| { action: "spawn" }
|
||||
| { action: "adopt" }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
const HEALTH_PROBE_TIMEOUT_MS = 3_000;
|
||||
const PORT_PROBE_TIMEOUT_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Decide what to do before spawning, given a probe of the port + health.
|
||||
*
|
||||
* Pure — no I/O — so it can be exhaustively unit-tested.
|
||||
*/
|
||||
export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDecision {
|
||||
// A healthy instance is already serving on the port — adopt it rather than
|
||||
// spawn a duplicate that would immediately die with EADDRINUSE.
|
||||
if (probe.healthy) {
|
||||
return { action: "adopt" };
|
||||
}
|
||||
// Port is held but nothing healthy answers: an orphaned or unrelated process
|
||||
// is squatting on it. Surface a clear, actionable error instead of letting
|
||||
// the child crash with a raw EADDRINUSE stack.
|
||||
if (probe.portInUse) {
|
||||
return {
|
||||
action: "error",
|
||||
message:
|
||||
`Port ${port} is already in use but the service did not respond to a health ` +
|
||||
`check. An orphaned previous instance or an unrelated process may be holding ` +
|
||||
`the port — stop it (or free the port) and try starting the service again.`,
|
||||
};
|
||||
}
|
||||
// Port is free and nothing is answering — safe to spawn.
|
||||
return { action: "spawn" };
|
||||
}
|
||||
|
||||
/** TCP connect check: resolves true when something accepts a connection. */
|
||||
function isPortInUse(port: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = createConnection({ host: "127.0.0.1", port }, () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.on("error", () => resolve(false));
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Health check: resolves true when healthUrl answers with a 2xx. */
|
||||
async function isHealthy(healthUrl: string, timeoutMs: number): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(healthUrl, { signal: controller.signal });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the service's port + health endpoint before spawning.
|
||||
*
|
||||
* @param healthUrl The service's health endpoint URL.
|
||||
* @param port The service's registered port.
|
||||
*/
|
||||
export async function probeBeforeSpawn(healthUrl: string, port: number): Promise<PreSpawnProbe> {
|
||||
const [healthy, portInUse] = await Promise.all([
|
||||
isHealthy(healthUrl, HEALTH_PROBE_TIMEOUT_MS),
|
||||
isPortInUse(port, PORT_PROBE_TIMEOUT_MS),
|
||||
]);
|
||||
return { healthy, portInUse };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Singleton registry of ServiceSupervisor instances. */
|
||||
|
||||
import type { ServiceSupervisor } from "./ServiceSupervisor";
|
||||
|
||||
const supervisors = new Map<string, ServiceSupervisor>();
|
||||
|
||||
export function registerSupervisor(supervisor: ServiceSupervisor): void {
|
||||
supervisors.set(supervisor.getStatus().tool, supervisor);
|
||||
}
|
||||
|
||||
export function getSupervisor(tool: string): ServiceSupervisor | null {
|
||||
return supervisors.get(tool) ?? null;
|
||||
}
|
||||
|
||||
/** Remove a supervisor by tool name. Intended for use in tests. */
|
||||
export function unregisterSupervisor(tool: string): void {
|
||||
supervisors.delete(tool);
|
||||
}
|
||||
|
||||
async function stopAll(): Promise<void> {
|
||||
// Drive every supervisor stop to completion before the process exits so the
|
||||
// DB status writes inside ServiceSupervisor.stop() flush. Otherwise the
|
||||
// event loop drains immediately on SIGTERM and rows are stuck in "running"
|
||||
// or "starting" until the next boot.
|
||||
await Promise.allSettled(Array.from(supervisors.values()).map((supervisor) => supervisor.stop()));
|
||||
}
|
||||
|
||||
function handleShutdownSignal(signal: NodeJS.Signals): void {
|
||||
stopAll()
|
||||
.catch(() => {
|
||||
/* never throw out of a signal handler */
|
||||
})
|
||||
.finally(() => {
|
||||
// Re-raise the signal with the default disposition so the process exit
|
||||
// status reflects the original signal (128 + signal number) rather than
|
||||
// a synthetic process.exit() code.
|
||||
process.kill(process.pid, signal);
|
||||
});
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => handleShutdownSignal("SIGINT"));
|
||||
process.once("SIGTERM", () => handleShutdownSignal("SIGTERM"));
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Generic reverse-proxy helper for embedded service UIs.
|
||||
*
|
||||
* Forwards HTTP traffic to a locally-running embedded service so its web UI
|
||||
* can be iframed inside the OmniRoute dashboard without CORS issues.
|
||||
*
|
||||
* Security:
|
||||
* - Target URL is constructed from the service's registered port — never
|
||||
* from user input — eliminating SSRF risk.
|
||||
* - Routes that use this helper must be classified LOCAL_ONLY in routeGuard.ts;
|
||||
* loopback enforcement blocks all non-loopback access before any handler runs.
|
||||
* - Client cookies and Authorization headers are stripped before forwarding
|
||||
* to prevent credential leakage between OmniRoute and the embedded service.
|
||||
* - Upstream set-cookie, x-frame-options, content-security-policy, and
|
||||
* cross-origin-* headers are stripped from responses so the iframe is not
|
||||
* broken by the embedded service's own security policies.
|
||||
* - HTML responses are rewritten (parse5) so path-absolute URLs work through
|
||||
* the proxy prefix.
|
||||
*/
|
||||
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { getOrCreateApiKey } from "@/lib/services/apiKey";
|
||||
import { rewriteHtml } from "@/lib/services/htmlRewriter";
|
||||
import { toUpstreamPath } from "@/lib/services/embedPath";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
// ─── constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Standard hop-by-hop headers that must never be forwarded. */
|
||||
export const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Request headers stripped before forwarding to the embedded service.
|
||||
* Prevents OmniRoute session cookies and Authorization from leaking upstream.
|
||||
*/
|
||||
export const STRIPPED_REQUEST_HEADERS = new Set(["cookie", "authorization"]);
|
||||
|
||||
/**
|
||||
* Response headers stripped before returning to the browser.
|
||||
*
|
||||
* - set-cookie: prevents the embedded service from setting cookies in the
|
||||
* OmniRoute origin, which would conflict with session management.
|
||||
* - content-security-policy / content-security-policy-report-only: the
|
||||
* embedded service's CSP is irrelevant inside the OmniRoute iframe.
|
||||
* - x-frame-options: would block the iframe entirely if set to DENY/SAMEORIGIN
|
||||
* by the embedded service (OmniRoute controls framing via its own CSP).
|
||||
* - cross-origin-*: remove COOP/COEP/CORP that could break the framed page.
|
||||
*/
|
||||
export const STRIPPED_RESPONSE_HEADERS = new Set([
|
||||
"set-cookie",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"x-frame-options",
|
||||
"cross-origin-embedder-policy",
|
||||
"cross-origin-opener-policy",
|
||||
"cross-origin-resource-policy",
|
||||
]);
|
||||
|
||||
export const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
// ─── public interface ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReverseProxyConfig {
|
||||
/** Service name used for supervisor lookup and API key retrieval. */
|
||||
name: string;
|
||||
/** Proxy prefix path (e.g. "/dashboard/providers/services/9router/embed"). */
|
||||
publicPrefix: string;
|
||||
/** When true, HTML responses are rewritten via htmlRewriter. Default: true. */
|
||||
htmlRewrite?: boolean;
|
||||
// Future: stripCookies, injectHeaders, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a request through to the locally-running embedded service identified
|
||||
* by `config.name`.
|
||||
*
|
||||
* Resolves the upstream port from the registered supervisor, builds the
|
||||
* upstream URL from `pathSegments`, forwards safe headers, injects the
|
||||
* service's own API key, and returns the upstream response with
|
||||
* security-conflicting headers stripped.
|
||||
*
|
||||
* @param request The incoming Next.js route Request.
|
||||
* @param pathSegments The `[[...path]]` catch-all segments, e.g. `["ui", "index.html"]` or `[]`.
|
||||
* @param config Proxy configuration (service name + public prefix).
|
||||
*/
|
||||
export async function proxyRequest(
|
||||
request: Request,
|
||||
pathSegments: string[],
|
||||
config: ReverseProxyConfig
|
||||
): Promise<Response> {
|
||||
const { name, publicPrefix, htmlRewrite = true } = config;
|
||||
|
||||
const supervisor = getSupervisor(name);
|
||||
if (!supervisor) {
|
||||
return createErrorResponse({ status: 404, message: `Service '${name}' not found.` });
|
||||
}
|
||||
|
||||
const { state, port } = supervisor.getStatus();
|
||||
if (state !== "running") {
|
||||
return createErrorResponse({
|
||||
status: 503,
|
||||
message: `Service '${name}' is not running (state: ${state}).`,
|
||||
});
|
||||
}
|
||||
|
||||
const incomingUrl = new URL(request.url);
|
||||
const upstreamPath = toUpstreamPath(pathSegments);
|
||||
const upstreamUrl = `http://127.0.0.1:${port}${upstreamPath}${incomingUrl.search}`;
|
||||
|
||||
// Build forwarded headers: strip hop-by-hop AND sensitive client headers.
|
||||
const forwardHeaders = new Headers();
|
||||
for (const [k, v] of request.headers.entries()) {
|
||||
const lower = k.toLowerCase();
|
||||
if (!HOP_BY_HOP.has(lower) && !STRIPPED_REQUEST_HEADERS.has(lower)) {
|
||||
forwardHeaders.set(k, v);
|
||||
}
|
||||
}
|
||||
forwardHeaders.set("host", `127.0.0.1:${port}`);
|
||||
|
||||
// Inject the embedded service's own API key so upstream can authenticate.
|
||||
const apiKey = await getOrCreateApiKey(name);
|
||||
forwardHeaders.set("authorization", `Bearer ${apiKey}`);
|
||||
|
||||
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
||||
|
||||
try {
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method: request.method,
|
||||
headers: forwardHeaders,
|
||||
body: hasBody ? request.body : undefined,
|
||||
// @ts-expect-error -- duplex is required by the Fetch spec for streaming
|
||||
// request bodies but is not yet in the TS DOM lib (Node.js 18+ supports it).
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
// Build response headers: strip hop-by-hop and security-conflicting headers.
|
||||
const responseHeaders = new Headers();
|
||||
for (const [k, v] of upstream.headers.entries()) {
|
||||
const lower = k.toLowerCase();
|
||||
if (!HOP_BY_HOP.has(lower) && !STRIPPED_RESPONSE_HEADERS.has(lower)) {
|
||||
responseHeaders.set(k, v);
|
||||
}
|
||||
}
|
||||
// Prevent Next.js from caching the proxied response.
|
||||
responseHeaders.set("cache-control", "no-store");
|
||||
|
||||
const contentType = upstream.headers.get("content-type") ?? "";
|
||||
|
||||
// HTML responses: buffer, rewrite links, return as string.
|
||||
if (htmlRewrite && contentType.startsWith("text/html")) {
|
||||
const html = await upstream.text();
|
||||
const rewritten = rewriteHtml(html, publicPrefix);
|
||||
return new Response(rewritten, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// All other content types: stream through unchanged.
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 502, message: `Proxy error: ${msg}` });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** In-memory ring buffer for service log lines with optional file flush. */
|
||||
|
||||
import fs from "node:fs";
|
||||
import type { LogLine } from "./types";
|
||||
|
||||
const DEFAULT_MAX_BYTES = 5_242_880; // 5 MB
|
||||
const FLUSH_DEBOUNCE_MS = 60_000; // 1 min
|
||||
|
||||
export class RingBuffer {
|
||||
private entries: LogLine[] = [];
|
||||
private currentBytes = 0;
|
||||
private readonly maxBytes: number;
|
||||
private subscribers: Set<(line: LogLine) => void> = new Set();
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushPath: string | null = null;
|
||||
private flushWarnedOnce = false;
|
||||
|
||||
constructor(maxBytes: number = DEFAULT_MAX_BYTES) {
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
setFlushPath(filePath: string): void {
|
||||
this.flushPath = filePath;
|
||||
}
|
||||
|
||||
push(line: LogLine): void {
|
||||
const entryBytes = Buffer.byteLength(line.line, "utf8") + 40;
|
||||
|
||||
while (this.entries.length > 0 && this.currentBytes + entryBytes > this.maxBytes) {
|
||||
const evicted = this.entries.shift();
|
||||
if (evicted) {
|
||||
this.currentBytes -= Buffer.byteLength(evicted.line, "utf8") + 40;
|
||||
if (this.currentBytes < 0) this.currentBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.entries.push(line);
|
||||
this.currentBytes += entryBytes;
|
||||
|
||||
for (const cb of this.subscribers) {
|
||||
try {
|
||||
cb(line);
|
||||
} catch {
|
||||
// subscriber errors must not crash the supervisor
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
snapshot(): LogLine[] {
|
||||
return this.entries.slice();
|
||||
}
|
||||
|
||||
subscribe(cb: (line: LogLine) => void): () => void {
|
||||
this.subscribers.add(cb);
|
||||
return () => this.subscribers.delete(cb);
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (!this.flushPath) return;
|
||||
if (this.flushTimer) return;
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushToDisk();
|
||||
}, FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private flushToDisk(): void {
|
||||
if (!this.flushPath) return;
|
||||
try {
|
||||
const content = this.entries.map((e) => `${e.ts} [${e.stream}] ${e.line}`).join("\n");
|
||||
fs.writeFileSync(this.flushPath, content, "utf8");
|
||||
} catch (err: unknown) {
|
||||
if (!this.flushWarnedOnce) {
|
||||
this.flushWarnedOnce = true;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Non-fatal — log once and stop trying
|
||||
console.warn(`[RingBuffer] flush to ${this.flushPath} failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
this.subscribers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Shared types for the embedded-services layer (ServiceSupervisor, installers, registry). */
|
||||
|
||||
export interface ServiceConfig {
|
||||
tool: string;
|
||||
port: number;
|
||||
spawnArgs: () => {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
};
|
||||
healthUrl: () => string;
|
||||
healthIntervalMs: number;
|
||||
stopTimeoutMs: number;
|
||||
logsBufferBytes: number;
|
||||
/**
|
||||
* When true (#6205), the supervisor probes the port + health endpoint before
|
||||
* spawning: a healthy prior instance is adopted, a held-but-unhealthy port
|
||||
* yields a clear error instead of a raw EADDRINUSE stack. Opt-in so the
|
||||
* default spawn path (and existing supervisor tests) stays byte-identical —
|
||||
* enabled for services that bind a fixed port (e.g. 9router).
|
||||
*/
|
||||
probeBeforeSpawn?: boolean;
|
||||
}
|
||||
|
||||
export type ServiceState =
|
||||
| "not_installed"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type HealthState = "healthy" | "unhealthy" | "unknown";
|
||||
|
||||
export interface ServiceStatus {
|
||||
tool: string;
|
||||
state: ServiceState;
|
||||
pid: number | null;
|
||||
port: number;
|
||||
health: HealthState;
|
||||
startedAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
ts: number;
|
||||
stream: "stdout" | "stderr";
|
||||
line: string;
|
||||
}
|
||||
Reference in New Issue
Block a user