import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; /** 通用 JSON mock 服务底座:路由、调用日志、可编程故障。供 mock TMDB / mock Emby 使用。 */ export interface MockCall { method: string; path: string; query: Record; headers: Record; status: number; } export interface MockFault { status?: number; body?: unknown; headers?: Record; delayMs?: number; /** 直接断开连接 */ drop?: boolean; times?: number; } export interface MockReply { status?: number; json?: unknown; headers?: Record; } export type MockRoute = (m: RegExpMatchArray, q: Record, req: IncomingMessage) => MockReply; export interface MockHttp { url: string; calls: MockCall[]; close(): Promise; /** 对路径匹配 pattern(字符串前缀或正则)的请求注入故障 */ fault(pattern: string | RegExp, f: MockFault): void; clearFaults(): void; callsTo(pattern: string | RegExp): MockCall[]; } const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path)); export async function startMockHttp( routes: [RegExp, MockRoute][], opts: { port?: number; guard?: (req: IncomingMessage, q: Record) => MockReply | null } = {}, ): Promise { const calls: MockCall[] = []; const faults: { pattern: string | RegExp; f: MockFault }[] = []; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const u = new URL(req.url ?? "/", "http://mock"); const q = Object.fromEntries(u.searchParams); const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 }; calls.push(call); const reply = (r: MockReply) => { call.status = r.status ?? 200; if (res.destroyed) return; res.writeHead(call.status, { "content-type": "application/json", ...r.headers }); res.end(r.json === undefined ? "" : JSON.stringify(r.json)); }; const fi = faults.findIndex((x) => matches(x.pattern, u.pathname)); const fault = fi >= 0 ? faults[fi]!.f : undefined; if (fault) { fault.times = (fault.times ?? 1) - 1; if (fault.times <= 0) faults.splice(fi, 1); if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs)); if (fault.drop) { call.status = -1; req.socket.destroy(); return; } if (fault.status) return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) }); } const denied = opts.guard?.(req, q); if (denied) return reply(denied); for (const [re, h] of routes) { const m = re.exec(u.pathname); if (m) { try { return reply(h(m, q, req)); } catch (e) { return reply({ status: 500, json: { error: String(e) } }); } } } reply({ status: 404, json: { error: "no route" } }); }); await new Promise((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok)); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}`, calls, close: () => new Promise((ok) => { server.closeAllConnections(); server.close(() => ok()); }), fault: (pattern, f) => faults.push({ pattern, f: { ...f } }), clearFaults: () => { faults.length = 0; }, callsTo: (p) => calls.filter((c) => matches(p, c.path)), }; }