trickle-cli 0.1.0

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.
Files changed (125) hide show
  1. package/dist/api-client.d.ts +208 -0
  2. package/dist/api-client.js +237 -0
  3. package/dist/commands/annotate.d.ts +6 -0
  4. package/dist/commands/annotate.js +433 -0
  5. package/dist/commands/audit.d.ts +7 -0
  6. package/dist/commands/audit.js +82 -0
  7. package/dist/commands/auto.d.ts +8 -0
  8. package/dist/commands/auto.js +268 -0
  9. package/dist/commands/capture.d.ts +14 -0
  10. package/dist/commands/capture.js +271 -0
  11. package/dist/commands/check.d.ts +6 -0
  12. package/dist/commands/check.js +408 -0
  13. package/dist/commands/codegen.d.ts +21 -0
  14. package/dist/commands/codegen.js +129 -0
  15. package/dist/commands/coverage.d.ts +13 -0
  16. package/dist/commands/coverage.js +126 -0
  17. package/dist/commands/dashboard.d.ts +1 -0
  18. package/dist/commands/dashboard.js +83 -0
  19. package/dist/commands/dev.d.ts +14 -0
  20. package/dist/commands/dev.js +319 -0
  21. package/dist/commands/diff.d.ts +7 -0
  22. package/dist/commands/diff.js +79 -0
  23. package/dist/commands/docs.d.ts +13 -0
  24. package/dist/commands/docs.js +383 -0
  25. package/dist/commands/errors.d.ts +7 -0
  26. package/dist/commands/errors.js +180 -0
  27. package/dist/commands/export.d.ts +18 -0
  28. package/dist/commands/export.js +238 -0
  29. package/dist/commands/functions.d.ts +6 -0
  30. package/dist/commands/functions.js +71 -0
  31. package/dist/commands/infer.d.ts +14 -0
  32. package/dist/commands/infer.js +275 -0
  33. package/dist/commands/init.d.ts +5 -0
  34. package/dist/commands/init.js +395 -0
  35. package/dist/commands/mock.d.ts +5 -0
  36. package/dist/commands/mock.js +232 -0
  37. package/dist/commands/openapi.d.ts +8 -0
  38. package/dist/commands/openapi.js +82 -0
  39. package/dist/commands/overview.d.ts +11 -0
  40. package/dist/commands/overview.js +266 -0
  41. package/dist/commands/pack.d.ts +11 -0
  42. package/dist/commands/pack.js +133 -0
  43. package/dist/commands/proxy.d.ts +13 -0
  44. package/dist/commands/proxy.js +312 -0
  45. package/dist/commands/replay.d.ts +14 -0
  46. package/dist/commands/replay.js +289 -0
  47. package/dist/commands/run.d.ts +17 -0
  48. package/dist/commands/run.js +997 -0
  49. package/dist/commands/sample.d.ts +13 -0
  50. package/dist/commands/sample.js +260 -0
  51. package/dist/commands/search.d.ts +5 -0
  52. package/dist/commands/search.js +80 -0
  53. package/dist/commands/stubs.d.ts +6 -0
  54. package/dist/commands/stubs.js +187 -0
  55. package/dist/commands/tail.d.ts +4 -0
  56. package/dist/commands/tail.js +76 -0
  57. package/dist/commands/test-gen.d.ts +13 -0
  58. package/dist/commands/test-gen.js +237 -0
  59. package/dist/commands/trace.d.ts +14 -0
  60. package/dist/commands/trace.js +417 -0
  61. package/dist/commands/types.d.ts +7 -0
  62. package/dist/commands/types.js +128 -0
  63. package/dist/commands/unpack.d.ts +11 -0
  64. package/dist/commands/unpack.js +166 -0
  65. package/dist/commands/validate.d.ts +13 -0
  66. package/dist/commands/validate.js +310 -0
  67. package/dist/commands/watch.d.ts +9 -0
  68. package/dist/commands/watch.js +267 -0
  69. package/dist/config.d.ts +1 -0
  70. package/dist/config.js +66 -0
  71. package/dist/formatters/diff-formatter.d.ts +5 -0
  72. package/dist/formatters/diff-formatter.js +43 -0
  73. package/dist/formatters/type-formatter.d.ts +22 -0
  74. package/dist/formatters/type-formatter.js +135 -0
  75. package/dist/index.d.ts +2 -0
  76. package/dist/index.js +419 -0
  77. package/dist/local-codegen.d.ts +22 -0
  78. package/dist/local-codegen.js +762 -0
  79. package/dist/ui/badges.d.ts +16 -0
  80. package/dist/ui/badges.js +71 -0
  81. package/dist/ui/helpers.d.ts +13 -0
  82. package/dist/ui/helpers.js +85 -0
  83. package/package.json +23 -0
  84. package/src/api-client.ts +407 -0
  85. package/src/commands/annotate.ts +450 -0
  86. package/src/commands/audit.ts +103 -0
  87. package/src/commands/auto.ts +268 -0
  88. package/src/commands/capture.ts +257 -0
  89. package/src/commands/check.ts +437 -0
  90. package/src/commands/codegen.ts +128 -0
  91. package/src/commands/coverage.ts +170 -0
  92. package/src/commands/dashboard.ts +46 -0
  93. package/src/commands/dev.ts +323 -0
  94. package/src/commands/diff.ts +99 -0
  95. package/src/commands/docs.ts +392 -0
  96. package/src/commands/errors.ts +205 -0
  97. package/src/commands/export.ts +287 -0
  98. package/src/commands/functions.ts +81 -0
  99. package/src/commands/infer.ts +260 -0
  100. package/src/commands/init.ts +419 -0
  101. package/src/commands/mock.ts +220 -0
  102. package/src/commands/openapi.ts +53 -0
  103. package/src/commands/overview.ts +310 -0
  104. package/src/commands/pack.ts +139 -0
  105. package/src/commands/proxy.ts +314 -0
  106. package/src/commands/replay.ts +356 -0
  107. package/src/commands/run.ts +1190 -0
  108. package/src/commands/sample.ts +259 -0
  109. package/src/commands/search.ts +107 -0
  110. package/src/commands/stubs.ts +211 -0
  111. package/src/commands/tail.ts +94 -0
  112. package/src/commands/test-gen.ts +236 -0
  113. package/src/commands/trace.ts +440 -0
  114. package/src/commands/types.ts +161 -0
  115. package/src/commands/unpack.ts +179 -0
  116. package/src/commands/validate.ts +368 -0
  117. package/src/commands/watch.ts +277 -0
  118. package/src/config.ts +38 -0
  119. package/src/formatters/diff-formatter.ts +51 -0
  120. package/src/formatters/type-formatter.ts +161 -0
  121. package/src/index.ts +454 -0
  122. package/src/local-codegen.ts +859 -0
  123. package/src/ui/badges.ts +66 -0
  124. package/src/ui/helpers.ts +80 -0
  125. package/tsconfig.json +8 -0
