@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/dist/builder.js CHANGED
@@ -1,11 +1,130 @@
1
- import { normalizePath, toHttpMethod } from "./constants.js";
1
+ import { awaitWithAbort, throwIfAborted } from "./abort.js";
2
+ import { isBinaryBody } from "./binary.js";
3
+ import { canonicalizePath, markResponseException, markRouteNotFound, normalizePath, toHttpMethod, } from "./constants.js";
2
4
  import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
3
- import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
5
+ import { collectBody, HttpIngressError, parseNodeHeaders, parseNodeQuery, writeRejectedSchmockResponse, writeSchmockResponse, } from "./http-helpers.js";
4
6
  import { createFetchInterceptor } from "./interceptor.js";
5
7
  import { parseRouteKey } from "./parser.js";
6
- import { runPluginPipeline } from "./plugin-pipeline.js";
8
+ import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
9
+ import { normalizeResponse } from "./response-normalizer.js";
7
10
  import { parseResponse } from "./response-parser.js";
8
11
  import { extractParams, findRoute, isGeneratorFunction, } from "./route-matcher.js";
12
+ function isThenable(value) {
13
+ return (typeof value === "object" &&
14
+ value !== null &&
15
+ "then" in value &&
16
+ typeof value.then === "function");
17
+ }
18
+ function unavailableHistoryValue(value) {
19
+ let type = typeof value;
20
+ if (typeof value === "object" && value !== null) {
21
+ try {
22
+ type = Object.prototype.toString.call(value);
23
+ }
24
+ catch {
25
+ type = "object";
26
+ }
27
+ }
28
+ return {
29
+ kind: "unavailable",
30
+ reason: "not-structured-cloneable",
31
+ type,
32
+ };
33
+ }
34
+ function removeSharedMemory(value, seen = new WeakMap()) {
35
+ if (typeof value !== "object" || value === null)
36
+ return value;
37
+ const existing = seen.get(value);
38
+ if (existing !== undefined)
39
+ return existing;
40
+ if (typeof SharedArrayBuffer !== "undefined" &&
41
+ value instanceof SharedArrayBuffer) {
42
+ const copy = Uint8Array.from(new Uint8Array(value)).buffer;
43
+ seen.set(value, copy);
44
+ return copy;
45
+ }
46
+ if (ArrayBuffer.isView(value) &&
47
+ typeof SharedArrayBuffer !== "undefined" &&
48
+ value.buffer instanceof SharedArrayBuffer) {
49
+ const copy = Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
50
+ seen.set(value, copy);
51
+ return copy;
52
+ }
53
+ seen.set(value, value);
54
+ if (value instanceof Map) {
55
+ const entries = [...value.entries()];
56
+ value.clear();
57
+ for (const [key, entryValue] of entries) {
58
+ value.set(removeSharedMemory(key, seen), removeSharedMemory(entryValue, seen));
59
+ }
60
+ return value;
61
+ }
62
+ if (value instanceof Set) {
63
+ const entries = [...value.values()];
64
+ value.clear();
65
+ for (const entryValue of entries) {
66
+ value.add(removeSharedMemory(entryValue, seen));
67
+ }
68
+ return value;
69
+ }
70
+ for (const key of Reflect.ownKeys(value)) {
71
+ Reflect.set(value, key, removeSharedMemory(Reflect.get(value, key), seen));
72
+ }
73
+ return value;
74
+ }
75
+ /**
76
+ * Reject a history limit that cannot bound anything.
77
+ *
78
+ * A negative limit used to read as "unbounded" and a fractional one evicted a
79
+ * fractional number of records, so a typo silently disabled the cap instead of
80
+ * failing. `Number.isInteger` also rejects NaN and Infinity. `0` stays valid
81
+ * and keeps meaning "history disabled".
82
+ */
83
+ function assertValidHistoryLimit(limit) {
84
+ if (limit === undefined)
85
+ return;
86
+ if (!Number.isInteger(limit) || limit < 0) {
87
+ throw new SchmockError(`Invalid maxHistorySize: ${String(limit)}. Expected a non-negative integer (0 disables history).`, "INVALID_CONFIG", { maxHistorySize: limit });
88
+ }
89
+ }
90
+ /**
91
+ * Header names whose VALUE is replaced in debug logs. The name is kept so a log
92
+ * still shows the header was present; only the credential is hidden. Matches
93
+ * the set the CLI already masks.
94
+ */
95
+ const REDACTED_HEADER_NAMES = new Set([
96
+ "authorization",
97
+ "proxy-authorization",
98
+ "cookie",
99
+ "set-cookie",
100
+ "x-api-key",
101
+ "x-auth-token",
102
+ "x-schmock-admin-token",
103
+ ]);
104
+ const REDACTED_HEADER_VALUE = "[redacted]";
105
+ /**
106
+ * Copy-on-write redaction: the input record is handed on to plugins, history
107
+ * and transports, so it must never be mutated. When nothing is sensitive the
108
+ * original object is returned unchanged.
109
+ */
110
+ function redactSensitiveHeaders(headers) {
111
+ let redacted;
112
+ for (const name of Object.keys(headers)) {
113
+ if (!REDACTED_HEADER_NAMES.has(name.toLowerCase()))
114
+ continue;
115
+ redacted ??= { ...headers };
116
+ redacted[name] = REDACTED_HEADER_VALUE;
117
+ }
118
+ return redacted ?? headers;
119
+ }
120
+ function snapshotHistoryValue(value) {
121
+ try {
122
+ return removeSharedMemory(structuredClone(value));
123
+ }
124
+ catch {
125
+ return unavailableHistoryValue(value);
126
+ }
127
+ }
9
128
  /**
10
129
  * Debug logger that respects debug mode configuration
11
130
  */
