@schmock/core 2.3.0 → 2.3.1

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/abort.d.ts +3 -0
  4. package/dist/abort.d.ts.map +1 -0
  5. package/dist/abort.js +30 -0
  6. package/dist/builder.d.ts +31 -10
  7. package/dist/builder.d.ts.map +1 -1
  8. package/dist/builder.js +760 -190
  9. package/dist/constants.d.ts +38 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/constants.js +123 -2
  12. package/dist/errors.d.ts +14 -3
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +41 -6
  15. package/dist/helpers.d.ts +9 -0
  16. package/dist/helpers.d.ts.map +1 -1
  17. package/dist/helpers.js +18 -2
  18. package/dist/http-helpers.d.ts +39 -4
  19. package/dist/http-helpers.d.ts.map +1 -1
  20. package/dist/http-helpers.js +147 -49
  21. package/dist/index.d.ts +292 -34
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +8 -3
  24. package/dist/interceptor.d.ts +11 -1
  25. package/dist/interceptor.d.ts.map +1 -1
  26. package/dist/interceptor.js +316 -169
  27. package/dist/parser.d.ts.map +1 -1
  28. package/dist/parser.js +12 -3
  29. package/dist/plugin-pipeline.d.ts +3 -3
  30. package/dist/plugin-pipeline.d.ts.map +1 -1
  31. package/dist/plugin-pipeline.js +29 -14
  32. package/dist/response-normalizer.d.ts +16 -0
  33. package/dist/response-normalizer.d.ts.map +1 -0
  34. package/dist/response-normalizer.js +316 -0
  35. package/dist/response-parser.d.ts.map +1 -1
  36. package/dist/response-parser.js +39 -2
  37. package/dist/route-matcher.d.ts +3 -0
  38. package/dist/route-matcher.d.ts.map +1 -1
  39. package/dist/route-matcher.js +12 -6
  40. package/dist/types.d.ts +7 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +14 -6
  43. package/src/audit-core-builder.test.ts +0 -218
  44. package/src/audit-response-guard.test.ts +0 -38
  45. package/src/binary.test.ts +0 -126
  46. package/src/binary.ts +0 -11
  47. package/src/builder.test.ts +0 -289
  48. package/src/builder.ts +0 -748
  49. package/src/constants.test.ts +0 -99
  50. package/src/constants.ts +0 -73
  51. package/src/debug.test.ts +0 -241
  52. package/src/delay.test.ts +0 -319
  53. package/src/dist-shape.test.ts +0 -35
  54. package/src/errors.test.ts +0 -223
  55. package/src/errors.ts +0 -130
  56. package/src/factory.test.ts +0 -133
  57. package/src/helpers.test.ts +0 -147
  58. package/src/helpers.ts +0 -58
  59. package/src/http-helpers.test.ts +0 -39
  60. package/src/http-helpers.ts +0 -149
  61. package/src/index.ts +0 -152
  62. package/src/interceptor.test.ts +0 -488
  63. package/src/interceptor.ts +0 -407
  64. package/src/namespace.test.ts +0 -274
  65. package/src/parser.property.test.ts +0 -630
  66. package/src/parser.test.ts +0 -148
  67. package/src/parser.ts +0 -64
  68. package/src/plugin-pipeline.ts +0 -208
  69. package/src/plugin-system.test.ts +0 -697
  70. package/src/response-parser.ts +0 -114
  71. package/src/response-parsing.test.ts +0 -333
  72. package/src/route-matcher.ts +0 -69
  73. package/src/route-matching.test.ts +0 -394
  74. package/src/server.test.ts +0 -234
  75. package/src/smart-defaults.test.ts +0 -361
  76. package/src/steps/async-support.steps.ts +0 -421
  77. package/src/steps/audit-core-builder.steps.ts +0 -188
  78. package/src/steps/audit-onerror-tuple.steps.ts +0 -83
  79. package/src/steps/basic-usage.steps.ts +0 -245
  80. package/src/steps/developer-experience.steps.ts +0 -204
  81. package/src/steps/error-handling.steps.ts +0 -555
  82. package/src/steps/fetch-interceptor.steps.ts +0 -457
  83. package/src/steps/fluent-api.steps.ts +0 -255
  84. package/src/steps/http-methods.steps.ts +0 -342
  85. package/src/steps/lifecycle-events.steps.ts +0 -149
  86. package/src/steps/performance-reliability.steps.ts +0 -90
  87. package/src/steps/plugin-integration.steps.ts +0 -334
  88. package/src/steps/request-history.steps.ts +0 -458
  89. package/src/steps/response-delay.steps.ts +0 -88
  90. package/src/steps/route-key-format.steps.ts +0 -99
  91. package/src/steps/standalone-server.steps.ts +0 -227
  92. package/src/steps/state-concurrency.steps.ts +0 -167
  93. package/src/types.ts +0 -36