@@ -0,0 +1,314 @@
1
+ import * as http from "http";
2
+ import * as https from "https";
3
+ import * as crypto from "crypto";
4
+ import chalk from "chalk";
5
+ import { getBackendUrl } from "../config";
6
+
7
+ export interface ProxyOptions {
8
+ target: string;
9
+ port?: string;
10
+ }
11
+
12
+ interface TypeNode {
13
+ kind: string;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ /**
18
+ * `trickle proxy` — Transparent reverse proxy that captures API types.
19
+ *
20
+ * Sits between the frontend and backend, forwarding all requests while
21
+ * observing request/response shapes and sending type observations to
22
+ * the trickle backend. Works with any backend language or framework —
23
+ * no instrumentation needed.
24
+ */
25
+ export async function proxyCommand(opts: ProxyOptions): Promise<void> {
26
+ const targetUrl = opts.target;
27
+ if (!targetUrl) {
28
+ console.error(chalk.red("\n Missing --target flag."));
29
+ console.error(chalk.gray(" Usage: trickle proxy --target http://localhost:3000\n"));
30
+ process.exit(1);
31
+ }
32
+
33
+ let parsedTarget: URL;
34
+ try {
35
+ parsedTarget = new URL(targetUrl);
36
+ } catch {
37
+ console.error(chalk.red(`\n Invalid target URL: ${targetUrl}\n`));
38
+ process.exit(1);
39
+ }
40
+
41
+ const port = parseInt(opts.port || "4000", 10);
42
+ const backendUrl = getBackendUrl();
43
+
44
+ // Check backend connectivity
45
+ try {
46
+ const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
47
+ if (!res.ok) throw new Error("not ok");
48
+ } catch {
49
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}`));
50
+ console.error(chalk.gray(" Start the backend first.\n"));
51
+ process.exit(1);
52
+ }
53
+
54
+ let requestCount = 0;
55
+ let typesSent = 0;
56
+
57
+ const server = http.createServer(async (req, res) => {
58
+ const method = (req.method || "GET").toUpperCase();
59
+ const urlPath = req.url || "/";
60
+
61
+ // Read request body
62
+ const reqBody = await readBody(req);
63
+ let reqJson: unknown = undefined;
64
+ if (reqBody.length > 0) {
65
+ try {
66
+ reqJson = JSON.parse(reqBody.toString("utf-8"));
67
+ } catch {
68
+ // Not JSON — skip type capture for request body
69
+ }
70
+ }
71
+
72
+ // Forward to target
73
+ const targetReqUrl = new URL(urlPath, targetUrl);
74
+ const isHttps = parsedTarget.protocol === "https:";
75
+ const mod = isHttps ? https : http;
76
+
77
+ const proxyReq = mod.request(
78
+ targetReqUrl.toString(),
79
+ {
80
+ method,
81
+ headers: {
82
+ ...req.headers,
83
+ host: parsedTarget.host,
84
+ },
85
+ },
86
+ (proxyRes) => {
87
+ // Read response body
88
+ const chunks: Buffer[] = [];
89
+ proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
90
+ proxyRes.on("end", () => {
91
+ const resBody = Buffer.concat(chunks);
92
+
93
+ // Forward response to client
94
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
95
+ res.end(resBody);
96
+
97
+ requestCount++;
98
+
99
+ // Parse response JSON for type capture
100
+ let resJson: unknown = undefined;
101
+ const contentType = proxyRes.headers["content-type"] || "";
102
+ if (contentType.includes("json") && resBody.length > 0) {
103
+ try {
104
+ resJson = JSON.parse(resBody.toString("utf-8"));
105
+ } catch {
106
+ // Not valid JSON
107
+ }
108
+ }
109
+
110
+ // Only capture types for JSON API-like routes
111
+ if (resJson !== undefined && isApiRoute(urlPath)) {
112
+ captureTypes(method, urlPath, reqJson, resJson, backendUrl).then((sent) => {
113
+ if (sent) typesSent++;
114
+ }).catch(() => {});
115
+
116
+ // Log
117
+ const status = proxyRes.statusCode || 200;
118
+ const statusColor = status < 400 ? chalk.green : chalk.red;
119
+ console.log(
120
+ chalk.gray(` ${chalk.bold(method)} ${urlPath} → `) +
121
+ statusColor(`${status}`) +
122
+ chalk.gray(` (${typesSent} types captured)`),
123
+ );
124
+ }
125
+ });
126
+ },
127
+ );
128
+
129
+ proxyReq.on("error", (err) => {
130
+ console.error(chalk.red(` Proxy error: ${err.message}`));
131
+ res.writeHead(502);
132
+ res.end(JSON.stringify({ error: "Bad Gateway", message: err.message }));
133
+ });
134
+
135
+ if (reqBody.length > 0) {
136
+ proxyReq.write(reqBody);
137
+ }
138
+ proxyReq.end();
139
+ });
140
+
141
+ server.listen(port, () => {
142
+ console.log("");
143
+ console.log(chalk.bold(" trickle proxy"));
144
+ console.log(chalk.gray(" " + "─".repeat(50)));
145
+ console.log(chalk.gray(` Proxy: http://localhost:${port}`));
146
+ console.log(chalk.gray(` Target: ${targetUrl}`));
147
+ console.log(chalk.gray(` Backend: ${backendUrl}`));
148
+ console.log(chalk.gray(" " + "─".repeat(50)));
149
+ console.log(chalk.gray(" Point your frontend at the proxy URL."));
150
+ console.log(chalk.gray(" Press Ctrl+C to stop.\n"));
151
+ });
152
+
153
+ process.on("SIGINT", () => {
154
+ console.log(chalk.gray(`\n Shutting down... (${requestCount} requests, ${typesSent} types captured)`));
155
+ server.close();
156
+ process.exit(0);
157
+ });
158
+
159
+ process.on("SIGTERM", () => {
160
+ server.close();
161
+ process.exit(0);
162
+ });
163
+ }
164
+
165
+ function readBody(stream: http.IncomingMessage): Promise<Buffer> {
166
+ return new Promise((resolve) => {
167
+ const chunks: Buffer[] = [];
168
+ stream.on("data", (chunk: Buffer) => chunks.push(chunk));
169
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
170
+ stream.on("error", () => resolve(Buffer.alloc(0)));
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Heuristic: only capture types for API-like routes (not static assets).
176
+ */
177
+ function isApiRoute(urlPath: string): boolean {
178
+ const path = urlPath.split("?")[0];
179
+ // Skip obvious static assets
180
+ if (/\.(js|css|html|png|jpg|gif|svg|ico|woff|woff2|ttf|eot|map)$/i.test(path)) {
181
+ return false;
182
+ }
183
+ // Include /api/ routes always
184
+ if (path.includes("/api/")) return true;
185
+ // Include anything that doesn't look like a file
186
+ if (!path.includes(".")) return true;
187
+ return false;
188
+ }
189
+
190
+ /**
191
+ * Infer a TypeNode from a JSON value.
192
+ */
193
+ function jsonToTypeNode(value: unknown): TypeNode {
194
+ if (value === null) return { kind: "primitive", name: "null" };
195
+ if (value === undefined) return { kind: "primitive", name: "undefined" };
196
+
197
+ switch (typeof value) {
198
+ case "string": return { kind: "primitive", name: "string" };
199
+ case "number": return { kind: "primitive", name: "number" };
200
+ case "boolean": return { kind: "primitive", name: "boolean" };
201
+ case "bigint": return { kind: "primitive", name: "bigint" };
202
+ case "symbol": return { kind: "primitive", name: "symbol" };
203
+ case "function": return { kind: "function", params: [], returnType: { kind: "unknown" } };
204
+ }
205
+
206
+ if (Array.isArray(value)) {
207
+ if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
208
+ // Infer element type from first element
209
+ const elementType = jsonToTypeNode(value[0]);
210
+ return { kind: "array", element: elementType };
211
+ }
212
+
213
+ // Object
214
+ const obj = value as Record<string, unknown>;
215
+ const properties: Record<string, TypeNode> = {};
216
+ for (const [key, val] of Object.entries(obj)) {
217
+ properties[key] = jsonToTypeNode(val);
218
+ }
219
+ return { kind: "object", properties };
220
+ }
221
+
222
+ /**
223
+ * Normalize URL path: replace dynamic segments like /users/123 with :param patterns.
224
+ * Uses heuristics: numeric segments and UUID-like segments become params.
225
+ */
226
+ function normalizePath(urlPath: string): string {
227
+ const path = urlPath.split("?")[0];
228
+ const parts = path.split("/");
229
+ return parts
230
+ .map((part, i) => {
231
+ if (!part) return part;
232
+ // Numeric IDs
233
+ if (/^\d+$/.test(part)) return ":id";
234
+ // UUIDs
235
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return ":id";
236
+ // Short hex hashes
237
+ if (/^[0-9a-f]{16,}$/i.test(part) && i > 1) return ":id";
238
+ return part;
239
+ })
240
+ .join("/");
241
+ }
242
+
243
+ /**
244
+ * Compute a SHA-256 hash (16 hex chars) for type dedup.
245
+ */
246
+ function computeTypeHash(argsType: TypeNode, returnType: TypeNode): string {
247
+ const data = JSON.stringify({ a: argsType, r: returnType });
248
+ return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
249
+ }
250
+
251
+ /**
252
+ * Send captured types to the trickle backend.
253
+ */
254
+ async function captureTypes(
255
+ method: string,
256
+ urlPath: string,
257
+ reqJson: unknown,
258
+ resJson: unknown,
259
+ backendUrl: string,
260
+ ): Promise<boolean> {
261
+ const normalizedPath = normalizePath(urlPath);
262
+ const functionName = `${method} ${normalizedPath}`;
263
+
264
+ // Build argsType: { body, params, query }
265
+ const argsProperties: Record<string, TypeNode> = {};
266
+
267
+ if (reqJson !== undefined && reqJson !== null) {
268
+ argsProperties.body = jsonToTypeNode(reqJson);
269
+ }
270
+
271
+ // Extract query params
272
+ const queryStart = urlPath.indexOf("?");
273
+ if (queryStart !== -1) {
274
+ const searchParams = new URLSearchParams(urlPath.slice(queryStart + 1));
275
+ const queryProps: Record<string, TypeNode> = {};
276
+ for (const [key] of searchParams) {
277
+ queryProps[key] = { kind: "primitive", name: "string" };
278
+ }
279
+ if (Object.keys(queryProps).length > 0) {
280
+ argsProperties.query = { kind: "object", properties: queryProps };
281
+ }
282
+ }
283
+
284
+ const argsType: TypeNode = Object.keys(argsProperties).length > 0
285
+ ? { kind: "object", properties: argsProperties }
286
+ : { kind: "object", properties: {} };
287
+
288
+ const returnType = jsonToTypeNode(resJson);
289
+ const typeHash = computeTypeHash(argsType, returnType);
290
+
291
+ const payload = {
292
+ functionName,
293
+ module: "proxy",
294
+ language: "js",
295
+ environment: "development",
296
+ typeHash,
297
+ argsType,
298
+ returnType,
299
+ sampleInput: reqJson !== undefined ? (Object.keys(argsProperties).length > 0 ? argsProperties : undefined) : undefined,
300
+ sampleOutput: resJson,
301
+ };
302
+
303
+ try {
304
+ const res = await fetch(`${backendUrl}/api/ingest`, {
305
+ method: "POST",
306
+ headers: { "Content-Type": "application/json" },
307
+ body: JSON.stringify(payload),
308
+ signal: AbortSignal.timeout(5000),
309
+ });
310
+ return res.ok;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
@@ -0,0 +1,356 @@
1
+ import chalk from "chalk";
2
+ import { fetchMockConfig, MockRoute } from "../api-client";
3
+
4
+ export interface ReplayOptions {
5
+ target?: string;
6
+ strict?: boolean;
7
+ json?: boolean;
8
+ failFast?: boolean;
9
+ }
10
+
11
+ interface ReplayResult {
12
+ method: string;
13
+ path: string;
14
+ status: "pass" | "fail" | "error";
15
+ httpStatus?: number;
16
+ message?: string;
17
+ expectedKeys?: string[];
18
+ actualKeys?: string[];
19
+ durationMs: number;
20
+ }
21
+
22
+ /**
23
+ * `trickle replay` — Replay captured API requests as regression tests.
24
+ *
25
+ * Uses the sample inputs/outputs already captured by trickle to replay
26
+ * requests against a running server and verify response shapes match.
27
+ * Developers get free regression tests without writing any test code.
28
+ */
29
+ export async function replayCommand(opts: ReplayOptions): Promise<void> {
30
+ const target = opts.target || "http://localhost:3000";
31
+
32
+ // Fetch observed routes
33
+ let routes: MockRoute[];
34
+ try {
35
+ const config = await fetchMockConfig();
36
+ routes = config.routes;
37
+ } catch {
38
+ console.error(chalk.red("\n Cannot connect to trickle backend."));
39
+ console.error(chalk.gray(" Is the backend running?\n"));
40
+ process.exit(1);
41
+ }
42
+
43
+ if (routes.length === 0) {
44
+ console.error(chalk.yellow("\n No observed routes to replay."));
45
+ console.error(chalk.gray(" Instrument your app and make some requests first.\n"));
46
+ process.exit(0);
47
+ }
48
+
49
+ if (!opts.json) {
50
+ console.log("");
51
+ console.log(chalk.bold(" trickle replay"));
52
+ console.log(chalk.gray(" " + "─".repeat(50)));
53
+ console.log(chalk.gray(` Target: ${target}`));
54
+ console.log(chalk.gray(` Routes: ${routes.length}`));
55
+ console.log(chalk.gray(` Mode: ${opts.strict ? "strict (exact values)" : "shape (structural match)"}`));
56
+ console.log(chalk.gray(" " + "─".repeat(50)));
57
+ console.log("");
58
+ }
59
+
60
+ const results: ReplayResult[] = [];
61
+
62
+ for (const route of routes) {
63
+ const result = await replayRoute(route, target, opts.strict || false);
64
+ results.push(result);
65
+
66
+ if (!opts.json) {
67
+ const icon = result.status === "pass"
68
+ ? chalk.green("✓")
69
+ : result.status === "fail"
70
+ ? chalk.red("✗")
71
+ : chalk.yellow("!");
72
+ const statusStr = result.httpStatus ? chalk.gray(` [${result.httpStatus}]`) : "";
73
+ const timeStr = chalk.gray(` ${result.durationMs}ms`);
74
+ const msg = result.message ? chalk.gray(` — ${result.message}`) : "";
75
+ console.log(` ${icon} ${chalk.bold(route.method)} ${route.path}${statusStr}${timeStr}${msg}`);
76
+ }
77
+
78
+ if (opts.failFast && result.status !== "pass") {
79
+ break;
80
+ }
81
+ }
82
+
83
+ // Summary
84
+ const passed = results.filter((r) => r.status === "pass").length;
85
+ const failed = results.filter((r) => r.status === "fail").length;
86
+ const errors = results.filter((r) => r.status === "error").length;
87
+
88
+ if (opts.json) {
89
+ console.log(JSON.stringify({
90
+ target,
91
+ mode: opts.strict ? "strict" : "shape",
92
+ total: results.length,
93
+ passed,
94
+ failed,
95
+ errors,
96
+ results,
97
+ }, null, 2));
98
+ } else {
99
+ console.log("");
100
+ console.log(chalk.gray(" " + "─".repeat(50)));
101
+ if (failed === 0 && errors === 0) {
102
+ console.log(chalk.green(` ${passed}/${results.length} passed`) + chalk.gray(` — all routes match`));
103
+ } else {
104
+ const parts: string[] = [];
105
+ if (passed > 0) parts.push(chalk.green(`${passed} passed`));
106
+ if (failed > 0) parts.push(chalk.red(`${failed} failed`));
107
+ if (errors > 0) parts.push(chalk.yellow(`${errors} errors`));
108
+ console.log(` ${parts.join(", ")} out of ${results.length} routes`);
109
+ }
110
+ console.log("");
111
+ }
112
+
113
+ if (failed > 0 || errors > 0) {
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ async function replayRoute(
119
+ route: MockRoute,
120
+ target: string,
121
+ strict: boolean,
122
+ ): Promise<ReplayResult> {
123
+ const { method, path: routePath } = route;
124
+
125
+ // Build URL — replace :param patterns with sample values if available
126
+ let url = routePath;
127
+ if (route.sampleInput && typeof route.sampleInput === "object") {
128
+ const input = route.sampleInput as Record<string, unknown>;
129
+ const params = input.params as Record<string, string> | undefined;
130
+ if (params) {
131
+ for (const [key, value] of Object.entries(params)) {
132
+ url = url.replace(`:${key}`, String(value));
133
+ }
134
+ }
135
+ }
136
+ // Replace any remaining :params with "1" as fallback
137
+ url = url.replace(/:(\w+)/g, "1");
138
+
139
+ const fullUrl = `${target}${url}`;
140
+ const start = Date.now();
141
+
142
+ try {
143
+ // Build request
144
+ const fetchOpts: RequestInit = { method };
145
+ const hasBody = ["POST", "PUT", "PATCH"].includes(method);
146
+
147
+ if (hasBody && route.sampleInput) {
148
+ const input = route.sampleInput as Record<string, unknown>;
149
+ const body = input.body || input;
150
+ if (body && typeof body === "object" && Object.keys(body as object).length > 0) {
151
+ fetchOpts.headers = { "Content-Type": "application/json" };
152
+ fetchOpts.body = JSON.stringify(body);
153
+ }
154
+ }
155
+
156
+ const res = await fetch(fullUrl, {
157
+ ...fetchOpts,
158
+ signal: AbortSignal.timeout(10000),
159
+ });
160
+ const durationMs = Date.now() - start;
161
+
162
+ if (!res.ok) {
163
+ return {
164
+ method, path: routePath, status: "fail",
165
+ httpStatus: res.status,
166
+ message: `HTTP ${res.status}`,
167
+ durationMs,
168
+ };
169
+ }
170
+
171
+ // Parse response
172
+ const contentType = res.headers.get("content-type") || "";
173
+ if (!contentType.includes("json")) {
174
+ return {
175
+ method, path: routePath, status: "pass",
176
+ httpStatus: res.status,
177
+ message: "non-JSON response",
178
+ durationMs,
179
+ };
180
+ }
181
+
182
+ const actual = await res.json();
183
+
184
+ if (!route.sampleOutput) {
185
+ return {
186
+ method, path: routePath, status: "pass",
187
+ httpStatus: res.status,
188
+ durationMs,
189
+ };
190
+ }
191
+
192
+ // Compare
193
+ if (strict) {
194
+ return compareStrict(method, routePath, route.sampleOutput, actual, res.status, durationMs);
195
+ } else {
196
+ return compareShape(method, routePath, route.sampleOutput, actual, res.status, durationMs);
197
+ }
198
+ } catch (err: unknown) {
199
+ const durationMs = Date.now() - start;
200
+ const message = err instanceof Error ? err.message : "Unknown error";
201
+ return {
202
+ method, path: routePath, status: "error",
203
+ message: message.includes("ECONNREFUSED") ? "connection refused" : message,
204
+ durationMs,
205
+ };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Shape comparison: verify that the actual response has the same structure
211
+ * (same keys, same types) as the expected sample output.
212
+ */
213
+ function compareShape(
214
+ method: string,
215
+ path: string,
216
+ expected: unknown,
217
+ actual: unknown,
218
+ httpStatus: number,
219
+ durationMs: number,
220
+ ): ReplayResult {
221
+ const mismatches = findShapeMismatches(expected, actual, "");
222
+
223
+ if (mismatches.length === 0) {
224
+ return { method, path, status: "pass", httpStatus, durationMs };
225
+ }
226
+
227
+ return {
228
+ method, path, status: "fail", httpStatus,
229
+ message: mismatches[0],
230
+ durationMs,
231
+ };
232
+ }
233
+
234
+ function findShapeMismatches(expected: unknown, actual: unknown, prefix: string): string[] {
235
+ const mismatches: string[] = [];
236
+
237
+ if (expected === null || expected === undefined) {
238
+ return mismatches;
239
+ }
240
+
241
+ const expectedType = typeOf(expected);
242
+ const actualType = typeOf(actual);
243
+
244
+ if (expectedType !== actualType) {
245
+ mismatches.push(`${prefix || "root"}: expected ${expectedType}, got ${actualType}`);
246
+ return mismatches;
247
+ }
248
+
249
+ if (expectedType === "object") {
250
+ const expectedObj = expected as Record<string, unknown>;
251
+ const actualObj = actual as Record<string, unknown>;
252
+ const expectedKeys = Object.keys(expectedObj).sort();
253
+ const actualKeys = Object.keys(actualObj).sort();
254
+
255
+ // Check for missing keys
256
+ for (const key of expectedKeys) {
257
+ if (!(key in actualObj)) {
258
+ mismatches.push(`${prefix ? prefix + "." : ""}${key}: missing`);
259
+ } else {
260
+ // Recurse (limit depth to avoid noise)
261
+ if (prefix.split(".").length < 3) {
262
+ mismatches.push(
263
+ ...findShapeMismatches(expectedObj[key], actualObj[key], `${prefix ? prefix + "." : ""}${key}`),
264
+ );
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ if (expectedType === "array") {
271
+ const expectedArr = expected as unknown[];
272
+ const actualArr = actual as unknown[];
273
+ if (expectedArr.length > 0 && actualArr.length > 0) {
274
+ mismatches.push(
275
+ ...findShapeMismatches(expectedArr[0], actualArr[0], `${prefix}[0]`),
276
+ );
277
+ }
278
+ }
279
+
280
+ return mismatches;
281
+ }
282
+
283
+ function typeOf(value: unknown): string {
284
+ if (value === null) return "null";
285
+ if (Array.isArray(value)) return "array";
286
+ return typeof value;
287
+ }
288
+
289
+ /**
290
+ * Strict comparison: verify exact value match.
291
+ */
292
+ function compareStrict(
293
+ method: string,
294
+ path: string,
295
+ expected: unknown,
296
+ actual: unknown,
297
+ httpStatus: number,
298
+ durationMs: number,
299
+ ): ReplayResult {
300
+ // Deep compare, but be lenient with dynamic fields (ids, timestamps)
301
+ const mismatches = findValueMismatches(expected, actual, "");
302
+
303
+ if (mismatches.length === 0) {
304
+ return { method, path, status: "pass", httpStatus, durationMs };
305
+ }
306
+
307
+ return {
308
+ method, path, status: "fail", httpStatus,
309
+ message: mismatches[0],
310
+ durationMs,
311
+ };
312
+ }
313
+
314
+ function findValueMismatches(expected: unknown, actual: unknown, prefix: string): string[] {
315
+ const mismatches: string[] = [];
316
+
317
+ if (expected === null || expected === undefined) return mismatches;
318
+
319
+ const expectedType = typeOf(expected);
320
+ const actualType = typeOf(actual);
321
+
322
+ if (expectedType !== actualType) {
323
+ mismatches.push(`${prefix || "root"}: expected ${expectedType} got ${actualType}`);
324
+ return mismatches;
325
+ }
326
+
327
+ if (expectedType === "object") {
328
+ const expectedObj = expected as Record<string, unknown>;
329
+ const actualObj = actual as Record<string, unknown>;
330
+
331
+ for (const key of Object.keys(expectedObj)) {
332
+ if (!(key in actualObj)) {
333
+ mismatches.push(`${prefix ? prefix + "." : ""}${key}: missing`);
334
+ } else if (prefix.split(".").length < 3) {
335
+ mismatches.push(
336
+ ...findValueMismatches(expectedObj[key], actualObj[key], `${prefix ? prefix + "." : ""}${key}`),
337
+ );
338
+ }
339
+ }
340
+ } else if (expectedType === "array") {
341
+ const expectedArr = expected as unknown[];
342
+ const actualArr = actual as unknown[];
343
+ if (expectedArr.length > 0 && actualArr.length > 0) {
344
+ mismatches.push(
345
+ ...findValueMismatches(expectedArr[0], actualArr[0], `${prefix}[0]`),
346
+ );
347
+ }
348
+ } else {
349
+ // Primitive comparison
350
+ if (expected !== actual) {
351
+ mismatches.push(`${prefix || "root"}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
352
+ }
353
+ }
354
+
355
+ return mismatches;
356
+ }