import http from "node:http"; import { handleAdmin } from "./admin.mjs"; import { json, normalizeHeaders, readBody, requestOrigin, setCors, tryParseJson, } from "./http.mjs"; import { handleAuth } from "./routes/auth.mjs"; import { handleAudio } from "./routes/audio.mjs"; import { handleConversations } from "./routes/conversations.mjs"; import { handleCron } from "./routes/cron.mjs"; import { handleIntegrations } from "./routes/integrations.mjs"; import { handleInvites } from "./routes/invites.mjs"; import { handleLlmCompletions } from "./routes/llm.mjs"; import { handleOAuth } from "./routes/oauth.mjs"; import { handlePayments } from "./routes/payments.mjs"; import { handleUser } from "./routes/user.mjs"; import { handleVersion } from "./routes/version.mjs"; import { handleWebhooks } from "./routes/webhooks.mjs"; import { handleSocketRequest, handleWebSocketUpgrade } from "./socket.mjs"; import { appendRequest, behavior, DEFAULT_PORT, hashString, MAX_PORT_RETRY_ATTEMPTS, openSockets, parseBehaviorJson, sleep, } from "./state.mjs"; let server = null; // Order matters: admin & socket.io short-circuit early; the rest fall through // in domain order so the cheapest predicates run first. const ROUTE_HANDLERS = [ handleOAuth, handleAuth, handleUser, handleInvites, handlePayments, handleAudio, // LLM completions must run before the catch-all stub in // `handleIntegrations` so keyword-driven test scripts can override // the default "Hello from e2e mock agent" reply. handleLlmCompletions, handleIntegrations, handleWebhooks, handleCron, handleConversations, handleVersion, ]; async function handleRequest(req, res) { const method = req.method ?? "GET"; const url = req.url ?? "/"; const body = await readBody(req); const parsedBody = tryParseJson(body); const origin = requestOrigin(req); appendRequest({ method, url, body, headers: normalizeHeaders(req.headers), timestamp: Date.now(), }); if (method === "OPTIONS") { setCors(res); res.writeHead(204); res.end(); return; } const ctx = { method, url, body, parsedBody, origin, req, res, getPort: getMockServerPort, }; if (handleAdmin(ctx)) return; const maybeShortCircuit = await maybeApplyGlobalBehavior(ctx); if (maybeShortCircuit) return; if (url.startsWith("/socket.io/")) { handleSocketRequest(ctx); return; } for (const handler of ROUTE_HANDLERS) { if (await handler(ctx)) return; } // Catch-all: fail fast so tests notice missing mock endpoints. console.log(`[MockServer] UNHANDLED ${method} ${url}`); json(res, 404, { success: false, error: `Mock server: no handler for ${method} ${url}`, }); } function requestHash(ctx) { return hashString(`${ctx.method}:${ctx.url}:${ctx.body || ""}`); } function ruleMatches(rule, ctx) { if (!rule || typeof rule !== "object") return false; if (rule.method && String(rule.method).toUpperCase() !== ctx.method) return false; if (typeof rule.path === "string" && rule.path !== ctx.url) return false; if (typeof rule.pathRegex === "string") { try { const regex = new RegExp(rule.pathRegex); if (!regex.test(ctx.url)) return false; } catch { return false; } } if (typeof rule.contains === "string" && !ctx.url.includes(rule.contains)) { return false; } return true; } async function maybeApplyGlobalBehavior(ctx) { const mockBehavior = behavior(); const baseDelay = Number(mockBehavior.globalDelayMs || 0); const jitterMax = Number(mockBehavior.globalJitterMs || 0); const jitter = Number.isFinite(jitterMax) && jitterMax > 0 ? requestHash(ctx) % Math.min(jitterMax, 5000) : 0; const totalDelay = Math.max(0, baseDelay) + jitter; if (totalDelay > 0) { await sleep(Math.min(totalDelay, 30_000)); } const rules = parseBehaviorJson("httpFaultRules", []); if (!Array.isArray(rules)) return false; for (const rule of rules) { if (!ruleMatches(rule, ctx)) continue; const status = Number(rule.status || 500); const body = rule.body && typeof rule.body === "object" ? rule.body : { success: false, error: rule.error || "Injected mock fault", }; console.warn( `[MockServer] Injected fault ${ctx.method} ${ctx.url} -> ${status}`, ); json(ctx.res, status, body); return true; } return false; } export function getMockServerPort() { const address = server?.address(); return typeof address === "object" && address ? address.port : null; } function createServerInstance() { const nextServer = http.createServer((req, res) => { handleRequest(req, res).catch((err) => { console.error("[MockServer] Unhandled error:", err); json(res, 500, { success: false, error: "Internal mock error" }); }); }); nextServer.on("connection", (socket) => { openSockets.add(socket); socket.on("close", () => openSockets.delete(socket)); }); nextServer.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket), ); return nextServer; } function listen(serverInstance, port) { return new Promise((resolve, reject) => { const onError = (err) => { serverInstance.off("listening", onListening); reject(err); }; const onListening = () => { serverInstance.off("error", onError); const address = serverInstance.address(); const resolvedPort = typeof address === "object" && address ? address.port : port; resolve(resolvedPort); }; serverInstance.once("error", onError); serverInstance.once("listening", onListening); serverInstance.listen(port, "127.0.0.1"); }); } export async function startMockServer(port = DEFAULT_PORT, options = {}) { if (server) { return { port: getMockServerPort() ?? port, alreadyRunning: true }; } const preferredPort = Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT; const retryIfInUse = options.retryIfInUse === true; const candidatePorts = retryIfInUse ? [ preferredPort, ...Array.from( { length: MAX_PORT_RETRY_ATTEMPTS }, (_, i) => preferredPort + i + 1, ), 0, ] : [preferredPort]; let lastError = null; for (const candidatePort of candidatePorts) { const nextServer = createServerInstance(); try { const resolvedPort = await listen(nextServer, candidatePort); server = nextServer; const retryNote = resolvedPort === preferredPort ? "" : ` (preferred ${preferredPort} unavailable)`; console.log( `[MockServer] Listening on http://127.0.0.1:${resolvedPort}${retryNote}`, ); return { port: resolvedPort, alreadyRunning: false, requestedPort: preferredPort, retried: resolvedPort !== preferredPort, }; } catch (err) { try { nextServer.close(); } catch { // The failed candidate may never have reached the listening state. } lastError = err; if (!retryIfInUse || err?.code !== "EADDRINUSE") { throw err; } console.warn( `[MockServer] Port ${candidatePort} unavailable; trying another local port`, ); } } throw lastError ?? new Error("Mock server failed to start"); } export function stopMockServer() { return new Promise((resolve) => { if (!server) { resolve(); return; } for (const socket of openSockets) { socket.destroy(); } openSockets.clear(); server.close(() => { console.log("[MockServer] Stopped"); server = null; resolve(); }); }); }