@schmock/core 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +129 -0
  2. package/dist/abort.d.ts +11 -1
  3. package/dist/abort.js +13 -2
  4. package/dist/adapter.d.ts +21 -0
  5. package/dist/adapter.js +19 -0
  6. package/dist/admission.d.ts +21 -0
  7. package/dist/admission.js +39 -0
  8. package/dist/binary.d.ts +0 -1
  9. package/dist/builder.d.ts +16 -32
  10. package/dist/builder.js +425 -904
  11. package/dist/constants.d.ts +33 -2
  12. package/dist/constants.js +73 -1
  13. package/dist/debug-logger.d.ts +10 -0
  14. package/dist/debug-logger.js +31 -0
  15. package/dist/delay.d.ts +12 -0
  16. package/dist/delay.js +37 -0
  17. package/dist/errors.d.ts +13 -2
  18. package/dist/errors.js +21 -2
  19. package/dist/events.d.ts +17 -0
  20. package/dist/events.js +58 -0
  21. package/dist/generations.d.ts +48 -0
  22. package/dist/generations.js +82 -0
  23. package/dist/headers.d.ts +27 -0
  24. package/dist/headers.js +57 -0
  25. package/dist/helpers.d.ts +9 -10
  26. package/dist/helpers.js +4 -1
  27. package/dist/history.d.ts +56 -0
  28. package/dist/history.js +151 -0
  29. package/dist/http-helpers.d.ts +110 -5
  30. package/dist/http-helpers.js +328 -46
  31. package/dist/index.d.ts +295 -31
  32. package/dist/index.js +17 -9
  33. package/dist/interceptor.d.ts +40 -10
  34. package/dist/interceptor.js +412 -178
  35. package/dist/node-server.d.ts +27 -0
  36. package/dist/node-server.js +166 -0
  37. package/dist/parser.d.ts +0 -1
  38. package/dist/parser.js +145 -22
  39. package/dist/plugin-hooks.d.ts +57 -0
  40. package/dist/plugin-hooks.js +276 -0
  41. package/dist/plugin-pipeline.d.ts +0 -1
  42. package/dist/plugin-pipeline.js +25 -4
  43. package/dist/response-normalizer.d.ts +36 -1
  44. package/dist/response-normalizer.js +102 -0
  45. package/dist/response-parser.d.ts +19 -1
  46. package/dist/response-parser.js +77 -19
  47. package/dist/route-matcher.d.ts +0 -1
  48. package/dist/route-table.d.ts +64 -0
  49. package/dist/route-table.js +220 -0
  50. package/dist/snapshot.d.ts +14 -0
  51. package/dist/snapshot.js +98 -0
  52. package/dist/types.d.ts +34 -1
  53. package/package.json +8 -3
  54. package/dist/abort.d.ts.map +0 -1
  55. package/dist/binary.d.ts.map +0 -1
  56. package/dist/builder.d.ts.map +0 -1
  57. package/dist/constants.d.ts.map +0 -1
  58. package/dist/errors.d.ts.map +0 -1
  59. package/dist/helpers.d.ts.map +0 -1
  60. package/dist/http-helpers.d.ts.map +0 -1
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/interceptor.d.ts.map +0 -1
  63. package/dist/parser.d.ts.map +0 -1
  64. package/dist/plugin-pipeline.d.ts.map +0 -1
  65. package/dist/response-normalizer.d.ts.map +0 -1
  66. package/dist/response-parser.d.ts.map +0 -1
  67. package/dist/route-matcher.d.ts.map +0 -1
  68. package/dist/types.d.ts.map +0 -1
package/dist/builder.js CHANGED
@@ -1,190 +1,53 @@
1
1
  import { awaitWithAbort, throwIfAborted } from "./abort.js";
2
- import { isBinaryBody } from "./binary.js";
3
- import { canonicalizePath, markResponseException, markRouteNotFound, normalizePath, toHttpMethod, } from "./constants.js";
4
- import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
5
- import { collectBody, HttpIngressError, parseNodeHeaders, parseNodeQuery, writeRejectedSchmockResponse, writeSchmockResponse, } from "./http-helpers.js";
6
- import { createFetchInterceptor } from "./interceptor.js";
7
- import { parseRouteKey } from "./parser.js";
2
+ import { canonicalizePath, markResponseException, markRouteNotFound, matchPathPrefix, normalizePath, parsePathPrefix, } from "./constants.js";
3
+ import { DebugLogger } from "./debug-logger.js";
4
+ import { applyResponseDelay } from "./delay.js";
5
+ import { errorMessage, RouteNotFoundError, SchmockError } from "./errors.js";
6
+ import { MockEvents } from "./events.js";
7
+ import { RequestGenerations } from "./generations.js";
8
+ import { redactHeaders } from "./headers.js";
9
+ import { RequestHistory } from "./history.js";
10
+ import { createFetchLease, NORMALIZED_ADMISSION_KEY, } from "./interceptor.js";
11
+ import { NodeServerController } from "./node-server.js";
12
+ import { assertValidPlugin, hasExchangeObserver, runExchangeHooks, runInstallHook, runUninstallHooks, } from "./plugin-hooks.js";
8
13
  import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
9
- import { normalizeResponse } from "./response-normalizer.js";
14
+ import { buildJsonErrorResponse, normalizeResponse, } from "./response-normalizer.js";
10
15
  import { parseResponse } from "./response-parser.js";
11
16
  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