@@ -43,7 +162,6 @@ class DebugLogger {
43
162
  * @internal
44
163
  */
45
164
  export class CallableMockInstance {
46
- globalConfig;
47
165
  routes = [];
48
166
  staticRoutes = new Map();
49
167
  plugins = [];
@@ -51,12 +169,21 @@ export class CallableMockInstance {
51
169
  requestHistory = [];
52
170
  callableRef;
53
171
  server;
54
- serverInfo;
55
- interceptHandle = null;
172
+ pendingServerStart;
173
+ serverCloseBarrier;
174
+ interceptHandles = new Set();
175
+ requestGeneration = { activeAdmissions: 0 };
176
+ historyGeneration = Symbol("schmock.history.generation");
177
+ interceptOwner = Symbol("schmock.intercept.owner");
178
+ globalConfig;
56
179
  // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
57
180
  listeners = new Map();
58
181
  constructor(globalConfig = {}) {
59
- this.globalConfig = globalConfig;
182
+ assertValidHistoryLimit(globalConfig.maxHistorySize);
183
+ this.globalConfig = {
184
+ ...globalConfig,
185
+ state: globalConfig.state ?? {},
186
+ };
60
187
  this.logger = new DebugLogger(globalConfig.debug || false);
61
188
  if (globalConfig.debug) {
62
189
  this.logger.log("config", "Debug mode enabled");
@@ -83,8 +210,8 @@ export class CallableMockInstance {
83
210
  // Default to plain text for primitives
84
211
  routeConfig.contentType = "text/plain";
85
212
  }
86
- else if (Buffer.isBuffer(generator)) {
87
- // Default to octet-stream for buffers
213
+ else if (isBinaryBody(generator)) {
214
+ // Default to octet-stream for browser and Node binary values
88
215
  routeConfig.contentType = "application/octet-stream";
89
216
  }
90
217
  else {
@@ -139,6 +266,82 @@ export class CallableMockInstance {
139
266
  this.callableRef = ref;
140
267
  }
141
268
  pipe(plugin) {
269
+ if (plugin.install && this.callableRef) {
270
+ const previousRoutes = this.routes;
271
+ const previousStaticRoutes = this.staticRoutes;
272
+ this.routes = previousRoutes.slice();
273
+ this.staticRoutes = new Map(previousStaticRoutes);
274
+ let installActive = true;
275
+ let installFacade;
276
+ const requireInstallScope = () => {
277
+ if (installActive)
278
+ return;
279
+ throw new SchmockError(`Plugin "${plugin.name}" used its install instance outside install()`, "PLUGIN_INSTALL_SCOPE_EXPIRED", { plugin: plugin.name });
280
+ };
281
+ const rejectInstallOperation = (operation) => {
282
+ requireInstallScope();
283
+ throw new SchmockError(`Plugin "${plugin.name}" cannot call ${operation} during install()`, "PLUGIN_INSTALL_OPERATION_UNSUPPORTED", { operation, plugin: plugin.name });
284
+ };
285
+ const registerRoute = (route, generator, config = {}) => {
286
+ requireInstallScope();
287
+ this.defineRoute(route, generator, config);
288
+ return installFacade;
289
+ };
290
+ installFacade = Object.assign(registerRoute, {
291
+ pipe: () => rejectInstallOperation("pipe()"),
292
+ handle: () => rejectInstallOperation("handle()"),
293
+ history: (method, path) => {
294
+ requireInstallScope();
295
+ return this.history(method, path);
296
+ },
297
+ called: (method, path) => {
298
+ requireInstallScope();
299
+ return this.called(method, path);
300
+ },
301
+ callCount: (method, path) => {
302
+ requireInstallScope();
303
+ return this.callCount(method, path);
304
+ },
305
+ lastRequest: (method, path) => {
306
+ requireInstallScope();
307
+ return this.lastRequest(method, path);
308
+ },
309
+ reset: () => rejectInstallOperation("reset()"),
310
+ resetHistory: () => rejectInstallOperation("resetHistory()"),
311
+ resetState: () => rejectInstallOperation("resetState()"),
312
+ on: () => rejectInstallOperation("on()"),
313
+ off: () => rejectInstallOperation("off()"),
314
+ getRoutes: () => {
315
+ requireInstallScope();
316
+ return this.getRoutes();
317
+ },
318
+ getState: () => {
319
+ requireInstallScope();
320
+ return this.getState();
321
+ },
322
+ listen: () => rejectInstallOperation("listen()"),
323
+ close: () => rejectInstallOperation("close()"),
324
+ intercept: () => rejectInstallOperation("intercept()"),
325
+ });
326
+ try {
327
+ const installResult = plugin.install(installFacade);
328
+ installActive = false;
329
+ if (isThenable(installResult)) {
330
+ void Promise.resolve(installResult).catch((error) => {
331
+ this.logger.log("plugin", `Rejected async install for ${plugin.name}: ${errorMessage(error)}`);
332
+ });
333
+ throw new SchmockError(`Plugin "${plugin.name}" returned a Promise from install()`, "PLUGIN_ASYNC_INSTALL_UNSUPPORTED", { plugin: plugin.name });
334
+ }
335
+ }
336
+ catch (error) {
337
+ this.routes = previousRoutes;
338
+ this.staticRoutes = previousStaticRoutes;
339
+ throw error;
340
+ }
341
+ finally {
342
+ installActive = false;
343
+ }
344
+ }
142
345
  this.plugins.push(plugin);
143
346
  this.logger.log("plugin", `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`, {
144
347
  name: plugin.name,
@@ -146,52 +349,79 @@ export class CallableMockInstance {
146
349
  hasProcess: typeof plugin.process === "function",
147
350
  hasOnError: typeof plugin.onError === "function",
148
351
  });
149
- if (plugin.install && this.callableRef) {
150
- plugin.install(this.callableRef);
151
- }
152
352
  return this;
153
353
  }
354
+ uninstallPlugins(plugins) {
355
+ for (let index = plugins.length - 1; index >= 0; index -= 1) {
356
+ const plugin = plugins[index];
357
+ if (!plugin.uninstall || !this.callableRef)
358
+ continue;
359
+ try {
360
+ const uninstallResult = plugin.uninstall(this.callableRef);
361
+ if (isThenable(uninstallResult)) {
362
+ void Promise.resolve(uninstallResult).catch((error) => {
363
+ this.logger.log("plugin", `Async uninstall for ${plugin.name} failed: ${errorMessage(error)}`);
364
+ });
365
+ this.logger.log("plugin", `Plugin ${plugin.name} returned an unsupported Promise from uninstall()`);
366
+ }
367
+ }
368
+ catch (error) {
369
+ this.logger.log("plugin", `Plugin ${plugin.name} uninstall failed: ${errorMessage(error)}`);
370
+ }
371
+ }
372
+ }
154
373
  // ===== Request Spy / History API =====
374
+ cloneRecord(r) {
375
+ return {
376
+ method: r.method,
377
+ path: r.path,
378
+ params: { ...r.params },
379
+ query: { ...r.query },
380
+ headers: { ...r.headers },
381
+ body: snapshotHistoryValue(r.body),
382
+ timestamp: r.timestamp,
383
+ response: {
384
+ status: r.response.status,
385
+ body: snapshotHistoryValue(r.response.body),
386
+ },
387
+ };
388
+ }
155
389
  /**
156
- * FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
157
- * history by mutating nested body/response objects.
158
- * Falls back to a shallow spread for bodies that are not structuredClone-able
159
- * (e.g. Buffers, streams — rare in practice but defensively handled).
390
+ * History stores the canonical request path — percent-encoded and
391
+ * trailing-slash-normalized exactly as `handle()` produced it — so a spy
392
+ * filter must be put into the same form before it is compared, or the very
393
+ * string the caller passed to `handle()` would not match its own record.
394
+ * `canonicalizePath` is idempotent, so an already-encoded filter keeps
395
+ * matching and both spellings work.
160
396
  */
161
- cloneRecord(r) {
162
- try {
163
- return structuredClone(r);
164
- }
165
- catch {
166
- return {
167
- ...r,
168
- response: { ...r.response },
169
- };
170
- }
397
+ #historyMatcher(method, path) {
398
+ const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
399
+ return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
171
400
  }
172
401
  history(method, path) {
173
402
  if (method || path) {
174
403
  return this.requestHistory
175
- .filter((r) => (!method || r.method === method) && (!path || r.path === path))
404
+ .filter(this.#historyMatcher(method, path))
176
405
  .map((r) => this.cloneRecord(r));
177
406
  }
178
407
  return this.requestHistory.map((r) => this.cloneRecord(r));
179
408
  }
180
409
  called(method, path) {
181
410
  if (method || path) {
182
- return this.requestHistory.some((r) => (!method || r.method === method) && (!path || r.path === path));
411
+ return this.requestHistory.some(this.#historyMatcher(method, path));
183
412
  }
184
413
  return this.requestHistory.length > 0;
185
414
  }
186
415
  callCount(method, path) {
187
416
  if (method || path) {
188
- return this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path)).length;
417
+ return this.requestHistory.filter(this.#historyMatcher(method, path))
418
+ .length;
189
419
  }
190
420
  return this.requestHistory.length;
191
421
  }
192
422
  lastRequest(method, path) {
193
423
  if (method || path) {
194
- const filtered = this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
424
+ const filtered = this.requestHistory.filter(this.#historyMatcher(method, path));
195
425
  const last = filtered[filtered.length - 1];
196
426
  // FIX 2.3: return a deep clone so callers cannot corrupt internal history
197
427
  return last ? this.cloneRecord(last) : undefined;
@@ -227,202 +457,429 @@ export class CallableMockInstance {
227
457
  }
228
458
  emit(event, data) {
229
459
  const set = this.listeners.get(event);
230
- if (set) {
231
- for (const listener of set) {
232
- listener(data);
460
+ if (!set)
461
+ return;
462
+ const snapshot = { ...data };
463
+ if ("headers" in data) {
464
+ snapshot.headers = Object.freeze({ ...data.headers });
465
+ }
466
+ if ("params" in data) {
467
+ snapshot.params = Object.freeze({ ...data.params });
468
+ }
469
+ const eventData = Object.freeze(snapshot);
470
+ for (const listener of [...set]) {
471
+ try {
472
+ const listenerResult = listener(eventData);
473
+ if (isThenable(listenerResult)) {
474
+ void Promise.resolve(listenerResult).catch((error) => {
475
+ this.logger.log("event", `${event} listener rejected: ${errorMessage(error)}`);
476
+ });
477
+ }
478
+ }
479
+ catch (error) {
480
+ this.logger.log("event", `${event} listener failed: ${errorMessage(error)}`);
233
481
  }
234
482
  }
235
483
  }
236
484
  // ===== Reset / Lifecycle =====
237
485
  reset() {
238
- this.interceptHandle?.restore();
239
- this.interceptHandle = null;
486
+ const retiredGeneration = this.requestGeneration;
487
+ this.requestGeneration = { activeAdmissions: 0 };
488
+ this.historyGeneration = Symbol("schmock.history.generation");
240
489
  this.close();
490
+ const installedPlugins = this.plugins;
491
+ this.plugins = [];
492
+ this.#retireRequestGeneration(retiredGeneration, installedPlugins);
241
493
  this.routes = [];
242
494
  this.staticRoutes.clear();
243
- this.plugins = [];
244
495
  this.requestHistory = [];
245
496
  this.listeners.clear();
246
- // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
247
- // reference — this avoids mutating the external state object passed by the user
248
- if (this.globalConfig.state) {
249
- this.globalConfig.state = {};
250
- }
497
+ this.globalConfig.state = {};
251
498
  this.logger.log("lifecycle", "Mock fully reset");
252
499
  }
253
500
  resetHistory() {
501
+ this.historyGeneration = Symbol("schmock.history.generation");
254
502
  this.requestHistory = [];
255
503
  this.logger.log("lifecycle", "Request history cleared");
256
504
  }
257
505
  resetState() {
258
- // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
259
- // reference — this avoids mutating the external state object passed by the user
260
- if (this.globalConfig.state) {
261
- this.globalConfig.state = {};
262
- }
506
+ this.globalConfig.state = {};
263
507
  this.logger.log("lifecycle", "State cleared");
264
508
  }
509
+ #captureRequestAdmission() {
510
+ const requestGeneration = this.requestGeneration;
511
+ requestGeneration.activeAdmissions += 1;
512
+ return {
513
+ requestGeneration,
514
+ historyGeneration: this.historyGeneration,
515
+ plugins: this.plugins.slice(),
516
+ routes: this.routes.slice(),
517
+ staticRoutes: new Map(this.staticRoutes),
518
+ state: this.globalConfig.state,
519
+ namespace: this.globalConfig.namespace,
520
+ globalDelay: this.globalConfig.delay,
521
+ maxHistorySize: this.globalConfig.maxHistorySize,
522
+ released: false,
523
+ };
524
+ }
525
+ #releaseRequestAdmission(admission) {
526
+ if (admission.released)
527
+ return;
528
+ admission.released = true;
529
+ const generation = admission.requestGeneration;
530
+ generation.activeAdmissions -= 1;
531
+ if (generation.activeAdmissions === 0 &&
532
+ generation.retiredPlugins !== undefined) {
533
+ const plugins = generation.retiredPlugins;
534
+ generation.retiredPlugins = undefined;
535
+ this.uninstallPlugins(plugins);
536
+ }
537
+ }
538
+ #retireRequestGeneration(generation, plugins) {
539
+ generation.retiredPlugins = plugins;
540
+ if (generation.activeAdmissions === 0) {
541
+ generation.retiredPlugins = undefined;
542
+ this.uninstallPlugins(plugins);
543
+ }
544
+ }
545
+ createRequestAdmission() {
546
+ const admission = this.#captureRequestAdmission();
547
+ return {
548
+ handle: (method, path, options) => this.handle(method, path, options, admission),
549
+ release: () => this.#releaseRequestAdmission(admission),
550
+ };
551
+ }
265
552
  // ===== Standalone Server =====
266
553
  listen(port = 0, hostname = "127.0.0.1") {
267
- if (this.server) {
554
+ if (this.server || this.pendingServerStart) {
268
555
  throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
269
556
  }
270
- // Lazy-load node:http so browser bundles never pull it in. See issue #395.
271
- return import("node:http").then(({ createServer }) => this.#startHttpServer(createServer, port, hostname));
557
+ let resolveStart = (_info) => { };
558
+ let rejectStart = (_error) => { };
559
+ const startPromise = new Promise((resolve, reject) => {
560
+ resolveStart = resolve;
561
+ rejectStart = reject;
562
+ });
563
+ const operation = {
564
+ token: Symbol("schmock.server.start"),
565
+ port,
566
+ hostname,
567
+ resolve: resolveStart,
568
+ reject: rejectStart,
569
+ settled: false,
570
+ };
571
+ this.pendingServerStart = operation;
572
+ const closeBarrier = this.serverCloseBarrier ?? Promise.resolve();
573
+ void closeBarrier
574
+ // Lazy-load node:http so browser bundles never pull it in. See issue #395.
575
+ .then(() => import("node:http"))
576
+ .then(({ createServer }) => {
577
+ if (!this.#ownsServerStart(operation))
578
+ return;
579
+ this.#startHttpServer(operation, createServer);
580
+ })
581
+ .catch((error) => {
582
+ this.#rejectServerStart(operation, error);
583
+ });
584
+ return startPromise;
585
+ }
586
+ #ownsServerStart(operation) {
587
+ return this.pendingServerStart === operation && !operation.settled;
272
588
  }
273
- #startHttpServer(createServer, port, hostname) {
589
+ #startHttpServer(operation, createServer) {
274
590
  const httpServer = createServer((req, res) => {
591
+ const admittedRequest = this.createRequestAdmission();
592
+ const abortController = new AbortController();
593
+ const abortRequest = () => abortController.abort();
594
+ req.once("aborted", abortRequest);
595
+ res.once("close", abortRequest);
596
+ let requestMethod = req.method?.toUpperCase() === "HEAD" ? "HEAD" : "GET";
275
597
  const handleRequest = async () => {
276
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
277
- const method = toHttpMethod(req.method ?? "GET");
278
- const path = url.pathname;
279
- const headers = parseNodeHeaders(req);
280
- const query = parseNodeQuery(url);
281
- const body = await collectBody(req, headers);
282
- const schmockResponse = await this.handle(method, path, {
283
- headers,
284
- body,
285
- query,
286
- });
287
- writeSchmockResponse(res, schmockResponse);
598
+ try {
599
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
600
+ const method = toHttpMethod(req.method ?? "GET");
601
+ requestMethod = method;
602
+ const path = url.pathname;
603
+ const headers = parseNodeHeaders(req);
604
+ const query = parseNodeQuery(url);
605
+ const body = await collectBody(req, headers);
606
+ const schmockResponse = await admittedRequest.handle(method, path, {
607
+ headers,
608
+ body,
609
+ query,
610
+ signal: abortController.signal,
611
+ });
612
+ writeSchmockResponse(res, schmockResponse);
613
+ }
614
+ finally {
615
+ req.off("aborted", abortRequest);
616
+ res.off("close", abortRequest);
617
+ admittedRequest.release();
618
+ }
288
619
  };
289
620
  handleRequest().catch((error) => {
290
- if (!res.headersSent) {
291
- res.writeHead(500, { "content-type": "application/json" });
621
+ // A failing error-response write must never escape this handler as an
622
+ // unhandled rejection: destroy the socket so the client is not left
623
+ // hanging on a response that will never arrive.
624
+ try {
625
+ const ingressError = error instanceof HttpIngressError ? error : undefined;
626
+ const status = ingressError?.status ?? 500;
627
+ const code = ingressError?.code ?? "SERVER_ERROR";
628
+ if (!res.headersSent && !res.writableEnded) {
629
+ if (ingressError)
630
+ res.shouldKeepAlive = false;
631
+ // `shouldKeepAlive = false` alone emits no Connection header when
632
+ // writeHead is given a header object, so the announcement has to be
633
+ // explicit. It travels on the transport's own header channel rather
634
+ // than on the response: normalizeResponse strips hop-by-hop headers
635
+ // from everything a route produces.
636
+ const transportHeaders = ingressError
637
+ ? { connection: "close" }
638
+ : undefined;
639
+ const response = normalizeResponse({
640
+ status,
641
+ body: {
642
+ error: error instanceof Error
643
+ ? error.message
644
+ : "Internal Server Error",
645
+ code,
646
+ },
647
+ headers: { "content-type": "application/json" },
648
+ }, requestMethod);
649
+ if (ingressError?.status === 413) {
650
+ writeRejectedSchmockResponse(req, res, response, transportHeaders);
651
+ }
652
+ else {
653
+ writeSchmockResponse(res, response, transportHeaders);
654
+ }
655
+ }
656
+ else if (!res.writableEnded) {
657
+ res.end();
658
+ }
659
+ }
660
+ catch {
661
+ res.destroy();
292
662
  }
293
- res.end(JSON.stringify({
294
- error: error instanceof Error ? error.message : "Internal Server Error",
295
- code: "SERVER_ERROR",
296
- }));
297
663
  });
298
664
  });
299
- this.server = httpServer;
300
- return new Promise((resolve, reject) => {
301
- httpServer.on("error", reject);
302
- httpServer.listen(port, hostname, () => {
665
+ operation.server = httpServer;
666
+ const handleStartupError = (error) => {
667
+ this.#rejectServerStart(operation, error);
668
+ };
669
+ httpServer.once("error", handleStartupError);
670
+ try {
671
+ httpServer.listen(operation.port, operation.hostname, () => {
672
+ httpServer.off("error", handleStartupError);
673
+ if (!this.#ownsServerStart(operation)) {
674
+ this.#beginServerClose(httpServer);
675
+ return;
676
+ }
303
677
  const addr = httpServer.address();
304
- const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
305
- this.serverInfo = { port: actualPort, hostname };
306
- this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
307
- resolve(this.serverInfo);
678
+ const actualPort = addr !== null && typeof addr === "object"
679
+ ? addr.port
680
+ : operation.port;
681
+ const info = { port: actualPort, hostname: operation.hostname };
682
+ operation.settled = true;
683
+ this.pendingServerStart = undefined;
684
+ this.server = httpServer;
685
+ this.logger.log("server", `Listening on ${operation.hostname}:${actualPort}`);
686
+ operation.resolve(info);
308
687
  });
688
+ }
689
+ catch (error) {
690
+ httpServer.off("error", handleStartupError);
691
+ this.#rejectServerStart(operation, error);
692
+ }
693
+ }
694
+ #rejectServerStart(operation, error) {
695
+ if (operation.settled)
696
+ return;
697
+ operation.settled = true;
698
+ if (this.pendingServerStart === operation) {
699
+ this.pendingServerStart = undefined;
700
+ }
701
+ if (operation.server) {
702
+ this.#beginServerClose(operation.server);
703
+ }
704
+ operation.reject(error);
705
+ }
706
+ #cancelServerStart() {
707
+ const operation = this.pendingServerStart;
708
+ if (!operation)
709
+ return;
710
+ this.#rejectServerStart(operation, new SchmockError("Server start was cancelled", "SERVER_START_CANCELLED"));
711
+ }
712
+ #beginServerClose(server) {
713
+ const closePromise = new Promise((resolve) => {
714
+ try {
715
+ server.close(() => resolve());
716
+ }
717
+ catch {
718
+ resolve();
719
+ }
720
+ });
721
+ try {
722
+ server.closeAllConnections();
723
+ }
724
+ catch {
725
+ // A not-yet-listening server has no connections to close.
726
+ }
727
+ const previousBarrier = this.serverCloseBarrier ?? Promise.resolve();
728
+ const combinedBarrier = Promise.all([previousBarrier, closePromise]).then(() => undefined);
729
+ this.serverCloseBarrier = combinedBarrier;
730
+ void combinedBarrier.finally(() => {
731
+ if (this.serverCloseBarrier === combinedBarrier) {
732
+ this.serverCloseBarrier = undefined;
733
+ }
309
734
  });
310
735
  }
311
736
  close() {
312
- if (!this.server) {
737
+ this.#cancelServerStart();
738
+ const server = this.server;
739
+ if (!server)
313
740
  return;
314
- }
315
- this.server.closeAllConnections();
316
- this.server.close();
317
741
  this.server = undefined;
318
- this.serverInfo = undefined;
742
+ this.#beginServerClose(server);
319
743
  this.logger.log("server", "Server stopped");
320
744
  }
321
745
  // ===== Fetch Interceptor =====
322
746
  intercept(options) {
323
- if (this.interceptHandle?.active) {
324
- throw new SchmockError("Already intercepting. Call restore() first.", "ALREADY_INTERCEPTING");
747
+ // Ownership is a lease, not a lock: nested providers, separate roots, and
748
+ // a manual intercept() alongside an adapter each get their own registry
749
+ // slot with their own options, released independently. The owner symbol
750
+ // keeps them one mock for dispatch, so a single request reaches handle()
751
+ // once no matter how many leases this instance holds.
752
+ const lease = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options, () => this.createRequestAdmission(), this.interceptOwner);
753
+ const handle = {
754
+ restore: () => {
755
+ lease.restore();
756
+ if (this.interceptHandles.delete(handle)) {
757
+ this.logger.log("lifecycle", `Interception lease released (${this.interceptHandles.size} still held)`);
758
+ }
759
+ },
760
+ update: (nextOptions) => {
761
+ lease.update(nextOptions);
762
+ },
763
+ get active() {
764
+ return lease.active;
765
+ },
766
+ };
767
+ this.interceptHandles.add(handle);
768
+ this.logger.log("lifecycle", `Interception lease acquired (${this.interceptHandles.size} held)`);
769
+ return handle;
770
+ }
771
+ async handle(method, path, options, admission) {
772
+ const requestAdmission = admission ?? this.#captureRequestAdmission();
773
+ try {
774
+ return await this.#handleAdmittedRequest(method, path, options, requestAdmission);
775
+ }
776
+ finally {
777
+ this.#releaseRequestAdmission(requestAdmission);
325
778
  }
326
- this.interceptHandle = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options);
327
- return this.interceptHandle;
328
779
  }
329
- async handle(method, path, options) {
780
+ async #handleAdmittedRequest(method, requestedPath, options, admission) {
781
+ // Canonicalize before anything observes the path: a transport hands over an
782
+ // already-encoded `url.pathname` while a direct handle() caller may type
783
+ // literal unicode, and every lifecycle event, log line and 404 message must
784
+ // report the same spelling.
785
+ const path = canonicalizePath(requestedPath);
786
+ const requestGeneration = admission.requestGeneration;
787
+ const historyGeneration = admission.historyGeneration;
788
+ const requestPlugins = admission.plugins;
789
+ const requestRoutes = admission.routes;
790
+ const requestStaticRoutes = admission.staticRoutes;
791
+ const requestState = admission.state;
792
+ const namespace = admission.namespace;
793
+ const globalDelay = admission.globalDelay;
794
+ const maxHistorySize = admission.maxHistorySize;
795
+ const signal = options?.signal;
796
+ throwIfAborted(signal);
330
797
  const handleStart = performance.now();
331
798
  const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
332
- const reqQuery = options?.query || {};
333
- const reqHeaders = options?.headers || {};
799
+ const reqQuery = { ...(options?.query ?? {}) };
800
+ const reqHeaders = { ...(options?.headers ?? {}) };
801
+ const requestBody = options?.body;
334
802
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
335
- headers: reqHeaders,
803
+ headers: redactSensitiveHeaders(reqHeaders),
336
804
  query: reqQuery,
337
- bodyType: options?.body ? typeof options.body : "none",
805
+ // Presence, not truthiness: "", 0 and false are bodies too.
806
+ bodyType: options !== undefined && "body" in options && options.body !== undefined
807
+ ? typeof options.body
808
+ : "none",
338
809
  });
339
810
  this.logger.time(`request-${requestId}`);
340
- this.emit("request:start", {
341
- method,
342
- path,
343
- headers: reqHeaders,
344
- });
811
+ if (this.requestGeneration === requestGeneration) {
812
+ this.emit("request:start", {
813
+ method,
814
+ path,
815
+ headers: reqHeaders,
816
+ });
817
+ }
818
+ // Hoisted so the catch block can finalize a matched request the same way
819
+ // the success path does — same delay override, same history record.
820
+ let requestPath = path;
821
+ let matchedRoute;
822
+ let params = {};
345
823
  try {
346
824
  // Apply namespace if configured
347
- let requestPath = path;
348
- if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
349
- const namespace = this.globalConfig.namespace.startsWith("/")
350
- ? this.globalConfig.namespace
351
- : `/${this.globalConfig.namespace}`;
825
+ if (namespace && namespace !== "/") {
826
+ const normalizedNamespace = canonicalizePath(namespace.startsWith("/") ? namespace : `/${namespace}`);
352
827
  const pathToCheck = path.startsWith("/") ? path : `/${path}`;
353
828
  // Check if path starts with namespace
354
829
  // handle both "/api/users" (starts with /api) and "/api" (exact match)
355
830
  // but NOT "/apiv2" (prefix match but wrong segment)
356
- const isMatch = pathToCheck === namespace ||
357
- pathToCheck.startsWith(namespace.endsWith("/") ? namespace : `${namespace}/`);
831
+ const isMatch = pathToCheck === normalizedNamespace ||
832
+ pathToCheck.startsWith(normalizedNamespace.endsWith("/")
833
+ ? normalizedNamespace
834
+ : `${normalizedNamespace}/`);
358
835
  if (!isMatch) {
359
- this.logger.log("route", `[${requestId}] Path doesn't match namespace ${namespace}`);
360
- const error = new RouteNotFoundError(method, path);
361
- const response = {
362
- status: 404,
363
- body: { error: error.message, code: error.code },
364
- headers: {},
365
- };
366
- this.emit("request:end", {
836
+ this.logger.log("route", `[${requestId}] Path doesn't match namespace ${normalizedNamespace}`);
837
+ // A request outside the namespace is a route miss like any other, so
838
+ // it reports one instead of silently ending.
839
+ return this.#finalizeMiss({
367
840
  method,
368
841
  path,
369
- status: 404,
370
- duration: performance.now() - handleStart,
842
+ requestId,
843
+ handleStart,
844
+ requestGeneration,
371
845
  });
372
- this.logger.timeEnd(`request-${requestId}`);
373
- return response;
374
846
  }
375
847
  // Remove namespace prefix, ensuring we always start with /
376
- const stripped = pathToCheck.slice(namespace.length);
848
+ const stripped = pathToCheck.slice(normalizedNamespace.length);
377
849
  requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
378
850
  }
851
+ // One trailing-slash normalization for the whole request: route lookup
852
+ // and parameter extraction must see the identical string, or a request
853
+ // could match a route and then capture no parameters.
854
+ requestPath = normalizePath(requestPath);
379
855
  // Find matching route
380
- const matchedRoute = findRoute(method, requestPath, this.staticRoutes, this.routes);
856
+ matchedRoute = findRoute(method, requestPath, requestStaticRoutes, requestRoutes);
381
857
  if (!matchedRoute) {
382
858
  this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
383
- this.emit("request:notfound", { method, path: requestPath });
384
- const error = new RouteNotFoundError(method, path);
385
- const response = {
386
- status: 404,
387
- body: { error: error.message, code: error.code },
388
- headers: {},
389
- };
390
- this.emit("request:end", {
859
+ return this.#finalizeMiss({
391
860
  method,
392
- path: requestPath,
393
- status: 404,
394
- duration: performance.now() - handleStart,
861
+ path,
862
+ requestId,
863
+ handleStart,
864
+ requestGeneration,
395
865
  });
396
- this.logger.timeEnd(`request-${requestId}`);
397
- return response;
398
866
  }
399
867
  this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
400
868
  // Extract parameters from the matched route
401
- const params = extractParams(matchedRoute, requestPath);
402
- this.emit("request:match", {
403
- method,
404
- path: requestPath,
405
- routePath: matchedRoute.path,
406
- params,
407
- });
408
- // Generate initial response from route handler
409
- const context = {
410
- method,
411
- path: requestPath,
412
- params,
413
- query: reqQuery,
414
- headers: reqHeaders,
415
- body: options?.body,
416
- state: this.globalConfig.state || {},
417
- };
418
- let result;
419
- if (isGeneratorFunction(matchedRoute.generator)) {
420
- result = await matchedRoute.generator(context);
421
- }
422
- else {
423
- result = matchedRoute.generator;
869
+ params = extractParams(matchedRoute, requestPath);
870
+ if (this.requestGeneration === requestGeneration) {
871
+ this.emit("request:match", {
872
+ method,
873
+ // Every lifecycle event carries the ORIGINAL request path; the
874
+ // namespace-stripped route form is exposed as routePath.
875
+ path,
876
+ routePath: matchedRoute.path,
877
+ params,
878
+ });
424
879
  }
425
- // Build plugin context
880
+ throwIfAborted(signal);
881
+ // Build plugin context before route code so request guards can reject
882
+ // invalid or unauthorized requests without triggering side effects.
426
883
  let pluginContext = {
427
884
  path: requestPath,
428
885
  route: matchedRoute.config,
@@ -430,94 +887,238 @@ export class CallableMockInstance {
430
887
  params,
431
888
  query: reqQuery,
432
889
  headers: reqHeaders,
433
- body: options?.body,
890
+ body: requestBody,
434
891
  state: new Map(),
435
- routeState: this.globalConfig.state || {},
892
+ routeState: requestState,
893
+ signal,
436
894
  };
895
+ const preflightResult = await runPluginBeforeRequest(requestPlugins, pluginContext, this.logger, signal);
896
+ throwIfAborted(signal);
897
+ pluginContext = preflightResult.context;
898
+ if (preflightResult.requestShortCircuited === true) {
899
+ pluginContext = { ...pluginContext, requestShortCircuited: true };
900
+ }
901
+ let result = preflightResult.response;
902
+ let skipPostProcessing = preflightResult.recoveredFromError === true;
903
+ if (result === undefined) {
904
+ const context = {
905
+ method: pluginContext.method,
906
+ path: pluginContext.path,
907
+ params: pluginContext.params,
908
+ query: pluginContext.query,
909
+ headers: pluginContext.headers,
910
+ body: pluginContext.body,
911
+ state: pluginContext.routeState ?? requestState,
912
+ pluginState: pluginContext.state,
913
+ signal,
914
+ };
915
+ try {
916
+ if (isGeneratorFunction(matchedRoute.generator)) {
917
+ result = await awaitWithAbort(matchedRoute.generator(context), signal);
918
+ }
919
+ else {
920
+ result = matchedRoute.generator;
921
+ }
922
+ throwIfAborted(signal);
923
+ }
924
+ catch (error) {
925
+ throwIfAborted(signal);
926
+ const recovery = await recoverGeneratorError(requestPlugins, pluginContext, error, this.logger, signal);
927
+ throwIfAborted(signal);
928
+ pluginContext = recovery.context;
929
+ result = recovery.response;
930
+ skipPostProcessing = recovery.recoveredFromError === true;
931
+ }
932
+ }
437
933
  // Run plugin pipeline to transform the response
438
934
  try {
439
- const pipelineResult = await runPluginPipeline(this.plugins, pluginContext, result, this.logger);
440
- pluginContext = pipelineResult.context;
441
- result = pipelineResult.response;
935
+ if (skipPostProcessing) {
936
+ this.logger.log("pipeline", "Skipping response processors after error recovery");
937
+ }
938
+ else {
939
+ const pipelineResult = await runPluginPipeline(requestPlugins, pluginContext, result, this.logger, signal);
940
+ throwIfAborted(signal);
941
+ pluginContext = pipelineResult.context;
942
+ result = pipelineResult.response;
943
+ }
442
944
  }
443
945
  catch (error) {
444
946
  this.logger.log("error", `[${requestId}] Plugin pipeline error: ${errorMessage(error)}`);
445
947
  throw error;
446
948
  }
447
949
  // Parse and prepare response
448
- const response = parseResponse(result, matchedRoute.config);
449
- // Apply delay (route-level overrides global)
450
- await this.applyDelay(matchedRoute.config.delay);
451
- // Record request in history (FIFO-bounded when maxHistorySize is set)
452
- this.requestHistory.push({
950
+ const response = normalizeResponse(parseResponse(result, matchedRoute.config), method);
951
+ await this.#finalizeMatchedRequest({
453
952
  method,
454
- path: requestPath,
953
+ path,
954
+ requestPath,
455
955
  params,
456
- query: reqQuery,
457
- headers: reqHeaders,
458
- body: options?.body,
459
- timestamp: Date.now(),
460
- response: { status: response.status, body: response.body },
461
- });
462
- const maxHistorySize = this.globalConfig.maxHistorySize;
463
- if (typeof maxHistorySize === "number" &&
464
- maxHistorySize >= 0 &&
465
- this.requestHistory.length > maxHistorySize) {
466
- this.requestHistory.splice(0, this.requestHistory.length - maxHistorySize);
467
- }
468
- this.emit("request:end", {
469
- method,
470
- path: requestPath,
471
- status: response.status,
472
- duration: performance.now() - handleStart,
956
+ reqQuery,
957
+ reqHeaders,
958
+ requestBody,
959
+ response,
960
+ routeDelay: matchedRoute.config.delay,
961
+ globalDelay,
962
+ record: true,
963
+ signal,
964
+ requestGeneration,
965
+ historyGeneration,
966
+ maxHistorySize,
967
+ requestId,
968
+ handleStart,
473
969
  });
474
- // Log successful response
475
- this.logger.log("response", `[${requestId}] Sending response ${response.status}`, {
476
- status: response.status,
477
- headers: response.headers,
478
- bodyType: typeof response.body,
479
- });
480
- this.logger.timeEnd(`request-${requestId}`);
481
970
  return response;
482
971
  }
483
972
  catch (error) {
973
+ throwIfAborted(signal);
484
974
  this.logger.log("error", `[${requestId}] Error processing request: ${errorMessage(error)}`, error);
485
975
  // Return error response
486
- const errorResponse = {
976
+ const responseError = error instanceof Error ? error : new Error(errorMessage(error));
977
+ const errorResponse = markResponseException(normalizeResponse({
487
978
  status: 500,
488
979
  body: {
489
- error: errorMessage(error),
980
+ error: responseError.message,
490
981
  code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
491
982
  },
492
- headers: {},
493
- };
494
- // Apply delay even for error responses
495
- await this.applyDelay();
496
- this.emit("request:end", {
983
+ headers: { "content-type": "application/json" },
984
+ }, method), responseError);
985
+ // A request that matched a route did happen: it is finalized exactly like
986
+ // a successful one — its own delay override, and a history record.
987
+ await this.#finalizeMatchedRequest({
497
988
  method,
498
989
  path,
499
- status: 500,
500
- duration: performance.now() - handleStart,
990
+ requestPath,
991
+ params,
992
+ reqQuery,
993
+ reqHeaders,
994
+ requestBody,
995
+ response: errorResponse,
996
+ routeDelay: matchedRoute?.config.delay,
997
+ globalDelay,
998
+ record: matchedRoute !== undefined,
999
+ signal,
1000
+ requestGeneration,
1001
+ historyGeneration,
1002
+ maxHistorySize,
1003
+ requestId,
1004
+ handleStart,
501
1005
  });
502
- this.logger.log("error", `[${requestId}] Returning error response 500`);
503
- this.logger.timeEnd(`request-${requestId}`);
504
1006
  return errorResponse;
505
1007
  }
506
1008
  }
1009
+ /**
1010
+ * Finish a request that matched a route.
1011
+ *
1012
+ * Order matters: delay first (an abort during it must escape before anything
1013
+ * is committed), then the history record, then `request:end`, then the logs.
1014
+ */
1015
+ async #finalizeMatchedRequest(input) {
1016
+ const { response, maxHistorySize } = input;
1017
+ // Apply delay (route-level overrides global)
1018
+ await this.applyDelay(input.routeDelay, input.globalDelay, input.signal);
1019
+ throwIfAborted(input.signal);
1020
+ // Record request in history (FIFO-bounded when maxHistorySize is set)
1021
+ if (input.record &&
1022
+ this.requestGeneration === input.requestGeneration &&
1023
+ this.historyGeneration === input.historyGeneration &&
1024
+ maxHistorySize !== 0) {
1025
+ this.requestHistory.push({
1026
+ method: input.method,
1027
+ path: input.requestPath,
1028
+ params: { ...input.params },
1029
+ query: { ...input.reqQuery },
1030
+ headers: { ...input.reqHeaders },
1031
+ body: snapshotHistoryValue(input.requestBody),
1032
+ timestamp: Date.now(),
1033
+ response: {
1034
+ status: response.status,
1035
+ body: snapshotHistoryValue(response.body),
1036
+ },
1037
+ });
1038
+ // The constructor already rejected a limit that is not a non-negative
1039
+ // integer, so a plain comparison is enough here.
1040
+ if (maxHistorySize !== undefined &&
1041
+ this.requestHistory.length > maxHistorySize) {
1042
+ this.requestHistory.splice(0, this.requestHistory.length - maxHistorySize);
1043
+ }
1044
+ }
1045
+ if (this.requestGeneration === input.requestGeneration) {
1046
+ this.emit("request:end", {
1047
+ method: input.method,
1048
+ path: input.path,
1049
+ status: response.status,
1050
+ duration: performance.now() - input.handleStart,
1051
+ });
1052
+ }
1053
+ this.logger.log("response", `[${input.requestId}] Sending response ${response.status}`, {
1054
+ status: response.status,
1055
+ headers: redactSensitiveHeaders(response.headers),
1056
+ bodyType: typeof response.body,
1057
+ });
1058
+ this.logger.timeEnd(`request-${input.requestId}`);
1059
+ }
1060
+ /**
1061
+ * Finish a request that matched no route — an unknown path or one outside the
1062
+ * configured namespace. Misses stay delay-free and out of history: nothing
1063
+ * ran, so there is nothing to record.
1064
+ */
1065
+ #finalizeMiss(input) {
1066
+ if (this.requestGeneration === input.requestGeneration) {
1067
+ this.emit("request:notfound", {
1068
+ method: input.method,
1069
+ path: input.path,
1070
+ });
1071
+ }
1072
+ const error = new RouteNotFoundError(input.method, input.path);
1073
+ const response = markRouteNotFound(normalizeResponse({
1074
+ status: 404,
1075
+ body: { error: error.message, code: error.code },
1076
+ headers: { "content-type": "application/json" },
1077
+ }, input.method));
1078
+ if (this.requestGeneration === input.requestGeneration) {
1079
+ this.emit("request:end", {
1080
+ method: input.method,
1081
+ path: input.path,
1082
+ status: 404,
1083
+ duration: performance.now() - input.handleStart,
1084
+ });
1085
+ }
1086
+ this.logger.timeEnd(`request-${input.requestId}`);
1087
+ return response;
1088
+ }
507
1089
  /**
508
1090
  * Apply configured response delay
509
1091
  * Supports both fixed delays and random delays within a range
510
1092
  * @private
511
1093
  */
512
- async applyDelay(routeDelay) {
513
- const effectiveDelay = routeDelay ?? this.globalConfig.delay;
1094
+ async applyDelay(routeDelay, globalDelay, signal) {
1095
+ const effectiveDelay = routeDelay ?? globalDelay;
514
1096
  if (!effectiveDelay) {
1097
+ throwIfAborted(signal);
515
1098
  return;
516
1099
  }
517
- const ms = Array.isArray(effectiveDelay)
1100
+ const configuredMs = Array.isArray(effectiveDelay)
518
1101
  ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
519
1102
  effectiveDelay[0]
520
1103
  : effectiveDelay;
521
- await new Promise((resolve) => setTimeout(resolve, ms));
1104
+ const ms = Math.max(0, configuredMs);
1105
+ throwIfAborted(signal);
1106
+ await new Promise((resolve, reject) => {
1107
+ const finish = () => {
1108
+ signal?.removeEventListener("abort", abort);
1109
+ resolve();
1110
+ };
1111
+ const abort = () => {
1112
+ clearTimeout(timer);
1113
+ try {
1114
+ throwIfAborted(signal);
1115
+ }
1116
+ catch (error) {
1117
+ reject(error);
1118
+ }
1119
+ };
1120
+ const timer = setTimeout(finish, ms);
1121
+ signal?.addEventListener("abort", abort, { once: true });
1122
+ });
522
1123
  }
523
1124
  }