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