- }
128
- /**
129
- * Debug logger that respects debug mode configuration
130
- */
131
- class DebugLogger {
132
- enabled;
133
- constructor(enabled = false) {
134
- this.enabled = enabled;
135
- }
136
- log(category, message, data) {
137
- if (!this.enabled)
138
- return;
139
- const timestamp = new Date().toISOString();
140
- const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
141
- if (data) {
142
- console.log(`${prefix} ${message}`, data);
143
- }
144
- else {
145
- console.log(`${prefix} ${message}`);
146
- }
147
- }
148
- time(label) {
149
- if (!this.enabled)
150
- return;
151
- console.time(`[SCHMOCK] ${label}`);
152
- }
153
- timeEnd(label) {
154
- if (!this.enabled)
155
- return;
156
- console.timeEnd(`[SCHMOCK] ${label}`);
157
- }
158
- }
17
+ import { copyRouteConfig, copyStaticData, RouteTable } from "./route-table.js";
18
+ /** `request:end` status for a request its caller cancelled. */
19
+ const ABORTED_REQUEST_STATUS = 499;
159
20
  /**
160
21
  * Callable mock instance that implements the new API.
161
22
  *
162
23
  * @internal
163
24
  */
164
25
  export class CallableMockInstance {
165
- routes = [];
166
- staticRoutes = new Map();
26
+ routeTable = new RouteTable();
167
27
  plugins = [];
168
28
  logger;
169
- requestHistory = [];
29
+ requestHistory;
30
+ nodeServer;
170
31
  callableRef;
171
- server;
172
- pendingServerStart;
173
- serverCloseBarrier;
174
32
  interceptHandles = new Set();
175
- requestGeneration = { activeAdmissions: 0 };
176
- historyGeneration = Symbol("schmock.history.generation");
33
+ generations = new RequestGenerations((plugins) => this.uninstallPlugins(plugins));
177
34
  interceptOwner = Symbol("schmock.intercept.owner");
178
35
  globalConfig;
179
- // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
180
- listeners = new Map();
36
+ events;
37
+ namespaceCache;
181
38
  constructor(globalConfig = {}) {
182
- assertValidHistoryLimit(globalConfig.maxHistorySize);
39
+ // First: an invalid maxHistorySize throws before anything else is built.
40
+ this.requestHistory = new RequestHistory(globalConfig.maxHistorySize);
183
41
  this.globalConfig = {
184
42
  ...globalConfig,
185
43
  state: globalConfig.state ?? {},
186
44
  };
187
45
  this.logger = new DebugLogger(globalConfig.debug || false);
46
+ this.events = new MockEvents(this.logger);
47
+ this.nodeServer = new NodeServerController({
48
+ admitRequest: () => this.createRequestAdmission(),
49
+ logger: this.logger,
50
+ });
188
51
  if (globalConfig.debug) {
189
52
  this.logger.log("config", "Debug mode enabled");
190
53
  }
@@ -196,153 +59,44 @@ export class CallableMockInstance {
196
59
  }
197
60
  // Method for defining routes (called when instance is invoked)
198
61
  defineRoute(route, generator, config) {
199
- // FIX 1.2: shallow-clone the caller's config so mutations below stay private
200
- const routeConfig = { ...config };
201
- // Auto-detect contentType if not provided
202
- if (!routeConfig.contentType) {
203
- if (typeof generator === "function") {
204
- // Default to JSON for function generators
205
- routeConfig.contentType = "application/json";
206
- }
207
- else if (typeof generator === "string" ||
208
- typeof generator === "number" ||
209
- typeof generator === "boolean") {
210
- // Default to plain text for primitives
211
- routeConfig.contentType = "text/plain";
212
- }
213
- else if (isBinaryBody(generator)) {
214
- // Default to octet-stream for browser and Node binary values
215
- routeConfig.contentType = "application/octet-stream";
216
- }
217
- else {
218
- // Default to JSON for objects/arrays
219
- routeConfig.contentType = "application/json";
220
- }
221
- }
222
- // Validate generator matches contentType if it's static data
223
- if (typeof generator !== "function" &&
224
- routeConfig.contentType === "application/json") {
225
- try {
226
- JSON.stringify(generator);
227
- }
228
- catch (_error) {
229
- throw new RouteDefinitionError(route, "Generator data is not valid JSON but contentType is application/json");
230
- }
231
- }
232
- // Parse the route key to create pattern and extract parameters
233
- const parsed = parseRouteKey(route);
234
- // FIX 2.2: normalize paths before duplicate check so /users and /users/ are
235
- // treated as the same route (consistent with the static-route Map key below)
236
- const normalizedParsedPath = normalizePath(parsed.path);
237
- const existing = this.routes.find((r) => r.method === parsed.method &&
238
- normalizePath(r.path) === normalizedParsedPath);
239
- if (existing) {
240
- this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
241
- return this;
242
- }
243
- // Compile the route
244
- const compiledRoute = {
245
- pattern: parsed.pattern,
246
- params: parsed.params,
247
- method: parsed.method,
248
- path: parsed.path,
249
- generator,
250
- config: routeConfig,
251
- };
252
- this.routes.push(compiledRoute);
253
- // Store static routes (no params) in Map for O(1) lookup
254
- if (parsed.params.length === 0) {
255
- const key = `${parsed.method} ${normalizePath(parsed.path)}`;
256
- this.staticRoutes.set(key, compiledRoute);
257
- }
258
- this.logger.log("route", `Route defined: ${route}`, {
259
- contentType: routeConfig.contentType,
260
- generatorType: typeof generator,
261
- hasParams: parsed.params.length > 0,
262
- });
62
+ this.routeTable.define({ route, generator, config, logger: this.logger });
263
63
  return this;
264
64
  }
265
65
  setCallableRef(ref) {
266
66
  this.callableRef = ref;
267
67
  }
268
68
  pipe(plugin) {
69
+ assertValidPlugin(plugin);
70
+ if (this.plugins.includes(plugin)) {
71
+ // Piping one object twice used to run install() twice and process()
72
+ // twice per request. It is a no-op rather than an error because the
73
+ // pattern is common and was silently accepted: `.pipe(p)` chained after
74
+ // every route definition, or a beforeEach that pipes without a reset.
75
+ // Distinct objects that share a name (two openapi() specs) still stack.
76
+ this.logger.log("warning", `Plugin ${plugin.name} is already piped into this mock — ignored`);
77
+ return this;
78
+ }
79
+ this.generations.uninstallBeforeReinstall(plugin);
269
80
  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
- });
81
+ // Routes the hook registers before it fails are rolled back with it.
82
+ const checkpoint = this.routeTable.checkpoint();
326
83
  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
- }
84
+ runInstallHook({
85
+ plugin,
86
+ reads: this,
87
+ registerRoute: (route, generator, config) => {
88
+ this.defineRoute(route, generator, config);
89
+ },
90
+ logger: this.logger,
91
+ });
335
92
  }
