@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/dist/builder.js CHANGED
@@ -1,12 +1,130 @@
1
+ import { awaitWithAbort, throwIfAborted } from "./abort.js";
1
2
  import { isBinaryBody } from "./binary.js";
2
- import { normalizePath, toHttpMethod } from "./constants.js";
3
+ import { canonicalizePath, markResponseException, markRouteNotFound, normalizePath, toHttpMethod, } from "./constants.js";
3
4
  import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
4
- import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
5
+ import { collectBody, HttpIngressError, parseNodeHeaders, parseNodeQuery, writeRejectedSchmockResponse, writeSchmockResponse, } from "./http-helpers.js";
5
6
  import { createFetchInterceptor } from "./interceptor.js";
6
7
  import { parseRouteKey } from "./parser.js";
7
8
  import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
9
+ import { normalizeResponse } from "./response-normalizer.js";
8
10
  import { parseResponse } from "./response-parser.js";
9
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
+ }
10
128
  /**
11
129
  * Debug logger that respects debug mode configuration
12
130
  */
@@ -44,7 +162,6 @@ class DebugLogger {
44
162
  * @internal
45
163
  */
46
164
  export class CallableMockInstance {
47
- globalConfig;
48
165
  routes = [];
49
166
  staticRoutes = new Map();
50
167
  plugins = [];
@@ -52,12 +169,21 @@ export class CallableMockInstance {
52
169
  requestHistory = [];
53
170
  callableRef;
54
171
  server;
55
- serverInfo;
56
- 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;
57
179
  // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
58
180
  listeners = new Map();
59
181
  constructor(globalConfig = {}) {
60
- this.globalConfig = globalConfig;
182
+ assertValidHistoryLimit(globalConfig.maxHistorySize);
183
+ this.globalConfig = {
184
+ ...globalConfig,
185
+ state: globalConfig.state ?? {},
186
+ };
61
187
  this.logger = new DebugLogger(globalConfig.debug || false);
62
188
  if (globalConfig.debug) {
63
189
  this.logger.log("config", "Debug mode enabled");
@@ -140,6 +266,82 @@ export class CallableMockInstance {
140
266
  this.callableRef = ref;
141
267
  }
142
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
+ }
143
345
  this.plugins.push(plugin);
144
346
  this.logger.log("plugin", `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`, {
145
347
  name: plugin.name,
@@ -147,52 +349,79 @@ export class CallableMockInstance {
147
349
  hasProcess: typeof plugin.process === "function",
148
350
  hasOnError: typeof plugin.onError === "function",
149
351
  });
150
- if (plugin.install && this.callableRef) {
151
- plugin.install(this.callableRef);
152
- }
153
352
  return this;
154
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
+ }
155
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
+ }
156
389
  /**
157
- * FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
158
- * history by mutating nested body/response objects.
159
- * Falls back to a shallow spread for bodies that are not structuredClone-able
160
- * (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.
161
396
  */
162
- cloneRecord(r) {
163
- try {
164
- return structuredClone(r);
165
- }
166
- catch {
167
- return {
168
- ...r,
169
- response: { ...r.response },
170
- };
171
- }
397
+ #historyMatcher(method, path) {
398
+ const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
399
+ return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
172
400
  }
173
401
  history(method, path) {
174
402
  if (method || path) {
175
403
  return this.requestHistory
176
- .filter((r) => (!method || r.method === method) && (!path || r.path === path))
404
+ .filter(this.#historyMatcher(method, path))
177
405
  .map((r) => this.cloneRecord(r));
178
406
  }
179
407
  return this.requestHistory.map((r) => this.cloneRecord(r));
180
408
  }
181
409
  called(method, path) {
182
410
  if (method || path) {
183
- return this.requestHistory.some((r) => (!method || r.method === method) && (!path || r.path === path));
411
+ return this.requestHistory.some(this.#historyMatcher(method, path));
184
412
  }
185
413
  return this.requestHistory.length > 0;
186
414
  }
187
415
  callCount(method, path) {
188
416
  if (method || path) {
189
- 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;
190
419
  }
191
420
  return this.requestHistory.length;
192
421
  }
193
422
  lastRequest(method, path) {
194
423
  if (method || path) {
195
- const filtered = this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
424
+ const filtered = this.requestHistory.filter(this.#historyMatcher(method, path));
196
425
  const last = filtered[filtered.length - 1];
197
426
  // FIX 2.3: return a deep clone so callers cannot corrupt internal history
198
427
  return last ? this.cloneRecord(last) : undefined;
@@ -228,192 +457,427 @@ export class CallableMockInstance {
228
457
  }
229
458
  emit(event, data) {
230
459
  const set = this.listeners.get(event);
231
- if (set) {
232
- for (const listener of set) {
233
- 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)}`);
234
481
  }
235
482
  }
236
483
  }
237
484
  // ===== Reset / Lifecycle =====
238
485
  reset() {
239
- this.interceptHandle?.restore();
240
- this.interceptHandle = null;
486
+ const retiredGeneration = this.requestGeneration;
487
+ this.requestGeneration = { activeAdmissions: 0 };
488
+ this.historyGeneration = Symbol("schmock.history.generation");
241
489
  this.close();
490
+ const installedPlugins = this.plugins;
491
+ this.plugins = [];
492
+ this.#retireRequestGeneration(retiredGeneration, installedPlugins);
242
493
  this.routes = [];
243
494
  this.staticRoutes.clear();
244
- this.plugins = [];
245
495
  this.requestHistory = [];
246
496
  this.listeners.clear();
247
- // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
248
- // reference — this avoids mutating the external state object passed by the user
249
- if (this.globalConfig.state) {
250
- this.globalConfig.state = {};
251
- }
497
+ this.globalConfig.state = {};
252
498
  this.logger.log("lifecycle", "Mock fully reset");
253
499
  }
254
500
  resetHistory() {
501
+ this.historyGeneration = Symbol("schmock.history.generation");
255
502
  this.requestHistory = [];
256
503
  this.logger.log("lifecycle", "Request history cleared");
257
504
  }
258
505
  resetState() {
259
- // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
260
- // reference — this avoids mutating the external state object passed by the user
261
- if (this.globalConfig.state) {
262
- this.globalConfig.state = {};
263
- }
506
+ this.globalConfig.state = {};
264
507
  this.logger.log("lifecycle", "State cleared");
265
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
+ }
266
552
  // ===== Standalone Server =====
267
553
  listen(port = 0, hostname = "127.0.0.1") {
268
- if (this.server) {
554
+ if (this.server || this.pendingServerStart) {
269
555
  throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
270
556
  }
271
- // Lazy-load node:http so browser bundles never pull it in. See issue #395.
272
- 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;
273
588
  }
274
- #startHttpServer(createServer, port, hostname) {
589
+ #startHttpServer(operation, createServer) {
275
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";
276
597
  const handleRequest = async () => {
277
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
278
- const method = toHttpMethod(req.method ?? "GET");
279
- const path = url.pathname;
280
- const headers = parseNodeHeaders(req);
281
- const query = parseNodeQuery(url);
282
- const body = await collectBody(req, headers);
283
- const schmockResponse = await this.handle(method, path, {
284
- headers,
285
- body,
286
- query,
287
- });
288
- 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
+ }
289
619
  };
290
620
  handleRequest().catch((error) => {
291
- if (!res.headersSent) {
292
- 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();
293
662
  }
294
- res.end(JSON.stringify({
295
- error: error instanceof Error ? error.message : "Internal Server Error",
296
- code: "SERVER_ERROR",
297
- }));
298
663
  });
299
664
  });
300
- this.server = httpServer;
301
- return new Promise((resolve, reject) => {
302
- const handleStartupError = (error) => {
303
- if (this.server === httpServer) {
304
- this.server = undefined;
305
- this.serverInfo = undefined;
306
- }
307
- reject(error);
308
- };
309
- httpServer.once("error", handleStartupError);
310
- 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, () => {
311
672
  httpServer.off("error", handleStartupError);
673
+ if (!this.#ownsServerStart(operation)) {
674
+ this.#beginServerClose(httpServer);
675
+ return;
676
+ }
312
677
  const addr = httpServer.address();
313
- const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
314
- this.serverInfo = { port: actualPort, hostname };
315
- this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
316
- 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);
317
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
+ }
318
734
  });
319
735
  }
320
736
  close() {
321
- if (!this.server) {
737
+ this.#cancelServerStart();
738
+ const server = this.server;
739
+ if (!server)
322
740
  return;
323
- }
324
- this.server.closeAllConnections();
325
- this.server.close();
326
741
  this.server = undefined;
327
- this.serverInfo = undefined;
742
+ this.#beginServerClose(server);
328
743
  this.logger.log("server", "Server stopped");
329
744
  }
330
745
  // ===== Fetch Interceptor =====
331
746
  intercept(options) {
332
- if (this.interceptHandle?.active) {
333
- 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);
334
778
  }
335
- this.interceptHandle = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options);
336
- return this.interceptHandle;
337
779
  }
338
- 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);
339
797
  const handleStart = performance.now();
340
798
  const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
341
- const reqQuery = options?.query || {};
342
- const reqHeaders = options?.headers || {};
799
+ const reqQuery = { ...(options?.query ?? {}) };
800
+ const reqHeaders = { ...(options?.headers ?? {}) };
801
+ const requestBody = options?.body;
343
802
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
344
- headers: reqHeaders,
803
+ headers: redactSensitiveHeaders(reqHeaders),
345
804
  query: reqQuery,
346
- 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",
347
809
  });
348
810
  this.logger.time(`request-${requestId}`);
349
- this.emit("request:start", {
350
- method,
351
- path,
352
- headers: reqHeaders,
353
- });
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 = {};
354
823
  try {
355
824
  // Apply namespace if configured
356
- let requestPath = path;
357
- if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
358
- const namespace = this.globalConfig.namespace.startsWith("/")
359
- ? this.globalConfig.namespace
360
- : `/${this.globalConfig.namespace}`;
825
+ if (namespace && namespace !== "/") {
826
+ const normalizedNamespace = canonicalizePath(namespace.startsWith("/") ? namespace : `/${namespace}`);
361
827
  const pathToCheck = path.startsWith("/") ? path : `/${path}`;
362
828
  // Check if path starts with namespace
363
829
  // handle both "/api/users" (starts with /api) and "/api" (exact match)
364
830
  // but NOT "/apiv2" (prefix match but wrong segment)
365
- const isMatch = pathToCheck === namespace ||
366
- pathToCheck.startsWith(namespace.endsWith("/") ? namespace : `${namespace}/`);
831
+ const isMatch = pathToCheck === normalizedNamespace ||
832
+ pathToCheck.startsWith(normalizedNamespace.endsWith("/")
833
+ ? normalizedNamespace
834
+ : `${normalizedNamespace}/`);
367
835
  if (!isMatch) {
368
- this.logger.log("route", `[${requestId}] Path doesn't match namespace ${namespace}`);
369
- const error = new RouteNotFoundError(method, path);
370
- const response = {
371
- status: 404,
372
- body: { error: error.message, code: error.code },
373
- headers: { "content-type": "application/json" },
374
- };
375
- 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({
376
840
  method,
377
841
  path,
378
- status: 404,
379
- duration: performance.now() - handleStart,
842
+ requestId,
843
+ handleStart,
844
+ requestGeneration,
380
845
  });
381
- this.logger.timeEnd(`request-${requestId}`);
382
- return response;
383
846
  }
384
847
  // Remove namespace prefix, ensuring we always start with /
385
- const stripped = pathToCheck.slice(namespace.length);
848
+ const stripped = pathToCheck.slice(normalizedNamespace.length);
386
849
  requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
387
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);
388
855
  // Find matching route
389
- const matchedRoute = findRoute(method, requestPath, this.staticRoutes, this.routes);
856
+ matchedRoute = findRoute(method, requestPath, requestStaticRoutes, requestRoutes);
390
857
  if (!matchedRoute) {
391
858
  this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
392
- this.emit("request:notfound", { method, path: requestPath });
393
- const error = new RouteNotFoundError(method, path);
394
- const response = {
395
- status: 404,
396
- body: { error: error.message, code: error.code },
397
- headers: { "content-type": "application/json" },
398
- };
399
- this.emit("request:end", {
859
+ return this.#finalizeMiss({
400
860
  method,
401
- path: requestPath,
402
- status: 404,
403
- duration: performance.now() - handleStart,
861
+ path,
862
+ requestId,
863
+ handleStart,
864
+ requestGeneration,
404
865
  });
405
- this.logger.timeEnd(`request-${requestId}`);
406
- return response;
407
866
  }
408
867
  this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
409
868
  // Extract parameters from the matched route
410
- const params = extractParams(matchedRoute, requestPath);
411
- this.emit("request:match", {
412
- method,
413
- path: requestPath,
414
- routePath: matchedRoute.path,
415
- params,
416
- });
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
+ });
879
+ }
880
+ throwIfAborted(signal);
417
881
  // Build plugin context before route code so request guards can reject
418
882
  // invalid or unauthorized requests without triggering side effects.
419
883
  let pluginContext = {
@@ -423,11 +887,13 @@ export class CallableMockInstance {
423
887
  params,
424
888
  query: reqQuery,
425
889
  headers: reqHeaders,
426
- body: options?.body,
890
+ body: requestBody,
427
891
  state: new Map(),
428
- routeState: this.globalConfig.state || {},
892
+ routeState: requestState,
893
+ signal,
429
894
  };
430
- const preflightResult = await runPluginBeforeRequest(this.plugins, pluginContext, this.logger);
895
+ const preflightResult = await runPluginBeforeRequest(requestPlugins, pluginContext, this.logger, signal);
896
+ throwIfAborted(signal);
431
897
  pluginContext = preflightResult.context;
432
898
  if (preflightResult.requestShortCircuited === true) {
433
899
  pluginContext = { ...pluginContext, requestShortCircuited: true };
@@ -442,18 +908,23 @@ export class CallableMockInstance {
442
908
  query: pluginContext.query,
443
909
  headers: pluginContext.headers,
444
910
  body: pluginContext.body,
445
- state: pluginContext.routeState ?? this.globalConfig.state ?? {},
911
+ state: pluginContext.routeState ?? requestState,
912
+ pluginState: pluginContext.state,
913
+ signal,
446
914
  };
447
915
  try {
448
916
  if (isGeneratorFunction(matchedRoute.generator)) {
449
- result = await matchedRoute.generator(context);
917
+ result = await awaitWithAbort(matchedRoute.generator(context), signal);
450
918
  }
451
919
  else {
452
920
  result = matchedRoute.generator;
453
921
  }
922
+ throwIfAborted(signal);
454
923
  }
455
924
  catch (error) {
456
- const recovery = await recoverGeneratorError(this.plugins, pluginContext, error, this.logger);
925
+ throwIfAborted(signal);
926
+ const recovery = await recoverGeneratorError(requestPlugins, pluginContext, error, this.logger, signal);
927
+ throwIfAborted(signal);
457
928
  pluginContext = recovery.context;
458
929
  result = recovery.response;
459
930
  skipPostProcessing = recovery.recoveredFromError === true;
@@ -465,7 +936,8 @@ export class CallableMockInstance {
465
936
  this.logger.log("pipeline", "Skipping response processors after error recovery");
466
937
  }
467
938
  else {
468
- const pipelineResult = await runPluginPipeline(this.plugins, pluginContext, result, this.logger);
939
+ const pipelineResult = await runPluginPipeline(requestPlugins, pluginContext, result, this.logger, signal);
940
+ throwIfAborted(signal);
469
941
  pluginContext = pipelineResult.context;
470
942
  result = pipelineResult.response;
471
943
  }
@@ -475,73 +947,154 @@ export class CallableMockInstance {
475
947
  throw error;
476
948
  }
477
949
  // Parse and prepare response
478
- const response = parseResponse(result, matchedRoute.config);
479
- // Apply delay (route-level overrides global)
480
- await this.applyDelay(matchedRoute.config.delay);
481
- // Record request in history (FIFO-bounded when maxHistorySize is set)
482
- this.requestHistory.push({
950
+ const response = normalizeResponse(parseResponse(result, matchedRoute.config), method);
951
+ await this.#finalizeMatchedRequest({
483
952
  method,
484
- path: requestPath,
953
+ path,
954
+ requestPath,
485
955
  params,
486
- query: reqQuery,
487
- headers: reqHeaders,
488
- body: options?.body,
489
- timestamp: Date.now(),
490
- response: { status: response.status, body: response.body },
491
- });
492
- const maxHistorySize = this.globalConfig.maxHistorySize;
493
- if (typeof maxHistorySize === "number" &&
494
- maxHistorySize >= 0 &&
495
- this.requestHistory.length > maxHistorySize) {
496
- this.requestHistory.splice(0, this.requestHistory.length - maxHistorySize);
497
- }
498
- this.emit("request:end", {
499
- method,
500
- path: requestPath,
501
- status: response.status,
502
- 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,
503
969
  });
504
- // Log successful response
505
- this.logger.log("response", `[${requestId}] Sending response ${response.status}`, {
506
- status: response.status,
507
- headers: response.headers,
508
- bodyType: typeof response.body,
509
- });
510
- this.logger.timeEnd(`request-${requestId}`);
511
970
  return response;
512
971
  }
513
972
  catch (error) {
973
+ throwIfAborted(signal);
514
974
  this.logger.log("error", `[${requestId}] Error processing request: ${errorMessage(error)}`, error);
515
975
  // Return error response
516
- const errorResponse = {
976
+ const responseError = error instanceof Error ? error : new Error(errorMessage(error));
977
+ const errorResponse = markResponseException(normalizeResponse({
517
978
  status: 500,
518
979
  body: {
519
- error: errorMessage(error),
980
+ error: responseError.message,
520
981
  code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
521
982
  },
522
983
  headers: { "content-type": "application/json" },
523
- };
524
- // Apply delay even for error responses
525
- await this.applyDelay();
526
- this.emit("request:end", {
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({
527
988
  method,
528
989
  path,
529
- status: 500,
530
- 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,
531
1005
  });
532
- this.logger.log("error", `[${requestId}] Returning error response 500`);
533
- this.logger.timeEnd(`request-${requestId}`);
534
1006
  return errorResponse;
535
1007
  }
536
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
+ }
537
1089
  /**
538
1090
  * Apply configured response delay
539
1091
  * Supports both fixed delays and random delays within a range
540
1092
  * @private
541
1093
  */
542
- async applyDelay(routeDelay) {
543
- const effectiveDelay = routeDelay ?? this.globalConfig.delay;
1094
+ async applyDelay(routeDelay, globalDelay, signal) {
1095
+ const effectiveDelay = routeDelay ?? globalDelay;
544
1096
  if (!effectiveDelay) {
1097
+ throwIfAborted(signal);
545
1098
  return;
546
1099
  }
547
1100
  const configuredMs = Array.isArray(effectiveDelay)
@@ -549,6 +1102,23 @@ export class CallableMockInstance {
549
1102
  effectiveDelay[0]
550
1103
  : effectiveDelay;
551
1104
  const ms = Math.max(0, configuredMs);
552
- await new Promise((resolve) => setTimeout(resolve, ms));
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
+ });
553
1123
  }
554
1124
  }