Files
openhuman/scripts/mock-api/server.mjs
T

324 lines
9.1 KiB
JavaScript

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 { handleTelegram } from "./routes/telegram.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 = [
// Telegram Bot API paths start with /bot<token>/… — check before the
// general-purpose handlers so the distinctive prefix routes cleanly.
handleTelegram,
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") {
if (!pathPatternMatches(rule.pathRegex, ctx.url)) return false;
}
if (typeof rule.contains === "string" && !ctx.url.includes(rule.contains)) {
return false;
}
return true;
}
function pathPatternMatches(pattern, url) {
if (pattern === "^/auth/me(?:\\?.*)?$") {
return url === "/auth/me" || url.startsWith("/auth/me?");
}
if (/^[a-zA-Z0-9_./?=&:-]+$/.test(pattern)) {
return url === pattern;
}
return false;
}
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 mode = typeof rule.mode === "string" ? rule.mode : "status";
// Chaos mode "reset": tear down the socket mid-response so the client sees
// a connection reset (ECONNRESET / "socket hang up") rather than a clean
// HTTP status — a real outage shape the status path can't reproduce.
if (mode === "reset") {
console.warn(
`[MockServer] Injected connection reset ${ctx.method} ${ctx.url}`,
);
ctx.res.socket?.destroy();
return true;
}
// Chaos mode "malformed": a 200 carrying a non-JSON body, so the caller's
// JSON parse throws instead of receiving a clean error envelope.
if (mode === "malformed") {
const status = Number(rule.status || 200);
const raw = typeof rule.body === "string" ? rule.body : "<<not-json>>{";
console.warn(
`[MockServer] Injected malformed body ${ctx.method} ${ctx.url} -> ${status}`,
);
setCors(ctx.res);
ctx.res.writeHead(status, { "Content-Type": "application/json" });
ctx.res.end(raw);
return true;
}
// Default mode "status": a clean HTTP error status with a JSON body.
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, head) =>
handleWebSocketUpgrade(req, socket, head),
);
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();
});
});
}