import { existsSync, readdirSync } from "node:fs"; import { createServer, request as httpRequest } from "node:http"; import { createServer as createNetServer } from "node:net"; import { resolve, relative, join } from "node:path"; import { createMockServer, type MockServer } from "../../../testing/mock-server"; import { type CommandResult, silentFailure } from "../../lib/command-result"; interface MockEntry { provider: string; scenario: string; mockPath: string; } interface RunningMock { server: MockServer; route: string; provider: string; scenario: string; } function readdirSafe(dir: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); } catch { return []; } } export function discoverMocks(mocksDir: string, filters: string[]): MockEntry[] { const mocks: MockEntry[] = []; for (const provider of readdirSafe(mocksDir)) { const providerDir = join(mocksDir, provider); for (const scenario of readdirSafe(providerDir)) { const mockPath = join(providerDir, scenario, "mock.json"); if (!existsSync(mockPath)) continue; mocks.push({ provider, scenario, mockPath }); } } if (filters.length === 0) return mocks; return mocks.filter(({ provider, scenario }) => filters.some((f) => f === provider || f === `${provider}/${scenario}`), ); } // Mockoon doesn't expose the actual listening port, so we can't use port 0 with readback. // This has a minor TOCTOU window between closing this server and Mockoon binding. function findFreePort(): Promise { return new Promise((resolve, reject) => { const srv = createNetServer(); srv.listen(0, "127.0.0.1", () => { const addr = srv.address(); const port = typeof addr === "object" && addr ? addr.port : 0; srv.close(() => resolve(port)); }); srv.on("error", reject); }); } async function startMock(mock: MockEntry): Promise { const port = await findFreePort(); const server = await createMockServer(mock.mockPath, port); const route = `/${mock.provider}/${mock.scenario}`; return { server, route, provider: mock.provider, scenario: mock.scenario }; } export type RouteResolution = | { kind: "malformed" } | { kind: "unknownRoute"; key: string } | { kind: "matched"; key: string; downstream: string }; // Resolve `/{provider}/{scenario}/rest?query` to a known mock route, stripping // the `/{provider}/{scenario}` prefix and keeping the rest (including query). export function resolveRoute(url: string | undefined, knownRoutes: string[]): RouteResolution { const match = (url ?? "").match(/^\/([^/?]+)\/([^/?]+)(\/[^?]*)?(\?.*)?$/); if (!match) return { kind: "malformed" }; const [, provider, scenario] = match; const key = `/${provider}/${scenario}`; if (!knownRoutes.includes(key)) return { kind: "unknownRoute", key }; const downstream = (url ?? "/").slice(key.length) || "/"; return { kind: "matched", key, downstream }; } function createProxy(routeTable: Map) { return createServer((req, res) => { const resolution = resolveRoute(req.url, [...routeTable.keys()]); if (resolution.kind === "malformed") { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found. Use /{provider}/{scenario}/..." })); return; } if (resolution.kind === "unknownRoute") { res.writeHead(404, { "Content-Type": "application/json" }); res.end( JSON.stringify({ error: `Unknown route: ${resolution.key}`, available: [...routeTable.keys()], }), ); return; } const target = routeTable.get(resolution.key); // resolveRoute only returns "matched" for keys present in routeTable if (!target) return; const proxyReq = httpRequest( { hostname: "127.0.0.1", port: target.server.port, path: resolution.downstream, method: req.method, headers: { ...req.headers, host: `127.0.0.1:${target.server.port}` }, }, (proxyRes) => { res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); proxyRes.pipe(res); }, ); proxyReq.on("error", (err) => { if (!res.headersSent) { res.writeHead(502, { "Content-Type": "application/json" }); } res.end(JSON.stringify({ error: "Bad gateway", detail: err.message })); }); req.pipe(proxyReq); }); } export async function runMockStart( mocksRoot: string, filters: string[], port: number, ): Promise { const mocksDir = resolve(mocksRoot); const mocks = discoverMocks(mocksDir, filters); if (mocks.length === 0) { console.error("No matching mocks found."); if (filters.length > 0) { console.error(`Filters: ${filters.join(", ")}`); console.error(`Available mocks are under: ${relative(process.cwd(), mocksDir)}/`); } return silentFailure(); } console.log(`Starting ${mocks.length} mock(s)...\n`); const running: RunningMock[] = []; for (const mock of mocks) { const info = await startMock(mock); running.push(info); } const routeTable = new Map(); for (const r of running) { routeTable.set(r.route, r); } console.log("Route table:"); console.log("\u2500".repeat(50)); for (const [route, { server }] of routeTable) { console.log(` ${route} \u2192 127.0.0.1:${server.port}`); } console.log("\u2500".repeat(50)); const proxy = createProxy(routeTable); proxy.listen(port, () => { console.log(`\nReverse proxy listening on http://localhost:${port}`); console.log("Press Ctrl+C to stop all mocks.\n"); }); function shutdown() { console.log("\nShutting down..."); proxy.close(); for (const { server } of running) { void server.stop(); } process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); // Block until signal handler calls process.exit return new Promise(() => void 0); }