336
93
  catch (error) {
337
- this.routes = previousRoutes;
338
- this.staticRoutes = previousStaticRoutes;
94
+ this.routeTable.rollback(checkpoint);
339
95
  throw error;
340
96
  }
341
- finally {
342
- installActive = false;
343
- }
344
97
  }
345
- this.plugins.push(plugin);
98
+ // Replaced, never pushed into: in-flight admissions hold the old array.
99
+ this.plugins = [...this.plugins, plugin];
346
100
  this.logger.log("plugin", `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`, {
347
101
  name: plugin.name,
348
102
  version: plugin.version,
@@ -352,154 +106,58 @@ export class CallableMockInstance {
352
106
  return this;
353
107
  }
354
108
  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
- }
109
+ if (!this.callableRef)
110
+ return;
111
+ runUninstallHooks({ plugins, reads: this, logger: this.logger });
372
112
  }
373
113
  // ===== 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
- }
389
- /**
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.
396
- */
397
- #historyMatcher(method, path) {
398
- const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
399
- return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
400
- }
401
114
  history(method, path) {
402
- if (method || path) {
403
- return this.requestHistory
404
- .filter(this.#historyMatcher(method, path))
405
- .map((r) => this.cloneRecord(r));
406
- }
407
- return this.requestHistory.map((r) => this.cloneRecord(r));
115
+ return this.requestHistory.history(method, path);
408
116
  }
409
117
  called(method, path) {
410
- if (method || path) {
411
- return this.requestHistory.some(this.#historyMatcher(method, path));
412
- }
413
- return this.requestHistory.length > 0;
118
+ return this.requestHistory.called(method, path);
414
119
  }
415
120
  callCount(method, path) {
416
- if (method || path) {
417
- return this.requestHistory.filter(this.#historyMatcher(method, path))
418
- .length;
419
- }
420
- return this.requestHistory.length;
121
+ return this.requestHistory.callCount(method, path);
421
122
  }
422
123
  lastRequest(method, path) {
423
- if (method || path) {
424
- const filtered = this.requestHistory.filter(this.#historyMatcher(method, path));
425
- const last = filtered[filtered.length - 1];
426
- // FIX 2.3: return a deep clone so callers cannot corrupt internal history
427
- return last ? this.cloneRecord(last) : undefined;
428
- }
429
- const last = this.requestHistory[this.requestHistory.length - 1];
430
- // FIX 2.3: return a deep clone so callers cannot corrupt internal history
431
- return last ? this.cloneRecord(last) : undefined;
124
+ return this.requestHistory.lastRequest(method, path);
432
125
  }
433
126
  // ===== Introspection =====
434
127
  getRoutes() {
435
- return this.routes.map((r) => ({
436
- method: r.method,
437
- path: r.path,
438
- hasParams: r.params.length > 0,
439
- }));
128
+ return this.routeTable.list();
440
129
  }
441
130
  getState() {
442
131
  return { ...(this.globalConfig.state || {}) };
443
132
  }
444
133
  // ===== Lifecycle Events =====
445
134
  on(event, listener) {
446
- let set = this.listeners.get(event);
447
- if (!set) {
448
- set = new Set();
449
- this.listeners.set(event, set);
450
- }
451
- set.add(listener);
135
+ this.events.on(event, listener);
452
136
  return this;
453
137
  }
454
138
  off(event, listener) {
455
- this.listeners.get(event)?.delete(listener);
139
+ this.events.off(event, listener);
456
140
  return this;
457
141
  }
458
- emit(event, data) {
459
- const set = this.listeners.get(event);
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)}`);
481
- }
482
- }
483
- }
484
142
  // ===== Reset / Lifecycle =====
485
143
  reset() {
486
- const retiredGeneration = this.requestGeneration;
487
- this.requestGeneration = { activeAdmissions: 0 };
488
- this.historyGeneration = Symbol("schmock.history.generation");
144
+ const retiredGeneration = this.generations.advance();
145
+ this.requestHistory.startGeneration();
489
146
  this.close();
490
147
  const installedPlugins = this.plugins;
491
148
  this.plugins = [];
492
- this.#retireRequestGeneration(retiredGeneration, installedPlugins);
493
- this.routes = [];
494
- this.staticRoutes.clear();
495
- this.requestHistory = [];
496
- this.listeners.clear();
149
+ this.generations.retire(retiredGeneration, installedPlugins);
150
+ // Replaced, never cleared in place: in-flight admissions still route with
151
+ // the old containers.
152
+ this.routeTable.clear();
153
+ this.requestHistory.clear();
154
+ this.events.clear();
497
155
  this.globalConfig.state = {};
498
156
  this.logger.log("lifecycle", "Mock fully reset");
499
157
  }
500
158
  resetHistory() {
501
- this.historyGeneration = Symbol("schmock.history.generation");
502
- this.requestHistory = [];
159
+ this.requestHistory.startGeneration();
160
+ this.requestHistory.clear();
503
161
  this.logger.log("lifecycle", "Request history cleared");
504
162
  }
505
163
  resetState() {
@@ -507,18 +165,17 @@ export class CallableMockInstance {
507
165
  this.logger.log("lifecycle", "State cleared");
508
166
  }
509
167
  #captureRequestAdmission() {
510
- const requestGeneration = this.requestGeneration;
511
- requestGeneration.activeAdmissions += 1;
168
+ const requestGeneration = this.generations.admit();
169
+ // O(1) snapshot: the containers are captured by reference. `plugins` is
170
+ // only ever replaced, and the route tables are copy-on-write.
512
171
  return {
513
172
  requestGeneration,
514
- historyGeneration: this.historyGeneration,
515
- plugins: this.plugins.slice(),
516
- routes: this.routes.slice(),
517
- staticRoutes: new Map(this.staticRoutes),
173
+ historyGeneration: this.requestHistory.generation,
174
+ plugins: this.plugins,
175
+ routes: this.routeTable.share(),
518
176
  state: this.globalConfig.state,
519
177
  namespace: this.globalConfig.namespace,
520
178
  globalDelay: this.globalConfig.delay,
521
- maxHistorySize: this.globalConfig.maxHistorySize,
522
179
  released: false,
523
180
  };
524
181
  }
@@ -526,221 +183,39 @@ export class CallableMockInstance {
526
183
  if (admission.released)
527
184
  return;
528
185
  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
- }
186
+ this.generations.release(admission.requestGeneration);
544
187
  }
545
188
  createRequestAdmission() {
546
189
  const admission = this.#captureRequestAdmission();
547
- return {
190
+ const admitted = {
548
191
  handle: (method, path, options) => this.handle(method, path, options, admission),
549
192
  release: () => this.#releaseRequestAdmission(admission),
550
- };
551
- }
552
- // ===== Standalone Server =====
553
- listen(port = 0, hostname = "127.0.0.1") {
554
- if (this.server || this.pendingServerStart) {
555
- throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
556
- }
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;
588
- }
589
- #startHttpServer(operation, createServer) {
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";
597
- const handleRequest = async () => {
193
+ // Whether handle(method, path) would reach a route, answered from this
194
+ // admission's own snapshot by the resolver handle() itself uses, so a
195
+ // miss is never a false negative. The interceptor asks it to skip
196
+ // reading the body of a request that will pass through anyway.
197
+ hasRoute: (method, path) => {
598
198
  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
- }
619
- };
620
- handleRequest().catch((error) => {
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
- }
199
+ const resolution = this.#resolveRoute(method, canonicalizePath(path), admission);
200
+ return resolution.kind === "match";
659
201
  }
660
202
  catch {
661
- res.destroy();
203
+ // Whatever made the resolver throw, handle() must answer it.
204
+ return true;
662
205
  }
663
- });
664
- });
665
- operation.server = httpServer;
666
- const handleStartupError = (error) => {
667
- this.#rejectServerStart(operation, error);
206
+ },
207
+ // handle() above normalizes every response for its method, so the
208
+ // interceptor may send one on without a second normalizing pass.
209
+ [NORMALIZED_ADMISSION_KEY]: true,
668
210
  };
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
- }
677
- const addr = httpServer.address();
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);
687
- });
688
- }
689
- catch (error) {
690
- httpServer.off("error", handleStartupError);
691
- this.#rejectServerStart(operation, error);
692
- }
211
+ return admitted;
693
212
  }
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
- }
734
- });
213
+ // ===== Standalone Server =====
214
+ listen(port = 0, hostname = "127.0.0.1") {
215
+ return this.nodeServer.listen(port, hostname);
735
216
  }
736
217
  close() {
737
- this.#cancelServerStart();
738
- const server = this.server;
739
- if (!server)
740
- return;
741
- this.server = undefined;
742
- this.#beginServerClose(server);
743
- this.logger.log("server", "Server stopped");
218
+ this.nodeServer.close();
744
219
  }
745
220
  // ===== Fetch Interceptor =====
746
221
  intercept(options) {
@@ -749,7 +224,13 @@ export class CallableMockInstance {
749
224
  // slot with their own options, released independently. The owner symbol
750
225
  // keeps them one mock for dispatch, so a single request reaches handle()
751
226
  // 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);
227
+ const lease = createFetchLease({
228
+ handle: (method, path, opts) => this.handle(method, path, opts),
229
+ options,
230
+ admitRequest: () => this.createRequestAdmission(),
231
+ owner: this.interceptOwner,
232
+ observe: () => this.#openExchangeObservation(),
233
+ });
753
234
  const handle = {
754
235
  restore: () => {
755
236
  lease.restore();
@@ -768,6 +249,28 @@ export class CallableMockInstance {
768
249
  this.logger.log("lifecycle", `Interception lease acquired (${this.interceptHandles.size} held)`);
769
250
  return handle;
770
251
  }
252
+ /**
253
+ * Called by the lease right before it consults this mock about one request.
254
+ * It captures the plugins and the generation the request is admitted under,
255
+ * so observers piped later, or retired by reset(), never see it.
256
+ */
257
+ #openExchangeObservation() {
258
+ const plugins = this.plugins; // replaced, never mutated
259
+ if (!hasExchangeObserver(plugins))
260
+ return undefined; // no exchange is built
261
+ const generation = this.generations.current;
262
+ // Same gate as events and history, checked before each observer: one
263
+ // observer may reset() the mock and uninstall the ones after it.
264
+ return (exchange) => {
265
+ runExchangeHooks({
266
+ plugins,
267
+ exchange,
268
+ logger: this.logger,
269
+ isLive: () => this.generations.isCurrent(generation),
270
+ });
271
+ };
272
+ }
273
+ // ===== Request Handling =====
771
274
  async handle(method, path, options, admission) {
772
275
  const requestAdmission = admission ?? this.#captureRequestAdmission();
773
276
  try {
@@ -783,342 +286,360 @@ export class CallableMockInstance {
783
286
  // literal unicode, and every lifecycle event, log line and 404 message must
784
287
  // report the same spelling.
785
288
  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
289
  const signal = options?.signal;
796
290
  throwIfAborted(signal);
797
- const handleStart = performance.now();
798
- const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
799
- const reqQuery = { ...(options?.query ?? {}) };
800
- const reqHeaders = { ...(options?.headers ?? {}) };
801
- const requestBody = options?.body;
291
+ const scope = {
292
+ method,
293
+ path,
294
+ admission,
295
+ signal,
296
+ handleStart: performance.now(),
297
+ requestId: this.globalConfig.debug ? crypto.randomUUID() : "",
298
+ query: { ...(options?.query ?? {}) },
299
+ headers: { ...(options?.headers ?? {}) },
300
+ body: options?.body,
301
+ };
302
+ const { requestId } = scope;
802
303
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
803
- headers: redactSensitiveHeaders(reqHeaders),
804
- query: reqQuery,
304
+ headers: redactHeaders(scope.headers),
305
+ query: scope.query,
805
306
  // Presence, not truthiness: "", 0 and false are bodies too.
806
307
  bodyType: options !== undefined && "body" in options && options.body !== undefined
807
308
  ? typeof options.body
808
309
  : "none",
809
310
  });
810
311
  this.logger.time(`request-${requestId}`);
811
- if (this.requestGeneration === requestGeneration) {
812
- this.emit("request:start", {
312
+ if (this.generations.isCurrent(admission.requestGeneration)) {
313
+ this.events.emit("request:start", {
813
314
  method,
814
315
  path,
815
- headers: reqHeaders,
316
+ headers: scope.headers,
816
317
  });
817
318
  }
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 = {};
823
319
  try {
824
- // Apply namespace if configured
825
- if (namespace && namespace !== "/") {
826
- const normalizedNamespace = canonicalizePath(namespace.startsWith("/") ? namespace : `/${namespace}`);
827
- const pathToCheck = path.startsWith("/") ? path : `/${path}`;
828
- // Check if path starts with namespace
829
- // handle both "/api/users" (starts with /api) and "/api" (exact match)
830
- // but NOT "/apiv2" (prefix match but wrong segment)
831
- const isMatch = pathToCheck === normalizedNamespace ||
832
- pathToCheck.startsWith(normalizedNamespace.endsWith("/")
833
- ? normalizedNamespace
834
- : `${normalizedNamespace}/`);
835
- if (!isMatch) {
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({
840
- method,
841
- path,
842
- requestId,
843
- handleStart,
844
- requestGeneration,
845
- });
846
- }
847
- // Remove namespace prefix, ensuring we always start with /
848
- const stripped = pathToCheck.slice(normalizedNamespace.length);
849
- requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
320
+ const resolution = this.#resolveRoute(method, path, admission);
321
+ if (resolution.kind !== "match") {
322
+ this.logger.log("route", resolution.kind === "outside-namespace"
323
+ ? `[${requestId}] Path doesn't match namespace ${resolution.namespacePath}`
324
+ : `[${requestId}] No route found for ${method} ${resolution.requestPath}`);
325
+ // A request outside the namespace is a route miss like any other, so
326
+ // it reports one instead of silently ending.
327
+ return this.#finalizeMiss(scope);
850
328
  }
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);
855
- // Find matching route
856
- matchedRoute = findRoute(method, requestPath, requestStaticRoutes, requestRoutes);
857
- if (!matchedRoute) {
858
- this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
859
- return this.#finalizeMiss({
860
- method,
861
- path,
862
- requestId,
863
- handleStart,
864
- requestGeneration,
865
- });
866
- }
867
- this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
868
- // Extract parameters from the matched route
869
- params = extractParams(matchedRoute, requestPath);
870
- if (this.requestGeneration === requestGeneration) {
871
- this.emit("request:match", {
329
+ const match = this.#bindRoute(scope, resolution);
330
+ scope.match = match;
331
+ if (this.generations.isCurrent(admission.requestGeneration)) {
332
+ this.events.emit("request:match", {
872
333
  method,
873
334
  // Every lifecycle event carries the ORIGINAL request path; the
874
335
  // namespace-stripped route form is exposed as routePath.
875
336
  path,
876
- routePath: matchedRoute.path,
877
- params,
337
+ routePath: match.route.path,
338
+ params: match.params,
878
339
  });
879
340
  }
880
341
  throwIfAborted(signal);
881
- // Build plugin context before route code so request guards can reject
882
- // invalid or unauthorized requests without triggering side effects.
883
- let pluginContext = {
884
- path: requestPath,
885
- route: matchedRoute.config,
886
- method,
887
- params,
888
- query: reqQuery,
889
- headers: reqHeaders,
890
- body: requestBody,
891
- state: new Map(),
892
- routeState: requestState,
893
- signal,
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 };
342
+ let draft = await this.#runPreflight(scope, match);
343
+ if (draft.result === undefined) {
344
+ draft = await this.#runGenerator(scope, match, draft);
900
345
  }
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,
346
+ const response = await this.#runResponsePipeline(scope, match, draft);
347
+ await this.#finalizeMatchedRequest(scope, response);
348
+ return response;
349
+ }
350
+ catch (error) {
351
+ return await this.#answerFailedRequest(scope, error);
352
+ }
353
+ }
354
+ /**
355
+ * Where a request path lands in the admission's route table: outside the
356
+ * namespace, on no route, or on a route. It only resolves — no logs, no
357
+ * events — so `handle()` and the admission's route probe share it and can
358
+ * never disagree. `path` must already be canonical.
359
+ */
360
+ #resolveRoute(method, path, admission) {
361
+ let requestPath = path;
362
+ // Apply namespace if configured. A root namespace ("/") parses to the
363
+ // empty prefix and strips nothing.
364
+ const namespacePrefix = admission.namespace
365
+ ? this.#namespacePrefix(admission.namespace)
366
+ : undefined;
367
+ if (namespacePrefix !== undefined && namespacePrefix.path !== "") {
368
+ const pathToCheck = path.startsWith("/") ? path : `/${path}`;
369
+ // Segment-boundary match: "/api" serves "/api" and "/api/users" but
370
+ // not "/apiv2". The trailing-slash rule is parsePathPrefix's, shared
371
+ // with intercept({ baseUrl }): "/api/" is the same namespace as "/api".
372
+ if (!matchPathPrefix(namespacePrefix, pathToCheck)) {
373
+ return {
374
+ kind: "outside-namespace",
375
+ namespacePath: namespacePrefix.path,
914
376
  };
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
377
  }
933
- // Run plugin pipeline to transform the response
934
- try {
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
- }
378
+ // Remove namespace prefix, ensuring we always start with /
379
+ const stripped = pathToCheck.slice(namespacePrefix.path.length);
380
+ requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
381
+ }
382
+ // One trailing-slash normalization for the whole request: route lookup
383
+ // and parameter extraction must see the identical string, or a request
384
+ // could match a route and then capture no parameters.
385
+ requestPath = normalizePath(requestPath);
386
+ const route = findRoute(method, requestPath, admission.routes.staticRoutes, admission.routes.routes);
387
+ return route
388
+ ? { kind: "match", route, requestPath }
389
+ : { kind: "no-route", requestPath };
390
+ }
391
+ /**
392
+ * Bind a request to the route it matched: its parameters, what history
393
+ * will record, and its own copy of the route config.
394
+ */
395
+ #bindRoute(scope, resolution) {
396
+ const { route, requestPath } = resolution;
397
+ this.logger.log("route", `[${scope.requestId}] Matched route: ${scope.method} ${route.path}`);
398
+ const params = extractParams(route, requestPath);
399
+ return {
400
+ route,
401
+ requestPath,
402
+ params,
403
+ // History reports what the CLIENT sent, so it is captured here, before
404
+ // any plugin or the generator gets the live objects and can edit them.
405
+ historyParams: { ...params },
406
+ historySnapshot: this.requestHistory.snapshotRequest(scope),
407
+ // A per-request copy: a plugin that edits `context.route` in place
408
+ // changes this request only, never the registered route.
409
+ routeConfig: copyRouteConfig(route.config),
410
+ };
411
+ }
412
+ /**
413
+ * Build the plugin context and run the plugins' `beforeRequest` guards, so
414
+ * they can reject invalid or unauthorized requests before any route code
415
+ * runs.
416
+ */
417
+ async #runPreflight(scope, match) {
418
+ const { signal } = scope;
419
+ const pluginContext = {
420
+ path: match.requestPath,
421
+ route: match.routeConfig,
422
+ method: scope.method,
423
+ params: match.params,
424
+ query: scope.query,
425
+ headers: scope.headers,
426
+ body: scope.body,
427
+ state: new Map(),
428
+ routeState: scope.admission.state,
429
+ signal,
430
+ };
431
+ const preflight = await runPluginBeforeRequest(scope.admission.plugins, pluginContext, this.logger, signal);
432
+ throwIfAborted(signal);
433
+ return {
434
+ context: preflight.requestShortCircuited === true
435
+ ? { ...preflight.context, requestShortCircuited: true }
436
+ : preflight.context,
437
+ result: preflight.response,
438
+ recovered: preflight.recoveredFromError === true,
439
+ };
440
+ }
441
+ /**
442
+ * Produce the route's response, and give the plugins' `onError` hooks the
443
+ * chance to recover when the generator throws.
444
+ */
445
+ async #runGenerator(scope, match, draft) {
446
+ const { signal } = scope;
447
+ const { context: pluginContext } = draft;
448
+ const plugins = scope.admission.plugins;
449
+ const context = {
450
+ method: pluginContext.method,
451
+ path: pluginContext.path,
452
+ params: pluginContext.params,
453
+ query: pluginContext.query,
454
+ headers: pluginContext.headers,
455
+ body: pluginContext.body,
456
+ state: pluginContext.routeState ?? scope.admission.state,
457
+ pluginState: pluginContext.state,
458
+ signal,
459
+ };
460
+ try {
461
+ let result;
462
+ if (isGeneratorFunction(match.route.generator)) {
463
+ result = await awaitWithAbort(match.route.generator(context), signal);
944
464
  }
945
- catch (error) {
946
- this.logger.log("error", `[${requestId}] Plugin pipeline error: ${errorMessage(error)}`);
947
- throw error;
465
+ else {
466
+ // Static data is one object shared by every request; plugins get
467
+ // their own copy so an in-place edit cannot leak into the next
468
+ // response (or back into the caller's object).
469
+ result =
470
+ plugins.length > 0
471
+ ? copyStaticData(match.route.generator)
472
+ : match.route.generator;
948
473
  }
949
- // Parse and prepare response
950
- const response = normalizeResponse(parseResponse(result, matchedRoute.config), method);
951
- await this.#finalizeMatchedRequest({
952
- method,
953
- path,
954
- requestPath,
955
- params,
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,
969
- });
970
- return response;
474
+ throwIfAborted(signal);
475
+ return { ...draft, result };
971
476
  }