package/src/builder.ts DELETED
@@ -1,748 +0,0 @@
1
- import type { Server } from "node:http";
2
- import { isBinaryBody } from "./binary.js";
3
- import { normalizePath, toHttpMethod } from "./constants.js";
4
- import {
5
- errorMessage,
6
- RouteDefinitionError,
7
- RouteNotFoundError,
8
- SchmockError,
9
- } from "./errors.js";
10
- import {
11
- collectBody,
12
- parseNodeHeaders,
13
- parseNodeQuery,
14
- writeSchmockResponse,
15
- } from "./http-helpers.js";
16
- import { createFetchInterceptor } from "./interceptor.js";
17
- import { parseRouteKey } from "./parser.js";
18
- import {
19
- recoverGeneratorError,
20
- runPluginBeforeRequest,
21
- runPluginPipeline,
22
- } from "./plugin-pipeline.js";
23
- import { parseResponse } from "./response-parser.js";
24
- import type { CompiledCallableRoute } from "./route-matcher.js";
25
- import {
26
- extractParams,
27
- findRoute,
28
- isGeneratorFunction,
29
- } from "./route-matcher.js";
30
-
31
- /**
32
- * Debug logger that respects debug mode configuration
33
- */
34
- class DebugLogger {
35
- constructor(private enabled = false) {}
36
-
37
- log(category: string, message: string, data?: unknown) {
38
- if (!this.enabled) return;
39
-
40
- const timestamp = new Date().toISOString();
41
- const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
42
-
43
- if (data) {
44
- console.log(`${prefix} ${message}`, data);
45
- } else {
46
- console.log(`${prefix} ${message}`);
47
- }
48
- }
49
-
50
- time(label: string) {
51
- if (!this.enabled) return;
52
- console.time(`[SCHMOCK] ${label}`);
53
- }
54
-
55
- timeEnd(label: string) {
56
- if (!this.enabled) return;
57
- console.timeEnd(`[SCHMOCK] ${label}`);
58
- }
59
- }
60
-
61
- /**
62
- * Callable mock instance that implements the new API.
63
- *
64
- * @internal
65
- */
66
- export class CallableMockInstance {
67
- private routes: CompiledCallableRoute[] = [];
68
- private staticRoutes = new Map<string, CompiledCallableRoute>();
69
- private plugins: Schmock.Plugin[] = [];
70
- private logger: DebugLogger;
71
- private requestHistory: Schmock.RequestRecord[] = [];
72
- private callableRef: Schmock.CallableMockInstance | undefined;
73
- private server: Server | undefined;
74
- private serverInfo: Schmock.ServerInfo | undefined;
75
- private interceptHandle: Schmock.InterceptHandle | null = null;
76
- // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
77
- private listeners = new Map<string, Set<Function>>();
78
-
79
- constructor(private globalConfig: Schmock.GlobalConfig = {}) {
80
- this.logger = new DebugLogger(globalConfig.debug || false);
81
- if (globalConfig.debug) {
82
- this.logger.log("config", "Debug mode enabled");
83
- }
84
- this.logger.log("config", "Callable mock instance created", {
85
- debug: globalConfig.debug,
86
- namespace: globalConfig.namespace,
87
- delay: globalConfig.delay,
88
- });
89
- }
90
-
91
- // Method for defining routes (called when instance is invoked)
92
- defineRoute(
93
- route: Schmock.RouteKey,
94
- generator: Schmock.Generator,
95
- config: Schmock.RouteConfig,
96
- ): this {
97
- // FIX 1.2: shallow-clone the caller's config so mutations below stay private
98
- const routeConfig = { ...config };
99
-
100
- // Auto-detect contentType if not provided
101
- if (!routeConfig.contentType) {
102
- if (typeof generator === "function") {
103
- // Default to JSON for function generators
104
- routeConfig.contentType = "application/json";
105
- } else if (
106
- typeof generator === "string" ||
107
- typeof generator === "number" ||
108
- typeof generator === "boolean"
109
- ) {
110
- // Default to plain text for primitives
111
- routeConfig.contentType = "text/plain";
112
- } else if (isBinaryBody(generator)) {
113
- // Default to octet-stream for browser and Node binary values
114
- routeConfig.contentType = "application/octet-stream";
115
- } else {
116
- // Default to JSON for objects/arrays
117
- routeConfig.contentType = "application/json";
118
- }
119
- }
120
-
121
- // Validate generator matches contentType if it's static data
122
- if (
123
- typeof generator !== "function" &&
124
- routeConfig.contentType === "application/json"
125
- ) {
126
- try {
127
- JSON.stringify(generator);
128
- } catch (_error) {
129
- throw new RouteDefinitionError(
130
- route,
131
- "Generator data is not valid JSON but contentType is application/json",
132
- );
133
- }
134
- }
135
-
136
- // Parse the route key to create pattern and extract parameters
137
- const parsed = parseRouteKey(route);
138
-
139
- // FIX 2.2: normalize paths before duplicate check so /users and /users/ are
140
- // treated as the same route (consistent with the static-route Map key below)
141
- const normalizedParsedPath = normalizePath(parsed.path);
142
- const existing = this.routes.find(
143
- (r) =>
144
- r.method === parsed.method &&
145
- normalizePath(r.path) === normalizedParsedPath,
146
- );
147
- if (existing) {
148
- this.logger.log(
149
- "warning",
150
- `Duplicate route: ${route} — first registration wins`,
151
- );
152
- return this;
153
- }
154
-
155
- // Compile the route
156
- const compiledRoute: CompiledCallableRoute = {
157
- pattern: parsed.pattern,
158
- params: parsed.params,
159
- method: parsed.method,
160
- path: parsed.path,
161
- generator,
162
- config: routeConfig,
163
- };
164
-
165
- this.routes.push(compiledRoute);
166
-
167
- // Store static routes (no params) in Map for O(1) lookup
168
- if (parsed.params.length === 0) {
169
- const key = `${parsed.method} ${normalizePath(parsed.path)}`;
170
- this.staticRoutes.set(key, compiledRoute);
171
- }
172
-
173
- this.logger.log("route", `Route defined: ${route}`, {
174
- contentType: routeConfig.contentType,
175
- generatorType: typeof generator,
176
- hasParams: parsed.params.length > 0,
177
- });
178
-
179
- return this;
180
- }
181
-
182
- setCallableRef(ref: Schmock.CallableMockInstance): void {
183
- this.callableRef = ref;
184
- }
185
-
186
- pipe(plugin: Schmock.Plugin): this {
187
- this.plugins.push(plugin);
188
- this.logger.log(
189
- "plugin",
190
- `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`,
191
- {
192
- name: plugin.name,
193
- version: plugin.version,
194
- hasProcess: typeof plugin.process === "function",
195
- hasOnError: typeof plugin.onError === "function",
196
- },
197
- );
198
- if (plugin.install && this.callableRef) {
199
- plugin.install(this.callableRef);
200
- }
201
- return this;
202
- }
203
-
204
- // ===== Request Spy / History API =====
205
-
206
- /**
207
- * FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
208
- * history by mutating nested body/response objects.
209
- * Falls back to a shallow spread for bodies that are not structuredClone-able
210
- * (e.g. Buffers, streams — rare in practice but defensively handled).
211
- */
212
- private cloneRecord(r: Schmock.RequestRecord): Schmock.RequestRecord {
213
- try {
214
- return structuredClone(r);
215
- } catch {
216
- return {
217
- ...r,
218
- response: { ...r.response },
219
- };
220
- }
221
- }
222
-
223
- history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
224
- if (method || path) {
225
- return this.requestHistory
226
- .filter(
227
- (r) => (!method || r.method === method) && (!path || r.path === path),
228
- )
229
- .map((r) => this.cloneRecord(r));
230
- }
231
- return this.requestHistory.map((r) => this.cloneRecord(r));
232
- }
233
-
234
- called(method?: Schmock.HttpMethod, path?: string): boolean {
235
- if (method || path) {
236
- return this.requestHistory.some(
237
- (r) => (!method || r.method === method) && (!path || r.path === path),
238
- );
239
- }
240
- return this.requestHistory.length > 0;
241
- }
242
-
243
- callCount(method?: Schmock.HttpMethod, path?: string): number {
244
- if (method || path) {
245
- return this.requestHistory.filter(
246
- (r) => (!method || r.method === method) && (!path || r.path === path),
247
- ).length;
248
- }
249
- return this.requestHistory.length;
250
- }
251
-
252
- lastRequest(
253
- method?: Schmock.HttpMethod,
254
- path?: string,
255
- ): Schmock.RequestRecord | undefined {
256
- if (method || path) {
257
- const filtered = this.requestHistory.filter(
258
- (r) => (!method || r.method === method) && (!path || r.path === path),
259
- );
260
- const last = filtered[filtered.length - 1];
261
- // FIX 2.3: return a deep clone so callers cannot corrupt internal history
262
- return last ? this.cloneRecord(last) : undefined;
263
- }
264
- const last = this.requestHistory[this.requestHistory.length - 1];
265
- // FIX 2.3: return a deep clone so callers cannot corrupt internal history
266
- return last ? this.cloneRecord(last) : undefined;
267
- }
268
-
269
- // ===== Introspection =====
270
-
271
- getRoutes(): Schmock.RouteInfo[] {
272
- return this.routes.map((r) => ({
273
- method: r.method,
274
- path: r.path,
275
- hasParams: r.params.length > 0,
276
- }));
277
- }
278
-
279
- getState(): Record<string, unknown> {
280
- return { ...(this.globalConfig.state || {}) };
281
- }
282
-
283
- // ===== Lifecycle Events =====
284
-
285
- on<E extends Schmock.SchmockEvent>(
286
- event: E,
287
- listener: (data: Schmock.SchmockEventMap[E]) => void,
288
- ): this {
289
- let set = this.listeners.get(event);
290
- if (!set) {
291
- set = new Set();
292
- this.listeners.set(event, set);
293
- }
294
- set.add(listener);
295
- return this;
296
- }
297
-
298
- off<E extends Schmock.SchmockEvent>(
299
- event: E,
300
- listener: (data: Schmock.SchmockEventMap[E]) => void,
301
- ): this {
302
- this.listeners.get(event)?.delete(listener);
303
- return this;
304
- }
305
-
306
- private emit(event: string, data: unknown): void {
307
- const set = this.listeners.get(event);
308
- if (set) {
309
- for (const listener of set) {
310
- listener(data);
311
- }
312
- }
313
- }
314
-
315
- // ===== Reset / Lifecycle =====
316
-
317
- reset(): void {
318
- this.interceptHandle?.restore();
319
- this.interceptHandle = null;
320
- this.close();
321
- this.routes = [];
322
- this.staticRoutes.clear();
323
- this.plugins = [];
324
- this.requestHistory = [];
325
- this.listeners.clear();
326
- // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
327
- // reference — this avoids mutating the external state object passed by the user
328
- if (this.globalConfig.state) {
329
- this.globalConfig.state = {};
330
- }
331
- this.logger.log("lifecycle", "Mock fully reset");
332
- }
333
-
334
- resetHistory(): void {
335
- this.requestHistory = [];
336
- this.logger.log("lifecycle", "Request history cleared");
337
- }
338
-
339
- resetState(): void {
340
- // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
341
- // reference — this avoids mutating the external state object passed by the user
342
- if (this.globalConfig.state) {
343
- this.globalConfig.state = {};
344
- }
345
- this.logger.log("lifecycle", "State cleared");
346
- }
347
-
348
- // ===== Standalone Server =====
349
-
350
- listen(port = 0, hostname = "127.0.0.1"): Promise<Schmock.ServerInfo> {
351
- if (this.server) {
352
- throw new SchmockError(
353
- "Server is already running",
354
- "SERVER_ALREADY_RUNNING",
355
- );
356
- }
357
-
358
- // Lazy-load node:http so browser bundles never pull it in. See issue #395.
359
- return import("node:http").then(({ createServer }) =>
360
- this.#startHttpServer(createServer, port, hostname),
361
- );
362
- }
363
-
364
- #startHttpServer(
365
- createServer: typeof import("node:http").createServer,
366
- port: number,
367
- hostname: string,
368
- ): Promise<Schmock.ServerInfo> {
369
- const httpServer = createServer((req, res) => {
370
- const handleRequest = async () => {
371
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
372
- const method = toHttpMethod(req.method ?? "GET");
373
- const path = url.pathname;
374
- const headers = parseNodeHeaders(req);
375
- const query = parseNodeQuery(url);
376
- const body = await collectBody(req, headers);
377
- const schmockResponse = await this.handle(method, path, {
378
- headers,
379
- body,
380
- query,
381
- });
382
- writeSchmockResponse(res, schmockResponse);
383
- };
384
-
385
- handleRequest().catch((error) => {
386
- if (!res.headersSent) {
387
- res.writeHead(500, { "content-type": "application/json" });
388
- }
389
- res.end(
390
- JSON.stringify({
391
- error:
392
- error instanceof Error ? error.message : "Internal Server Error",
393
- code: "SERVER_ERROR",
394
- }),
395
- );
396
- });
397
- });
398
-
399
- this.server = httpServer;
400
-
401
- return new Promise((resolve, reject) => {
402
- const handleStartupError = (error: Error) => {
403
- if (this.server === httpServer) {
404
- this.server = undefined;
405
- this.serverInfo = undefined;
406
- }
407
- reject(error);
408
- };
409
- httpServer.once("error", handleStartupError);
410
- httpServer.listen(port, hostname, () => {
411
- httpServer.off("error", handleStartupError);
412
- const addr = httpServer.address();
413
- const actualPort =
414
- addr !== null && typeof addr === "object" ? addr.port : port;
415
- this.serverInfo = { port: actualPort, hostname };
416
- this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
417
- resolve(this.serverInfo);
418
- });
419
- });
420
- }
421
-
422
- close(): void {
423
- if (!this.server) {
424
- return;
425
- }
426
- this.server.closeAllConnections();
427
- this.server.close();
428
- this.server = undefined;
429
- this.serverInfo = undefined;
430
- this.logger.log("server", "Server stopped");
431
- }
432
-
433
- // ===== Fetch Interceptor =====
434
-
435
- intercept(options?: Schmock.InterceptOptions): Schmock.InterceptHandle {
436
- if (this.interceptHandle?.active) {
437
- throw new SchmockError(
438
- "Already intercepting. Call restore() first.",
439
- "ALREADY_INTERCEPTING",
440
- );
441
- }
442
-
443
- this.interceptHandle = createFetchInterceptor(
444
- (method, path, opts) => this.handle(method, path, opts),
445
- options,
446
- );
447
-
448
- return this.interceptHandle;
449
- }
450
-
451
- async handle(
452
- method: Schmock.HttpMethod,
453
- path: string,
454
- options?: Schmock.RequestOptions,
455
- ): Promise<Schmock.Response> {
456
- const handleStart = performance.now();
457
- const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
458
- const reqQuery = options?.query || {};
459
- const reqHeaders = options?.headers || {};
460
- this.logger.log("request", `[${requestId}] ${method} ${path}`, {
461
- headers: reqHeaders,
462
- query: reqQuery,
463
- bodyType: options?.body ? typeof options.body : "none",
464
- });
465
- this.logger.time(`request-${requestId}`);
466
-
467
- this.emit("request:start", {
468
- method,
469
- path,
470
- headers: reqHeaders,
471
- });
472
-
473
- try {
474
- // Apply namespace if configured
475
- let requestPath = path;
476
- if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
477
- const namespace = this.globalConfig.namespace.startsWith("/")
478
- ? this.globalConfig.namespace
479
- : `/${this.globalConfig.namespace}`;
480
-
481
- const pathToCheck = path.startsWith("/") ? path : `/${path}`;
482
-
483
- // Check if path starts with namespace
484
- // handle both "/api/users" (starts with /api) and "/api" (exact match)
485
- // but NOT "/apiv2" (prefix match but wrong segment)
486
- const isMatch =
487
- pathToCheck === namespace ||
488
- pathToCheck.startsWith(
489
- namespace.endsWith("/") ? namespace : `${namespace}/`,
490
- );
491
-
492
- if (!isMatch) {
493
- this.logger.log(
494
- "route",
495
- `[${requestId}] Path doesn't match namespace ${namespace}`,
496
- );
497
- const error = new RouteNotFoundError(method, path);
498
- const response = {
499
- status: 404,
500
- body: { error: error.message, code: error.code },
501
- headers: { "content-type": "application/json" },
502
- };
503
- this.emit("request:end", {
504
- method,
505
- path,
506
- status: 404,
507
- duration: performance.now() - handleStart,
508
- });
509
- this.logger.timeEnd(`request-${requestId}`);
510
- return response;
511
- }
512
-
513
- // Remove namespace prefix, ensuring we always start with /
514
- const stripped = pathToCheck.slice(namespace.length);
515
- requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
516
- }
517
-
518
- // Find matching route
519
- const matchedRoute = findRoute(
520
- method,
521
- requestPath,
522
- this.staticRoutes,
523
- this.routes,
524
- );
525
-
526
- if (!matchedRoute) {
527
- this.logger.log(
528
- "route",
529
- `[${requestId}] No route found for ${method} ${requestPath}`,
530
- );
531
- this.emit("request:notfound", { method, path: requestPath });
532
- const error = new RouteNotFoundError(method, path);
533
- const response = {
534
- status: 404,
535
- body: { error: error.message, code: error.code },
536
- headers: { "content-type": "application/json" },
537
- };
538
- this.emit("request:end", {
539
- method,
540
- path: requestPath,
541
- status: 404,
542
- duration: performance.now() - handleStart,
543
- });
544
- this.logger.timeEnd(`request-${requestId}`);
545
- return response;
546
- }
547
-
548
- this.logger.log(
549
- "route",
550
- `[${requestId}] Matched route: ${method} ${matchedRoute.path}`,
551
- );
552
-
553
- // Extract parameters from the matched route
554
- const params = extractParams(matchedRoute, requestPath);
555
-
556
- this.emit("request:match", {
557
- method,
558
- path: requestPath,
559
- routePath: matchedRoute.path,
560
- params,
561
- });
562
-
563
- // Build plugin context before route code so request guards can reject
564
- // invalid or unauthorized requests without triggering side effects.
565
- let pluginContext: Schmock.PluginContext = {
566
- path: requestPath,
567
- route: matchedRoute.config,
568
- method,
569
- params,
570
- query: reqQuery,
571
- headers: reqHeaders,
572
- body: options?.body,
573
- state: new Map(),
574
- routeState: this.globalConfig.state || {},
575
- };
576
-
577
- const preflightResult = await runPluginBeforeRequest(
578
- this.plugins,
579
- pluginContext,
580
- this.logger,
581
- );
582
- pluginContext = preflightResult.context;
583
- if (preflightResult.requestShortCircuited === true) {
584
- pluginContext = { ...pluginContext, requestShortCircuited: true };
585
- }
586
-
587
- let result: unknown = preflightResult.response;
588
- let skipPostProcessing = preflightResult.recoveredFromError === true;
589
-
590
- if (result === undefined) {
591
- const context: Schmock.RequestContext = {
592
- method: pluginContext.method,
593
- path: pluginContext.path,
594
- params: pluginContext.params,
595
- query: pluginContext.query,
596
- headers: pluginContext.headers,
597
- body: pluginContext.body,
598
- state: pluginContext.routeState ?? this.globalConfig.state ?? {},
599
- };
600
-
601
- try {
602
- if (isGeneratorFunction(matchedRoute.generator)) {
603
- result = await matchedRoute.generator(context);
604
- } else {
605
- result = matchedRoute.generator;
606
- }
607
- } catch (error) {
608
- const recovery = await recoverGeneratorError(
609
- this.plugins,
610
- pluginContext,
611
- error,
612
- this.logger,
613
- );
614
- pluginContext = recovery.context;
615
- result = recovery.response;
616
- skipPostProcessing = recovery.recoveredFromError === true;
617
- }
618
- }
619
-
620
- // Run plugin pipeline to transform the response
621
- try {
622
- if (skipPostProcessing) {
623
- this.logger.log(
624
- "pipeline",
625
- "Skipping response processors after error recovery",
626
- );
627
- } else {
628
- const pipelineResult = await runPluginPipeline(
629
- this.plugins,
630
- pluginContext,
631
- result,
632
- this.logger,
633
- );
634
- pluginContext = pipelineResult.context;
635
- result = pipelineResult.response;
636
- }
637
- } catch (error) {
638
- this.logger.log(
639
- "error",
640
- `[${requestId}] Plugin pipeline error: ${errorMessage(error)}`,
641
- );
642
- throw error;
643
- }
644
-
645
- // Parse and prepare response
646
- const response = parseResponse(result, matchedRoute.config);
647
-
648
- // Apply delay (route-level overrides global)
649
- await this.applyDelay(matchedRoute.config.delay);
650
-
651
- // Record request in history (FIFO-bounded when maxHistorySize is set)
652
- this.requestHistory.push({
653
- method,
654
- path: requestPath,
655
- params,
656
- query: reqQuery,
657
- headers: reqHeaders,
658
- body: options?.body,
659
- timestamp: Date.now(),
660
- response: { status: response.status, body: response.body },
661
- });
662
- const maxHistorySize = this.globalConfig.maxHistorySize;
663
- if (
664
- typeof maxHistorySize === "number" &&
665
- maxHistorySize >= 0 &&
666
- this.requestHistory.length > maxHistorySize
667
- ) {
668
- this.requestHistory.splice(
669
- 0,
670
- this.requestHistory.length - maxHistorySize,
671
- );
672
- }
673
-
674
- this.emit("request:end", {
675
- method,
676
- path: requestPath,
677
- status: response.status,
678
- duration: performance.now() - handleStart,
679
- });
680
-
681
- // Log successful response
682
- this.logger.log(
683
- "response",
684
- `[${requestId}] Sending response ${response.status}`,
685
- {
686
- status: response.status,
687
- headers: response.headers,
688
- bodyType: typeof response.body,
689
- },
690
- );
691
- this.logger.timeEnd(`request-${requestId}`);
692
-
693
- return response;
694
- } catch (error) {
695
- this.logger.log(
696
- "error",
697
- `[${requestId}] Error processing request: ${errorMessage(error)}`,
698
- error,
699
- );
700
-
701
- // Return error response
702
- const errorResponse = {
703
- status: 500,
704
- body: {
705
- error: errorMessage(error),
706
- code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
707
- },
708
- headers: { "content-type": "application/json" },
709
- };
710
-
711
- // Apply delay even for error responses
712
- await this.applyDelay();
713
-
714
- this.emit("request:end", {
715
- method,
716
- path,
717
- status: 500,
718
- duration: performance.now() - handleStart,
719
- });
720
-
721
- this.logger.log("error", `[${requestId}] Returning error response 500`);
722
- this.logger.timeEnd(`request-${requestId}`);
723
- return errorResponse;
724
- }
725
- }
726
-
727
- /**
728
- * Apply configured response delay
729
- * Supports both fixed delays and random delays within a range
730
- * @private
731
- */
732
- private async applyDelay(
733
- routeDelay?: number | [number, number],
734
- ): Promise<void> {
735
- const effectiveDelay = routeDelay ?? this.globalConfig.delay;
736
- if (!effectiveDelay) {
737
- return;
738
- }
739
-
740
- const configuredMs = Array.isArray(effectiveDelay)
741
- ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
742
- effectiveDelay[0]
743
- : effectiveDelay;
744
- const ms = Math.max(0, configuredMs);
745
-
746
- await new Promise((resolve) => setTimeout(resolve, ms));
747
- }
748
- }