@passelin/mock-bff 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js ADDED
@@ -0,0 +1,869 @@
1
+ import Fastify from "fastify";
2
+ import cors from "@fastify/cors";
3
+ import multipart from "@fastify/multipart";
4
+ import fastifyStatic from "@fastify/static";
5
+ import { readFile, rm, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { MockStorage } from "./storage.js";
8
+ import { isApiLikeRequest, parseHar } from "./har.js";
9
+ import { buildVariantName, matchMock } from "./matcher.js";
10
+ import { buildPrompt, generateMockResponse } from "./ai.js";
11
+ import { normalizePath, normalizeQuery } from "./utils.js";
12
+ import { buildOpenApiHint, loadOpenApiFile, validateResponseWithOpenApi, } from "./openapi.js";
13
+ const DROPPED_REPLAY_HEADERS = [
14
+ "content-encoding",
15
+ "content-length",
16
+ "transfer-encoding",
17
+ "connection",
18
+ ];
19
+ function sanitizeReplayHeaders(headers) {
20
+ const dropped = new Set(DROPPED_REPLAY_HEADERS);
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(headers)) {
23
+ if (dropped.has(k.toLowerCase()))
24
+ continue;
25
+ out[k] = v;
26
+ }
27
+ return out;
28
+ }
29
+ function maskApiKey(value) {
30
+ if (!value)
31
+ return null;
32
+ const trimmed = value.trim();
33
+ if (!trimmed)
34
+ return null;
35
+ return `${trimmed.slice(0, 6)}…`;
36
+ }
37
+ function normalizeModelList(models) {
38
+ return [...new Set(models)]
39
+ .filter((m) => m)
40
+ .sort((a, b) => a.localeCompare(b));
41
+ }
42
+ function knownModels(provider) {
43
+ if (provider === "openai")
44
+ return normalizeModelList(["gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "gpt-4o"]);
45
+ if (provider === "anthropic")
46
+ return normalizeModelList([
47
+ "claude-3-7-sonnet-latest",
48
+ "claude-3-5-sonnet-latest",
49
+ "claude-3-5-haiku-latest",
50
+ ]);
51
+ if (provider === "ollama")
52
+ return normalizeModelList(["llama3.1:8b", "qwen2.5:7b", "mistral:7b"]);
53
+ return [];
54
+ }
55
+ function ollamaTagsUrl(base) {
56
+ const normalized = base.replace(/\/+$/, "").replace(/\/v1$/, "");
57
+ return `${normalized}/api/tags`;
58
+ }
59
+ function ollamaShowUrl(base) {
60
+ const normalized = base.replace(/\/+$/, "").replace(/\/v1$/, "");
61
+ return `${normalized}/api/show`;
62
+ }
63
+ async function ollamaCapabilities(base, model) {
64
+ try {
65
+ const res = await fetch(ollamaShowUrl(base), {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify({ model }),
69
+ });
70
+ if (!res.ok)
71
+ return [];
72
+ const data = (await res.json());
73
+ return data.capabilities ?? data.details?.capabilities ?? [];
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ function keepByCapabilities(caps) {
80
+ if (!caps || caps.length === 0)
81
+ return true; // unknown => keep
82
+ const c = caps.map((x) => x.toLowerCase());
83
+ const hasCompletion = c.includes("completion") || c.includes("generate") || c.includes("text");
84
+ const onlyVision = c.includes("vision") && !hasCompletion;
85
+ const onlyFunction = (c.includes("tools") ||
86
+ c.includes("function") ||
87
+ c.includes("tool-calling")) &&
88
+ !hasCompletion;
89
+ return !onlyVision && !onlyFunction;
90
+ }
91
+ async function listOllamaModels(base) {
92
+ try {
93
+ const res = await fetch(ollamaTagsUrl(base));
94
+ if (!res.ok)
95
+ return [];
96
+ const data = (await res.json());
97
+ const names = (data.models ?? [])
98
+ .map((m) => m.name)
99
+ .filter((x) => Boolean(x));
100
+ const kept = [];
101
+ for (const name of names) {
102
+ const caps = await ollamaCapabilities(base, name);
103
+ if (keepByCapabilities(caps))
104
+ kept.push(name);
105
+ }
106
+ return normalizeModelList(kept);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ }
112
+ function normalizePathTemplate(apiPath) {
113
+ const parts = normalizePath(apiPath)
114
+ .split("/")
115
+ .filter(Boolean)
116
+ .map((seg) => /^[0-9a-f-]{6,}$/i.test(seg) || /^\d+$/.test(seg)
117
+ ? ":id"
118
+ : seg.toLowerCase());
119
+ return "/" + parts.join("/");
120
+ }
121
+ async function collectSimilarExamples(args) {
122
+ const index = await args.storage.readIndex();
123
+ const tpl = normalizePathTemplate(args.path);
124
+ const candidates = index
125
+ .filter((e) => e.method === args.method && normalizePathTemplate(e.path) === tpl)
126
+ .slice(0, args.limit ?? 5);
127
+ const out = [];
128
+ for (const c of candidates) {
129
+ const files = await args.storage.listVariants(c.method, c.path);
130
+ if (files.length === 0)
131
+ continue;
132
+ const first = await args.storage.readMock(files[0]);
133
+ if (!first)
134
+ continue;
135
+ out.push({
136
+ method: c.method,
137
+ path: c.path,
138
+ responseBody: first.response.body,
139
+ label: `similar-request:${c.method} ${c.path}`,
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+ function summarizeBodyShape(body) {
145
+ if (!body || typeof body !== "object")
146
+ return typeof body;
147
+ if (Array.isArray(body))
148
+ return `array(len=${body.length})`;
149
+ const keys = Object.keys(body).slice(0, 12);
150
+ return `object{${keys.join(", ")}}`;
151
+ }
152
+ function shouldWriteContextInsight(args) {
153
+ const tpl = normalizePathTemplate(args.path);
154
+ return !args.index.some((e) => e.method === args.method && normalizePathTemplate(e.path) === tpl);
155
+ }
156
+ function upsertIndex(entries, method, apiPath, variantPath) {
157
+ const existing = entries.find((e) => e.method === method && e.path === apiPath);
158
+ if (!existing) {
159
+ entries.push({
160
+ method,
161
+ path: apiPath,
162
+ variants: [variantPath],
163
+ defaultVariant: variantPath,
164
+ });
165
+ return entries;
166
+ }
167
+ if (!existing.variants.includes(variantPath))
168
+ existing.variants.push(variantPath);
169
+ if (!existing.defaultVariant)
170
+ existing.defaultVariant = variantPath;
171
+ return entries;
172
+ }
173
+ export async function createApp(options) {
174
+ const app = Fastify({ logger: false });
175
+ const storage = new MockStorage(path.join(options.rootDir, "mocks"));
176
+ await storage.ensureLayout();
177
+ let packageVersion = "unknown";
178
+ try {
179
+ const packageJson = await readFile(path.join(options.rootDir, "package.json"), "utf8");
180
+ const parsed = JSON.parse(packageJson);
181
+ if (parsed.version)
182
+ packageVersion = parsed.version;
183
+ }
184
+ catch { }
185
+ const maxRequestLogs = Number(process.env.MOCK_MAX_REQUEST_LOGS || 500);
186
+ const requestLogs = [];
187
+ const sseClients = new Set();
188
+ const emitLiveEvent = (event, data = {}) => {
189
+ for (const c of sseClients)
190
+ c.send(event, data);
191
+ };
192
+ const pushRequestLog = (entry) => {
193
+ requestLogs.push(entry);
194
+ if (requestLogs.length > maxRequestLogs) {
195
+ requestLogs.splice(0, requestLogs.length - maxRequestLogs);
196
+ }
197
+ emitLiveEvent("request", {
198
+ at: entry.at,
199
+ method: entry.method,
200
+ path: entry.path,
201
+ status: entry.status,
202
+ match: entry.match,
203
+ });
204
+ };
205
+ const initialConfig = await storage.readConfig();
206
+ await storage.writeConfig({ ...initialConfig, appName: options.appName });
207
+ await app.register(cors);
208
+ await app.register(multipart, {
209
+ limits: {
210
+ fileSize: Number(process.env.MOCK_MAX_UPLOAD_BYTES || 250 * 1024 * 1024),
211
+ },
212
+ });
213
+ app.setErrorHandler((err, req, reply) => {
214
+ const e = err;
215
+ const entry = {
216
+ level: "error",
217
+ ts: new Date().toISOString(),
218
+ kind: "mock-bff-error",
219
+ method: req.method,
220
+ url: req.url,
221
+ message: e.message,
222
+ name: e.name,
223
+ stack: e.stack,
224
+ };
225
+ process.stderr.write(`${JSON.stringify(entry)}\n`);
226
+ if (!reply.sent) {
227
+ reply.code(500).send({
228
+ statusCode: 500,
229
+ error: "Internal Server Error",
230
+ message: e.message || "Unexpected error",
231
+ });
232
+ }
233
+ });
234
+ const adminDistDir = path.join(options.rootDir, "admin", "dist");
235
+ await app.register(fastifyStatic, {
236
+ root: adminDistDir,
237
+ prefix: "/-/admin/",
238
+ decorateReply: false,
239
+ });
240
+ const serveAdminIndex = async (_req, reply) => {
241
+ try {
242
+ const html = await readFile(path.join(adminDistDir, "index.html"), "utf8");
243
+ return reply.type("text/html").send(html);
244
+ }
245
+ catch {
246
+ return reply
247
+ .code(500)
248
+ .send({ error: "Admin UI not built. Run: npm run build:admin" });
249
+ }
250
+ };
251
+ app.get("/-/admin", serveAdminIndex);
252
+ app.get("/-/api/health", async () => ({
253
+ ok: true,
254
+ app: options.appName,
255
+ version: packageVersion,
256
+ }));
257
+ app.get("/-/api/config", async () => storage.readConfig());
258
+ app.patch("/-/api/config", async (req) => {
259
+ const prev = await storage.readConfig();
260
+ const patch = req.body;
261
+ const next = {
262
+ ...prev,
263
+ ...patch,
264
+ har: {
265
+ ...prev.har,
266
+ ...(typeof patch.har === "object" && patch.har
267
+ ? patch.har
268
+ : {}),
269
+ },
270
+ providerBaseUrls: {
271
+ ...prev.providerBaseUrls,
272
+ ...(typeof patch.providerBaseUrls === "object" && patch.providerBaseUrls
273
+ ? patch.providerBaseUrls
274
+ : {}),
275
+ },
276
+ };
277
+ await storage.writeConfig(next);
278
+ return next;
279
+ });
280
+ app.get("/-/api/providers", async () => {
281
+ const cfg = await storage.readConfig();
282
+ const ollamaBase = process.env.OLLAMA_BASE_URL ??
283
+ cfg.providerBaseUrls?.ollama ??
284
+ "http://127.0.0.1:11434";
285
+ const ollamaModels = await listOllamaModels(ollamaBase);
286
+ return {
287
+ current: {
288
+ provider: cfg.aiProvider ?? "openai",
289
+ model: cfg.aiModel ?? "gpt-5.4-mini",
290
+ },
291
+ providers: {
292
+ openai: {
293
+ models: knownModels("openai"),
294
+ baseUrl: process.env.OPENAI_BASE_URL ?? cfg.providerBaseUrls?.openai,
295
+ apiKeyPreview: maskApiKey(process.env.OPENAI_API_KEY),
296
+ apiKeyHint: "Set OPENAI_API_KEY before starting dev server (e.g. export OPENAI_API_KEY=...).",
297
+ },
298
+ anthropic: {
299
+ models: knownModels("anthropic"),
300
+ baseUrl: process.env.ANTHROPIC_BASE_URL ?? cfg.providerBaseUrls?.anthropic,
301
+ apiKeyPreview: maskApiKey(process.env.ANTHROPIC_API_KEY),
302
+ apiKeyHint: "Set ANTHROPIC_API_KEY before starting dev server (e.g. export ANTHROPIC_API_KEY=...).",
303
+ },
304
+ ollama: {
305
+ models: ollamaModels.length ? ollamaModels : knownModels("ollama"),
306
+ baseUrl: ollamaBase,
307
+ apiKeyPreview: null,
308
+ apiKeyHint: "No API key required by default for local Ollama.",
309
+ },
310
+ none: {
311
+ models: [],
312
+ baseUrl: null,
313
+ apiKeyPreview: null,
314
+ apiKeyHint: "Disables model calls and uses deterministic fallback generation.",
315
+ },
316
+ },
317
+ };
318
+ });
319
+ app.post("/-/api/openapi", async (req, reply) => {
320
+ const part = await req.file();
321
+ if (!part)
322
+ return reply.code(400).send({ error: "Missing file" });
323
+ const data = await part.toBuffer();
324
+ const filename = (part.filename || "openapi.json").toLowerCase();
325
+ const target = path.join(storage.metaDir(), filename.endsWith(".yaml") || filename.endsWith(".yml")
326
+ ? "openapi.yaml"
327
+ : "openapi.json");
328
+ await writeFile(target, data);
329
+ emitLiveEvent("openapi-updated", { saved: true });
330
+ return { saved: true };
331
+ });
332
+ app.get("/-/api/openapi", async () => {
333
+ const jsonPath = path.join(storage.metaDir(), "openapi.json");
334
+ const yamlPath = path.join(storage.metaDir(), "openapi.yaml");
335
+ try {
336
+ const raw = await readFile(jsonPath, "utf8");
337
+ return { exists: true, format: "json", raw };
338
+ }
339
+ catch { }
340
+ try {
341
+ const raw = await readFile(yamlPath, "utf8");
342
+ return { exists: true, format: "yaml", raw };
343
+ }
344
+ catch { }
345
+ return { exists: false };
346
+ });
347
+ app.delete("/-/api/openapi", async () => {
348
+ await Promise.all([
349
+ rm(path.join(storage.metaDir(), "openapi.json"), { force: true }),
350
+ rm(path.join(storage.metaDir(), "openapi.yaml"), { force: true }),
351
+ ]);
352
+ emitLiveEvent("openapi-updated", { deleted: true });
353
+ return { deleted: true };
354
+ });
355
+ app.post("/-/api/har", async (req, reply) => {
356
+ const part = await req.file();
357
+ if (!part)
358
+ return reply.code(400).send({ error: "Missing HAR file" });
359
+ const config = await storage.readConfig();
360
+ const content = (await part.toBuffer()).toString("utf8");
361
+ const parsed = parseHar(content, config);
362
+ let index = await storage.readIndex();
363
+ for (const item of parsed) {
364
+ const saved = await storage.saveVariant(item.method, item.path, item.variant, item.mock);
365
+ index = upsertIndex(index, item.method, item.path, saved);
366
+ const existingDefault = await storage.readMock(storage.defaultPath(item.method, item.path));
367
+ if (!existingDefault)
368
+ await storage.saveDefault(item.method, item.path, item.mock);
369
+ }
370
+ await storage.writeIndex(index);
371
+ emitLiveEvent("endpoints-updated", {
372
+ source: "har",
373
+ imported: parsed.length,
374
+ });
375
+ return { imported: parsed.length };
376
+ });
377
+ app.get("/-/api/endpoints", async () => {
378
+ const index = await storage.readIndex();
379
+ return index.map((e) => ({
380
+ method: e.method,
381
+ path: e.path,
382
+ variants: e.variants.length,
383
+ hasDefault: Boolean(e.defaultVariant),
384
+ }));
385
+ });
386
+ app.delete("/-/api/endpoint", async (req, reply) => {
387
+ const method = req.query.method?.toUpperCase();
388
+ const apiPath = req.query.path;
389
+ if (!method || !apiPath)
390
+ return reply
391
+ .code(400)
392
+ .send({ error: "method and path query params are required" });
393
+ await storage.clearEndpoint(method, apiPath);
394
+ const index = await storage.readIndex();
395
+ const next = index.filter((e) => !(e.method === method && e.path === apiPath));
396
+ await storage.writeIndex(next);
397
+ emitLiveEvent("endpoints-updated", {
398
+ action: "endpoint-deleted",
399
+ method,
400
+ path: apiPath,
401
+ });
402
+ return { cleared: true, method, path: apiPath };
403
+ });
404
+ app.delete("/-/api/endpoints", async () => {
405
+ await storage.clearAllMocks();
406
+ emitLiveEvent("endpoints-updated", { action: "endpoints-cleared" });
407
+ return { clearedAll: true };
408
+ });
409
+ app.get("/-/api/variants", async (req, reply) => {
410
+ const method = req.query.method?.toUpperCase();
411
+ const apiPath = req.query.path;
412
+ if (!method || !apiPath)
413
+ return reply
414
+ .code(400)
415
+ .send({ error: "method and path query params are required" });
416
+ const files = await storage.listVariants(method, apiPath);
417
+ const items = [];
418
+ const pickPriorityKey = (obj) => {
419
+ const keys = Object.keys(obj);
420
+ if (keys.length === 0)
421
+ return undefined;
422
+ const lower = keys.map((k) => k.toLowerCase());
423
+ const exactId = keys.find((k) => k.toLowerCase() === "id");
424
+ if (exactId)
425
+ return exactId;
426
+ const starId = keys.find((k) => k.toLowerCase().endsWith("id"));
427
+ if (starId)
428
+ return starId;
429
+ const name = keys.find((k) => k.toLowerCase() === "name");
430
+ if (name)
431
+ return name;
432
+ const type = keys.find((k) => k.toLowerCase() === "type");
433
+ if (type)
434
+ return type;
435
+ return keys[0];
436
+ };
437
+ for (const file of files) {
438
+ const mock = await storage.readMock(file);
439
+ const id = file
440
+ .split("/")
441
+ .pop()
442
+ ?.replace(/\.json$/, "") ?? file;
443
+ const snap = (mock?.requestSnapshot ?? {});
444
+ const query = snap.query ?? {};
445
+ const body = snap.body;
446
+ const usp = new URLSearchParams();
447
+ for (const [k, v] of Object.entries(query)) {
448
+ if (Array.isArray(v))
449
+ v.forEach((x) => usp.append(k, String(x)));
450
+ else if (v !== undefined)
451
+ usp.append(k, String(v));
452
+ }
453
+ const queryStr = usp.toString();
454
+ let bodyStr = "";
455
+ if (Array.isArray(body)) {
456
+ const len = body.length;
457
+ const first = body[0];
458
+ let firstPart = "";
459
+ if (first && typeof first === "object" && !Array.isArray(first)) {
460
+ const k = pickPriorityKey(first);
461
+ if (k) {
462
+ const v = first[k];
463
+ firstPart = String(v ?? "").slice(0, 10);
464
+ }
465
+ }
466
+ bodyStr = `[${len}]${firstPart ? ` ${firstPart}` : ""}`;
467
+ }
468
+ else if (body && typeof body === "object") {
469
+ const obj = body;
470
+ const k = pickPriorityKey(obj);
471
+ if (k)
472
+ bodyStr = `${k}=${String(obj[k] ?? "")}`;
473
+ }
474
+ const displayLabel = [queryStr || "", bodyStr || ""].filter(Boolean).join(" · ") ||
475
+ (method === "GET" ? "No query params" : id);
476
+ items.push({
477
+ id,
478
+ file,
479
+ source: mock?.meta.source,
480
+ status: mock?.response.status,
481
+ createdAt: mock?.meta.createdAt,
482
+ displayLabel,
483
+ });
484
+ }
485
+ return { method, path: apiPath, variants: items };
486
+ });
487
+ app.get("/-/api/variant", async (req, reply) => {
488
+ const method = req.query.method?.toUpperCase();
489
+ const apiPath = req.query.path;
490
+ const id = req.query.id;
491
+ if (!method || !apiPath || !id)
492
+ return reply.code(400).send({ error: "method, path, id are required" });
493
+ const filePath = storage.mockPath(method, apiPath, id);
494
+ const mock = await storage.readMock(filePath);
495
+ if (!mock)
496
+ return reply.code(404).send({ error: "variant not found" });
497
+ return { method, path: apiPath, id, mock };
498
+ });
499
+ app.delete("/-/api/variant", async (req, reply) => {
500
+ const method = req.query.method?.toUpperCase();
501
+ const apiPath = req.query.path;
502
+ const id = req.query.id;
503
+ if (!method || !apiPath || !id)
504
+ return reply.code(400).send({ error: "method, path, id are required" });
505
+ const existing = await storage.listVariants(method, apiPath);
506
+ if (existing.length <= 1) {
507
+ return reply.code(400).send({
508
+ error: "Cannot delete the last variant. Delete the endpoint instead.",
509
+ });
510
+ }
511
+ await storage.clearVariant(method, apiPath, id);
512
+ const idx = await storage.readIndex();
513
+ const entry = idx.find((e) => e.method === method && e.path === apiPath);
514
+ if (entry) {
515
+ entry.variants = entry.variants.filter((p) => !p.endsWith(`/${id}.json`));
516
+ await storage.writeIndex(idx);
517
+ }
518
+ emitLiveEvent("variants-updated", {
519
+ action: "variant-deleted",
520
+ method,
521
+ path: apiPath,
522
+ id,
523
+ });
524
+ emitLiveEvent("endpoints-updated", {
525
+ action: "variant-deleted",
526
+ method,
527
+ path: apiPath,
528
+ });
529
+ return { deleted: true };
530
+ });
531
+ app.put("/-/api/variant", async (req, reply) => {
532
+ const method = req.body.method?.toUpperCase();
533
+ const apiPath = req.body.path;
534
+ const id = req.body.id;
535
+ const mock = req.body.mock;
536
+ if (!method || !apiPath || !id || !mock)
537
+ return reply
538
+ .code(400)
539
+ .send({ error: "method, path, id, mock are required" });
540
+ const savedPath = await storage.saveVariant(method, apiPath, id, {
541
+ ...mock,
542
+ meta: {
543
+ ...mock.meta,
544
+ source: "manual",
545
+ createdAt: new Date().toISOString(),
546
+ },
547
+ });
548
+ const index = await storage.readIndex();
549
+ await storage.writeIndex(upsertIndex(index, method, apiPath, savedPath));
550
+ const defaultPath = storage.defaultPath(method, apiPath);
551
+ const existingDefault = await storage.readMock(defaultPath);
552
+ if (!existingDefault) {
553
+ await storage.saveDefault(method, apiPath, {
554
+ ...mock,
555
+ meta: {
556
+ ...mock.meta,
557
+ source: "manual",
558
+ createdAt: new Date().toISOString(),
559
+ },
560
+ });
561
+ }
562
+ emitLiveEvent("variants-updated", {
563
+ action: "variant-saved",
564
+ method,
565
+ path: apiPath,
566
+ id,
567
+ });
568
+ emitLiveEvent("endpoints-updated", {
569
+ action: "variant-saved",
570
+ method,
571
+ path: apiPath,
572
+ });
573
+ return { saved: true };
574
+ });
575
+ app.get("/-/api/diagnostics", async (req, reply) => {
576
+ const method = req.query.method?.toUpperCase();
577
+ const apiPath = req.query.path;
578
+ if (!method || !apiPath)
579
+ return reply
580
+ .code(400)
581
+ .send({ error: "method and path query params are required" });
582
+ const index = await storage.readIndex();
583
+ const entry = index.find((e) => e.method === method && e.path === apiPath);
584
+ const variants = await storage.listVariants(method, apiPath);
585
+ const defaultExists = Boolean(await storage.readMock(storage.defaultPath(method, apiPath)));
586
+ return {
587
+ method,
588
+ path: apiPath,
589
+ indexed: Boolean(entry),
590
+ indexVariantCount: entry?.variants.length ?? 0,
591
+ variantFiles: variants,
592
+ hasDefault: defaultExists,
593
+ };
594
+ });
595
+ app.get("/-/api/misses", async (_req, reply) => {
596
+ const file = path.join(storage.metaDir(), "misses.log.jsonl");
597
+ try {
598
+ return (await readFile(file, "utf8"))
599
+ .trim()
600
+ .split("\n")
601
+ .filter(Boolean)
602
+ .map((line) => JSON.parse(line));
603
+ }
604
+ catch {
605
+ return reply.send([]);
606
+ }
607
+ });
608
+ app.delete("/-/api/misses", async () => {
609
+ await storage.clearMisses();
610
+ emitLiveEvent("misses-cleared", {});
611
+ return { cleared: true };
612
+ });
613
+ app.get("/-/api/requests", async (req) => {
614
+ const requested = Number(req.query.limit ?? 100);
615
+ const limit = Number.isFinite(requested)
616
+ ? Math.max(1, Math.min(1000, requested))
617
+ : 100;
618
+ const rows = requestLogs.slice(-limit).reverse();
619
+ return { max: maxRequestLogs, count: requestLogs.length, rows };
620
+ });
621
+ app.get("/-/api/events", async (_req, reply) => {
622
+ reply.raw.setHeader("Content-Type", "text/event-stream");
623
+ reply.raw.setHeader("Cache-Control", "no-cache");
624
+ reply.raw.setHeader("Connection", "keep-alive");
625
+ reply.raw.flushHeaders?.();
626
+ const id = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
627
+ const send = (event, data) => {
628
+ reply.raw.write(`event: ${event}\n`);
629
+ reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
630
+ };
631
+ sseClients.add({ id, send });
632
+ send("ready", { ok: true });
633
+ const keepAlive = setInterval(() => {
634
+ reply.raw.write(`: ping\n\n`);
635
+ }, 25000);
636
+ reply.raw.on("close", () => {
637
+ clearInterval(keepAlive);
638
+ for (const c of sseClients) {
639
+ if (c.id === id) {
640
+ sseClients.delete(c);
641
+ break;
642
+ }
643
+ }
644
+ });
645
+ return reply;
646
+ });
647
+ app.delete("/-/api/requests", async () => {
648
+ requestLogs.splice(0, requestLogs.length);
649
+ emitLiveEvent("requests-cleared", {});
650
+ return { cleared: true };
651
+ });
652
+ app.get("/-/api/context", async () => ({
653
+ context: await readFile(path.join(storage.metaDir(), "context.md"), "utf8"),
654
+ }));
655
+ app.put("/-/api/context", async (req) => {
656
+ await writeFile(path.join(storage.metaDir(), "context.md"), req.body.context, "utf8");
657
+ return { saved: true };
658
+ });
659
+ app.post("/-/api/reindex", async () => {
660
+ const index = await storage.readIndex();
661
+ await storage.writeIndex(index);
662
+ return { reindexed: index.length };
663
+ });
664
+ app.route({
665
+ method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
666
+ url: "/*",
667
+ handler: async (req, reply) => {
668
+ const method = req.method.toUpperCase();
669
+ const fullPath = normalizePath(req.url.split("?")[0] || "/");
670
+ if (fullPath.startsWith("/-/") || fullPath.startsWith("/admin/")) {
671
+ return reply.code(404).send({ error: "Not found" });
672
+ }
673
+ const config = await storage.readConfig();
674
+ if (!isApiLikeRequest({
675
+ method,
676
+ pathname: fullPath,
677
+ config,
678
+ requireJsonResponse: false,
679
+ })) {
680
+ pushRequestLog({
681
+ at: new Date().toISOString(),
682
+ method,
683
+ path: fullPath,
684
+ query: req.query ?? {},
685
+ match: "none",
686
+ status: 404,
687
+ });
688
+ return reply.code(404).send({ error: "Non-API request is not mocked" });
689
+ }
690
+ const query = normalizeQuery(req.query ?? {}, config.ignoredQueryParams);
691
+ const body = req.body;
692
+ const variantName = buildVariantName(query, body ?? {});
693
+ const exact = await storage.readMock(storage.mockPath(method, fullPath, variantName));
694
+ const variantFiles = await storage.listVariants(method, fullPath);
695
+ const variants = (await Promise.all(variantFiles.map((f) => storage.readMock(f)))).filter(Boolean);
696
+ const defaultMock = await storage.readMock(storage.defaultPath(method, fullPath));
697
+ const match = matchMock({
698
+ exact,
699
+ variants,
700
+ defaultMock,
701
+ requestBody: body,
702
+ });
703
+ if (match.type !== "miss" && match.mock) {
704
+ pushRequestLog({
705
+ at: new Date().toISOString(),
706
+ method,
707
+ path: fullPath,
708
+ query,
709
+ match: match.type,
710
+ status: match.mock.response.status,
711
+ prompt: match.mock.meta.prompt,
712
+ });
713
+ return reply
714
+ .header("x-mock-match", match.type)
715
+ .code(match.mock.response.status)
716
+ .headers(sanitizeReplayHeaders(match.mock.response.headers))
717
+ .send(match.mock.response.body);
718
+ }
719
+ if (!config.aiEnabled) {
720
+ await storage.appendMiss({
721
+ at: new Date().toISOString(),
722
+ method,
723
+ path: fullPath,
724
+ query,
725
+ body,
726
+ resolvedBy: "none",
727
+ });
728
+ emitLiveEvent("miss", { method, path: fullPath, resolvedBy: "none" });
729
+ pushRequestLog({
730
+ at: new Date().toISOString(),
731
+ method,
732
+ path: fullPath,
733
+ query,
734
+ match: "none",
735
+ status: 404,
736
+ });
737
+ return reply.code(404).send({ error: "No mock found" });
738
+ }
739
+ const context = await readFile(path.join(storage.metaDir(), "context.md"), "utf8");
740
+ const similarExamples = await collectSimilarExamples({
741
+ storage,
742
+ method,
743
+ path: fullPath,
744
+ limit: 5,
745
+ });
746
+ const openapiDoc = (await loadOpenApiFile(path.join(storage.metaDir(), "openapi.json"))) ??
747
+ (await loadOpenApiFile(path.join(storage.metaDir(), "openapi.yaml")));
748
+ const openApiHint = buildOpenApiHint({
749
+ doc: openapiDoc,
750
+ method,
751
+ path: fullPath,
752
+ });
753
+ const mergedContext = openApiHint
754
+ ? `${context}\n\n## OPENAPI HINT FOR THIS REQUEST\n\n${openApiHint}`
755
+ : context;
756
+ const aiInput = {
757
+ method,
758
+ path: fullPath,
759
+ query,
760
+ body,
761
+ requestHeaders: req.headers,
762
+ context: mergedContext,
763
+ nearbyExamples: [
764
+ ...variants.slice(0, 5).map((v) => ({
765
+ method,
766
+ path: fullPath,
767
+ responseBody: v.response.body,
768
+ label: `same-endpoint:${method} ${fullPath}`,
769
+ })),
770
+ ...similarExamples,
771
+ ],
772
+ };
773
+ const promptForLogs = config.aiStorePrompt
774
+ ? buildPrompt(aiInput, config, new Date())
775
+ : undefined;
776
+ const generated = await generateMockResponse(aiInput, config);
777
+ if (!generated) {
778
+ await storage.appendMiss({
779
+ at: new Date().toISOString(),
780
+ method,
781
+ path: fullPath,
782
+ query,
783
+ body,
784
+ resolvedBy: "none",
785
+ });
786
+ emitLiveEvent("miss", { method, path: fullPath, resolvedBy: "none" });
787
+ pushRequestLog({
788
+ at: new Date().toISOString(),
789
+ method,
790
+ path: fullPath,
791
+ query,
792
+ match: "none",
793
+ status: 404,
794
+ prompt: promptForLogs,
795
+ });
796
+ return reply.code(404).send({ error: "No mock found" });
797
+ }
798
+ await storage.appendMiss({
799
+ at: new Date().toISOString(),
800
+ method,
801
+ path: fullPath,
802
+ query,
803
+ body,
804
+ resolvedBy: "ai",
805
+ });
806
+ emitLiveEvent("miss", { method, path: fullPath, resolvedBy: "ai" });
807
+ const openapiFileJson = path.join(storage.metaDir(), "openapi.json");
808
+ const openapiFileYaml = path.join(storage.metaDir(), "openapi.yaml");
809
+ const openapi = (await loadOpenApiFile(openapiFileJson)) ??
810
+ (await loadOpenApiFile(openapiFileYaml));
811
+ const validation = validateResponseWithOpenApi({
812
+ doc: openapi,
813
+ method,
814
+ path: fullPath,
815
+ status: generated.response.status,
816
+ responseBody: generated.response.body,
817
+ });
818
+ if (config.openApiMode === "strict" && !validation.ok) {
819
+ pushRequestLog({
820
+ at: new Date().toISOString(),
821
+ method,
822
+ path: fullPath,
823
+ query,
824
+ match: "generated-invalid",
825
+ status: 502,
826
+ });
827
+ return reply
828
+ .header("x-mock-match", "generated-invalid")
829
+ .code(502)
830
+ .send({
831
+ error: "Generated response violates OpenAPI schema",
832
+ validationErrors: validation.errors,
833
+ });
834
+ }
835
+ if (config.openApiMode === "assist" && !validation.ok) {
836
+ generated.meta.notes = `${generated.meta.notes ?? ""}; openapi-warnings=${validation.errors.join(" | ")}`;
837
+ }
838
+ const savedPath = await storage.saveVariant(method, fullPath, variantName, generated);
839
+ const index = await storage.readIndex();
840
+ await storage.writeIndex(upsertIndex(index, method, fullPath, savedPath));
841
+ emitLiveEvent("variants-updated", {
842
+ action: "variant-generated",
843
+ method,
844
+ path: fullPath,
845
+ id: variantName,
846
+ });
847
+ emitLiveEvent("endpoints-updated", {
848
+ action: "variant-generated",
849
+ method,
850
+ path: fullPath,
851
+ });
852
+ pushRequestLog({
853
+ at: new Date().toISOString(),
854
+ method,
855
+ path: fullPath,
856
+ query,
857
+ match: "generated",
858
+ status: generated.response.status,
859
+ prompt: generated.meta.prompt,
860
+ });
861
+ return reply
862
+ .header("x-mock-match", "generated")
863
+ .code(generated.response.status)
864
+ .headers(sanitizeReplayHeaders(generated.response.headers))
865
+ .send(generated.response.body);
866
+ },
867
+ });
868
+ return app;
869
+ }