972
477
  catch (error) {
973
478
  throwIfAborted(signal);
974
- this.logger.log("error", `[${requestId}] Error processing request: ${errorMessage(error)}`, error);
975
- // Return error response
976
- const responseError = error instanceof Error ? error : new Error(errorMessage(error));
977
- const errorResponse = markResponseException(normalizeResponse({
978
- status: 500,
979
- body: {
980
- error: responseError.message,
981
- code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
982
- },
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({
988
- method,
989
- path,
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,
1005
- });
1006
- return errorResponse;
479
+ const recovery = await recoverGeneratorError(plugins, pluginContext, error, this.logger, signal);
480
+ throwIfAborted(signal);
481
+ return {
482
+ context: recovery.context,
483
+ result: recovery.response,
484
+ recovered: recovery.recoveredFromError === true,
485
+ };
1007
486
  }
1008
487
  }
1009
488
  /**
1010
- * Finish a request that matched a route.
489
+ * Run the plugins' response processors (skipped after an error recovery)
490
+ * and turn the result into the normalized response.
491
+ */
492
+ async #runResponsePipeline(scope, match, draft) {
493
+ let result = draft.result;
494
+ try {
495
+ if (draft.recovered) {
496
+ this.logger.log("pipeline", "Skipping response processors after error recovery");
497
+ }
498
+ else {
499
+ const pipelineResult = await runPluginPipeline(scope.admission.plugins, draft.context, result, this.logger, scope.signal);
500
+ throwIfAborted(scope.signal);
501
+ result = pipelineResult.response;
502
+ }
503
+ }
504
+ catch (error) {
505
+ this.logger.log("error", `[${scope.requestId}] Plugin pipeline error: ${errorMessage(error)}`);
506
+ throw error;
507
+ }
508
+ return normalizeResponse(parseResponse(result, match.routeConfig), scope.method);
509
+ }
510
+ /**
511
+ * Answer a request that failed after `request:start` with a marked 500.
512
+ * Every such exit ends with exactly one `request:end`; a cancelled request
513
+ * reports 499 (client closed request) before its abort reason propagates.
514
+ */
515
+ async #answerFailedRequest(scope, error) {
516
+ this.#throwIfRequestAborted(scope);
517
+ this.logger.log("error", `[${scope.requestId}] Error processing request: ${errorMessage(error)}`, error);
518
+ const responseError = error instanceof Error ? error : new Error(errorMessage(error));
519
+ const errorResponse = markResponseException(buildJsonErrorResponse({
520
+ status: 500,
521
+ error: responseError.message,
522
+ code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
523
+ method: scope.method,
524
+ }), responseError);
525
+ // A request that matched a route did happen: it is finalized exactly like
526
+ // a successful one — its own delay override, and a history record.
527
+ try {
528
+ await this.#finalizeMatchedRequest(scope, errorResponse);
529
+ }
530
+ catch (finalizeError) {
531
+ // Only the delay can reject here, and only with the abort reason.
532
+ this.#throwIfRequestAborted(scope);
533
+ throw finalizeError;
534
+ }
535
+ return errorResponse;
536
+ }
537
+ /**
538
+ * Finish a request after `request:start`, matched or failed.
1011
539
  *
1012
540
  * Order matters: delay first (an abort during it must escape before anything
1013
541
  * is committed), then the history record, then `request:end`, then the logs.
1014
542
  */
1015
- async #finalizeMatchedRequest(input) {
1016
- const { response, maxHistorySize } = input;
543
+ async #finalizeMatchedRequest(scope, response) {
544
+ const { admission, match, signal } = scope;
1017
545
  // Apply delay (route-level overrides global)
1018
- await this.applyDelay(input.routeDelay, input.globalDelay, input.signal);
1019
- throwIfAborted(input.signal);
546
+ await applyResponseDelay({
547
+ routeDelay: match?.routeConfig.delay,
548
+ globalDelay: admission.globalDelay,
549
+ signal,
550
+ });
551
+ throwIfAborted(signal);
1020
552
  // 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
- },
553
+ const historySnapshot = match?.historySnapshot;
554
+ if (match !== undefined &&
555
+ historySnapshot !== undefined &&
556
+ this.generations.isCurrent(admission.requestGeneration)) {
557
+ this.requestHistory.record({
558
+ generation: admission.historyGeneration,
559
+ method: scope.method,
560
+ path: match.requestPath,
561
+ params: match.historyParams,
562
+ snapshot: historySnapshot,
563
+ response,
1037
564
  });
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
565
  }
1045
- if (this.requestGeneration === input.requestGeneration) {
1046
- this.emit("request:end", {
1047
- method: input.method,
1048
- path: input.path,
566
+ if (this.generations.isCurrent(admission.requestGeneration)) {
567
+ this.events.emit("request:end", {
568
+ method: scope.method,
569
+ path: scope.path,
1049
570
  status: response.status,
1050
- duration: performance.now() - input.handleStart,
571
+ duration: performance.now() - scope.handleStart,
1051
572
  });
1052
573
  }
1053
- this.logger.log("response", `[${input.requestId}] Sending response ${response.status}`, {
574
+ this.logger.log("response", `[${scope.requestId}] Sending response ${response.status}`, {
1054
575
  status: response.status,
1055
- headers: redactSensitiveHeaders(response.headers),
576
+ headers: redactHeaders(response.headers),
1056
577
  bodyType: typeof response.body,
1057
578
  });
1058
- this.logger.timeEnd(`request-${input.requestId}`);
579
+ this.logger.timeEnd(`request-${scope.requestId}`);
580
+ }
581
+ /**
582
+ * Rethrow a cancellation after reporting it as the request's terminal
583
+ * event, so a `request:start` listener always sees exactly one
584
+ * `request:end`. 499 is the de facto "client closed request" status.
585
+ */
586
+ #throwIfRequestAborted(scope) {
587
+ const { signal } = scope;
588
+ if (!signal?.aborted)
589
+ return;
590
+ if (this.generations.isCurrent(scope.admission.requestGeneration)) {
591
+ this.events.emit("request:end", {
592
+ method: scope.method,
593
+ path: scope.path,
594
+ status: ABORTED_REQUEST_STATUS,
595
+ duration: performance.now() - scope.handleStart,
596
+ });
597
+ }
598
+ throwIfAborted(signal);
599
+ }
600
+ /**
601
+ * The namespace as the path prefix request paths are compared with:
602
+ * percent-encoded, with a leading slash and without a trailing one, so
603
+ * `"/api/"` and `"/api"` behave identically (both serve `/api`, neither
604
+ * serves `/api//users`). `""` for a root namespace. Only the path of an
605
+ * origin-form namespace is used. Cached per namespace string instead of
606
+ * re-parsed per request.
607
+ */
608
+ #namespacePrefix(namespace) {
609
+ const cached = this.namespaceCache;
610
+ if (cached?.raw === namespace)
611
+ return cached.prefix;
612
+ const prefix = parsePathPrefix(namespace);
613
+ this.namespaceCache = { raw: namespace, prefix };
614
+ return prefix;
1059
615
  }
1060
616
  /**
1061
617
  * Finish a request that matched no route — an unknown path or one outside the
1062
618
  * configured namespace. Misses stay delay-free and out of history: nothing
1063
619
  * ran, so there is nothing to record.
1064
620
  */
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({
621
+ #finalizeMiss(scope) {
622
+ const { method, path } = scope;
623
+ // Checked before each event: a listener may reset the mock in between.
624
+ if (this.generations.isCurrent(scope.admission.requestGeneration)) {
625
+ this.events.emit("request:notfound", { method, path });
626
+ }
627
+ const error = new RouteNotFoundError(method, path);
628
+ const response = markRouteNotFound(buildJsonErrorResponse({
1074
629
  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,
630
+ error: error.message,
631
+ code: error.code,
632
+ method,
633
+ }));
634
+ if (this.generations.isCurrent(scope.admission.requestGeneration)) {
635
+ this.events.emit("request:end", {
636
+ method,
637
+ path,
1082
638
  status: 404,
1083
- duration: performance.now() - input.handleStart,
639
+ duration: performance.now() - scope.handleStart,
1084
640
  });
1085
641
  }
1086
- this.logger.timeEnd(`request-${input.requestId}`);
642
+ this.logger.timeEnd(`request-${scope.requestId}`);
1087
643
  return response;
1088
644
  }
1089
- /**
1090
- * Apply configured response delay
1091
- * Supports both fixed delays and random delays within a range
1092
- * @private
1093
- */
1094
- async applyDelay(routeDelay, globalDelay, signal) {
1095
- const effectiveDelay = routeDelay ?? globalDelay;
1096
- if (!effectiveDelay) {
1097
- throwIfAborted(signal);
1098
- return;
1099
- }
1100
- const configuredMs = Array.isArray(effectiveDelay)
1101
- ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
1102
- effectiveDelay[0]
1103
- : effectiveDelay;
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
- });
1123
- }
1124
645
  }