@schmock/core 2.4.1 → 2.5.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 (66) 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 +19 -0
  5. package/dist/adapter.js +17 -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 +397 -903
  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 +43 -0
  22. package/dist/generations.js +75 -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 +230 -0
  29. package/dist/http-helpers.d.ts +110 -5
  30. package/dist/http-helpers.js +328 -46
  31. package/dist/index.d.ts +213 -31
  32. package/dist/index.js +17 -9
  33. package/dist/interceptor.d.ts +15 -11
  34. package/dist/interceptor.js +241 -164
  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 +40 -0
  40. package/dist/plugin-hooks.js +192 -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/types.d.ts +27 -1
  51. package/package.json +8 -3
  52. package/dist/abort.d.ts.map +0 -1
  53. package/dist/binary.d.ts.map +0 -1
  54. package/dist/builder.d.ts.map +0 -1
  55. package/dist/constants.d.ts.map +0 -1
  56. package/dist/errors.d.ts.map +0 -1
  57. package/dist/helpers.d.ts.map +0 -1
  58. package/dist/http-helpers.d.ts.map +0 -1
  59. package/dist/index.d.ts.map +0 -1
  60. package/dist/interceptor.d.ts.map +0 -1
  61. package/dist/parser.d.ts.map +0 -1
  62. package/dist/plugin-pipeline.d.ts.map +0 -1
  63. package/dist/response-normalizer.d.ts.map +0 -1
  64. package/dist/response-parser.d.ts.map +0 -1
  65. package/dist/route-matcher.d.ts.map +0 -1
  66. package/dist/types.d.ts.map +0 -1
@@ -1,14 +1,29 @@
1
1
  /// <reference path="../schmock.d.ts" />
2
2
  import { awaitWithAbort, throwIfAborted } from "./abort.js";
3
- import { isBinaryBody } from "./binary.js";
4
- import { canonicalizePath, getResponseException, isRouteNotFound, toHttpMethod, } from "./constants.js";
5
- import { normalizeResponse, serializeResponseBody, } from "./response-normalizer.js";
3
+ import { canonicalizePath, getResponseException, isHttpMethod, isRouteNotFound, matchPathPrefix, parsePathPrefix, } from "./constants.js";
4
+ import { buildFormattedErrorResponse, buildJsonErrorResponse, normalizeResponse, serializeResponseBody, withDefaultContentType, } from "./response-normalizer.js";
6
5
  const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
7
- // A lease whose baseUrl filter rejected the request never reached its handler.
8
- // It is distinct from PASSTHROUGH so the dispatch loop can tell "this owner
9
- // already ran" from "this lease was not interested in the request at all".
6
+ // A lease whose baseUrl filter rejected the request never reached its handler:
7
+ // it was not interested in the request at all, and claimed nothing.
10
8
  const FILTERED = Symbol("schmock.fetch.filtered");
9
+ // A newer lease of the same mock already asked it this exact request, so
10
+ // asking again would only repeat the answer and its lifecycle events.
11
+ const ALREADY_CONSULTED = Symbol("schmock.fetch.already-consulted");
11
12
  const RELATIVE_REQUEST_BASE = "http://schmock.invalid/";
13
+ /**
14
+ * Marks an admission whose `handle()` already returns responses normalized
15
+ * for the request method (hop-by-hop headers dropped, a HEAD body stripped):
16
+ * only the admissions a `schmock()` instance of this copy creates. The
17
+ * interceptor re-normalizes the responses of any other admission, such as a
18
+ * hand-written one passed to `createFetchInterceptor`. Deliberately
19
+ * unregistered: an admission from a second copy of `@schmock/core` is simply
20
+ * normalized again.
21
+ */
22
+ export const NORMALIZED_ADMISSION_KEY = Symbol("schmock.normalized-admission");
23
+ function isNormalizedAdmission(admission) {
24
+ return (admission !== undefined &&
25
+ Reflect.get(admission, NORMALIZED_ADMISSION_KEY) === true);
26
+ }
12
27
  let activeSession;
13
28
  function getRelativeRequestBase() {
14
29
  const candidates = [
@@ -106,29 +121,42 @@ function createInterceptorSession() {
106
121
  }
107
122
  const normalizedRequest = normalizeFetchRequest(input, init);
108
123
  throwIfAborted(normalizedRequest.request.signal);
109
- // A mock is consulted at most once per request, however many leases it
110
- // holds: without this, nested providers on one mock would run handle()
111
- // — and emit request:start/notfound/end — once per lease.
112
- const consultedOwners = new Set();
124
+ // A mock is asked each distinct effective request at most once, however
125
+ // many leases it holds: without this, nested providers on one mock would
126
+ // run handle() — and emit request:start/notfound/end — once per lease.
127
+ // The key is the request each lease would issue after its own baseUrl
128
+ // filter and beforeRequest, so an older lease whose hook rewrites the
129
+ // request (an outer provider stripping "/api") still gets its turn.
130
+ const consultedRequests = new Map();
131
+ const claimFor = (owner) => (requestKey) => {
132
+ if (owner === undefined)
133
+ return true;
134
+ let keys = consultedRequests.get(owner);
135
+ if (keys === undefined) {
136
+ keys = new Set();
137
+ consultedRequests.set(owner, keys);
138
+ }
139
+ if (keys.has(requestKey))
140
+ return false;
141
+ keys.add(requestKey);
142
+ return true;
143
+ };
113
144
  for (let index = snapshot.length - 1; index >= 0; index -= 1) {
114
145
  const registered = snapshot[index];
115
- const { owner } = registered;
116
- if (owner !== undefined && consultedOwners.has(owner)) {
117
- continue;
118
- }
119
- const result = await awaitWithAbort(registered.intercept(normalizedRequest), normalizedRequest.request.signal);
146
+ const result = await awaitWithAbort(registered.intercept({
147
+ request: normalizedRequest,
148
+ claim: claimFor(registered.owner),
149
+ }), normalizedRequest.request.signal);
120
150
  throwIfAborted(normalizedRequest.request.signal);
121
- // The lease filtered the request out before admission, so its owner
122
- // keeps its turn — a sibling lease may carry a matching baseUrl.
123
- if (result === FILTERED) {
151
+ // FILTERED: this lease was not interested, so a sibling lease may be.
152
+ // ALREADY_CONSULTED: the mock already answered this exact request.
153
+ // PASSTHROUGH: the mock has no route for it. All three move on.
154
+ if (result === FILTERED ||
155
+ result === ALREADY_CONSULTED ||
156
+ result === PASSTHROUGH) {
124
157
  continue;
125
158
  }
126
- if (result !== PASSTHROUGH) {
127
- return result;
128
- }
129
- if (owner !== undefined) {
130
- consultedOwners.add(owner);
131
- }
159
+ return result;
132
160
  }
133
161
  return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
134
162
  };
@@ -176,31 +204,6 @@ function registerInterceptor(intercept, applyOptions, owner) {
176
204
  },
177
205
  };
178
206
  }
179
- /**
180
- * Parse the user-supplied baseUrl option into its origin and path parts.
181
- * - "/api" → { origin: null, path: "/api" }
182
- * - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
183
- * - "https://x.com" → { origin: "https://x.com", path: "" }
184
- *
185
- * Trailing slash is stripped from the path so the segment-boundary check
186
- * works the same way for "/api" and "/api/".
187
- */
188
- function parseBaseUrl(baseUrl) {
189
- if (baseUrl.includes("://")) {
190
- try {
191
- const parsed = new URL(baseUrl);
192
- const canonicalPath = canonicalizePath(parsed.pathname);
193
- const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
194
- return { origin: parsed.origin, path };
195
- }
196
- catch {
197
- // Fall through to path-only handling
198
- }
199
- }
200
- const canonicalPath = canonicalizePath(baseUrl);
201
- const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
202
- return { origin: null, path };
203
- }
204
207
  function extractQuery(url) {
205
208
  return Object.fromEntries(url.searchParams);
206
209
  }
@@ -216,18 +219,25 @@ function normalizeMediaType(contentType) {
216
219
  }
217
220
  async function extractBody(request) {
218
221
  if (request.body === null)
219
- return undefined;
220
- const body = request.clone();
222
+ return { value: undefined, malformedJson: false };
221
223
  const mediaType = normalizeMediaType(request.headers.get("content-type"));
222
- if (mediaType === "application/json" || mediaType.endsWith("+json")) {
223
- const text = await body.text();
224
- try {
225
- return JSON.parse(text);
226
- }
227
- catch {
228
- return text;
229
- }
224
+ if (mediaType !== "application/json" && !mediaType.endsWith("+json")) {
225
+ return { value: await extractNonJsonBody(request), malformedJson: false };
226
+ }
227
+ const text = await request.clone().text();
228
+ // An empty JSON body is no body at all, as the Node ingress reads it.
229
+ if (text === "")
230
+ return { value: undefined, malformedJson: false };
231
+ try {
232
+ return { value: JSON.parse(text), malformedJson: false };
233
+ }
234
+ catch {
235
+ return { value: text, malformedJson: true };
230
236
  }
237
+ }
238
+ async function extractNonJsonBody(request) {
239
+ const body = request.clone();
240
+ const mediaType = normalizeMediaType(request.headers.get("content-type"));
231
241
  if (mediaType === "application/x-www-form-urlencoded") {
232
242
  return Object.fromEntries(new URLSearchParams(await body.text()));
233
243
  }
@@ -239,98 +249,99 @@ async function extractBody(request) {
239
249
  }
240
250
  return body.arrayBuffer();
241
251
  }
252
+ /** An admission's route probe (`hasRoute`), when it carries one. */
253
+ function routeProbeOf(admission) {
254
+ if (admission === undefined)
255
+ return undefined;
256
+ const probe = Reflect.get(admission, "hasRoute");
257
+ if (typeof probe !== "function")
258
+ return undefined;
259
+ // Anything but a definite `false` counts as a route, so an unexpected
260
+ // answer, or a throw, only costs the body read the probe would have saved.
261
+ return (method, path) => {
262
+ try {
263
+ return Reflect.apply(probe, admission, [method, path]) !== false;
264
+ }
265
+ catch {
266
+ return true;
267
+ }
268
+ };
269
+ }
242
270
  function isAbortError(error) {
243
271
  return (typeof error === "object" &&
244
272
  error !== null &&
245
273
  "name" in error &&
246
274
  error.name === "AbortError");
247
275
  }
248
- function toFetchResponse(response, method) {
249
- const headers = { ...response.headers };
250
- const hasContentType = Object.keys(headers).some((name) => name.toLowerCase() === "content-type");
251
- if (!hasContentType &&
252
- response.body !== null &&
253
- response.body !== undefined) {
254
- if (isBinaryBody(response.body)) {
255
- headers["content-type"] = "application/octet-stream";
256
- }
257
- else if (typeof response.body !== "string") {
258
- headers["content-type"] = "application/json";
259
- }
260
- }
261
- const normalized = normalizeResponse({ ...response, headers }, method);
262
- return new Response(serializeResponseBody(normalized) ?? null, {
276
+ /**
277
+ * Build the fetch Response from an already-normalized Schmock response.
278
+ */
279
+ function createFetchResponse(normalized, context) {
280
+ const response = new Response(serializeResponseBody(normalized) ?? null, {
263
281
  status: normalized.status,
264
282
  headers: normalized.headers,
265
283
  });
284
+ // A constructed Response has an empty url. Real fetch reports the request
285
+ // URL, and code resolving links with `new URL(next, res.url)` needs it.
286
+ Object.defineProperty(response, "url", { value: context.url });
287
+ return response;
288
+ }
289
+ function toFetchResponse(response, context) {
290
+ return createFetchResponse(normalizeResponse(withDefaultContentType(response), context.method), context);
291
+ }
292
+ function jsonErrorResponse(input) {
293
+ return createFetchResponse(buildJsonErrorResponse({
294
+ status: input.status,
295
+ error: input.error,
296
+ code: input.code,
297
+ method: input.context.method,
298
+ }), input.context);
266
299
  }
267
300
  /**
268
- * Formatted error bodies are always JSON, so the replaced response's own
269
- * content type must be dropped rather than inherited. Every case variant goes
270
- * first: leaving a `Content-Type` beside the forced lowercase key makes the
271
- * pair transport-invalid and the normalizer rejects it.
301
+ * A request this lease owns but cannot route: pass it on, or answer the same
302
+ * 404 a route miss gets when passthrough is off.
272
303
  */
273
- function withJsonContentType(headers) {
274
- const result = {};
275
- for (const [name, value] of Object.entries(headers ?? {})) {
276
- if (name.toLowerCase() === "content-type")
277
- continue;
278
- result[name] = value;
279
- }
280
- result["content-type"] = "application/json";
281
- return result;
304
+ function unroutedResult(passthrough, context) {
305
+ if (passthrough)
306
+ return PASSTHROUGH;
307
+ return jsonErrorResponse({
308
+ status: 404,
309
+ error: "No matching mock route found",
310
+ code: "ROUTE_NOT_FOUND",
311
+ context,
312
+ });
282
313
  }
283
314
  /**
284
- * Invoke the errorFormatter for a core-marked exception and build its
285
- * response, falling back to a minimal safe body when the formatter throws or
286
- * its result is not serializable.
287
- *
288
- * This helper is TOTAL — it never throws. It runs inside the interceptor's
289
- * `try`, so an escaping error would land in the catch below and invoke the
290
- * formatter a second time; the re-entrancy is exactly the defect the Express
291
- * adapter's `sendFormattedError` was shaped to avoid.
292
- *
293
- * `responseHeaders` carries the (post-hook) headers of the response being
294
- * replaced so metadata such as `retry-after` survives. There are two distinct
295
- * fallbacks. When the inherited headers are untransportable, the send is
296
- * retried once with the fixed JSON header set and the SAME formatted body —
297
- * nothing is on the wire yet, and losing the body would silently change the
298
- * user's error contract. Only a failure of the formatter itself, or of its
299
- * body, reaches the minimal fallback, which deliberately inherits nothing.
315
+ * The fetch Response for an errorFormatter result, built by the shared
316
+ * {@link buildFormattedErrorResponse}. TOTAL: it never throws, and the
317
+ * formatter runs exactly once. It runs inside the interceptor's `try`, so an
318
+ * escaping error would land in the catch below and invoke the formatter a
319
+ * second time.
300
320
  */
301
- function formatInterceptedError(errorFormatter, error, responseHeaders, method) {
302
- try {
303
- const formatted = errorFormatter(error);
304
- try {
305
- return toFetchResponse({
306
- status: 500,
307
- body: formatted,
308
- headers: withJsonContentType(responseHeaders),
309
- }, method);
310
- }
311
- catch {
312
- // `formatted` is reused, so the formatter still fires exactly once.
313
- return toFetchResponse({
314
- status: 500,
315
- body: formatted,
316
- headers: { "content-type": "application/json" },
317
- }, method);
318
- }
319
- }
320
- catch {
321
- return toFetchResponse({
322
- status: 500,
323
- body: { error: "Internal Server Error", code: "INTERNAL_ERROR" },
324
- headers: { "content-type": "application/json" },
325
- }, method);
326
- }
321
+ function formattedErrorResponse(input) {
322
+ return createFetchResponse(buildFormattedErrorResponse({
323
+ formatter: input.formatter,
324
+ error: input.error,
325
+ inheritedHeaders: input.responseHeaders,
326
+ method: input.context.method,
327
+ }), input.context);
328
+ }
329
+ /** Fetch reports the request URL without its fragment. */
330
+ function responseUrlOf(url) {
331
+ const responseUrl = new URL(url.href);
332
+ responseUrl.hash = "";
333
+ return responseUrl.href;
334
+ }
335
+ function effectiveRequestKey(method, path) {
336
+ return `${method} ${canonicalizePath(path)}`;
327
337
  }
328
338
  /**
329
339
  * Create a fetch interceptor that routes requests through mock.handle().
330
340
  *
331
- * `owner` identifies the mock behind the lease. Leases sharing an owner are
332
- * consulted at most once per request, so a mock held by several leases runs
333
- * its handler — and emits its lifecycle events — once per network request.
341
+ * `owner` identifies the mock behind the lease. Leases sharing an owner ask it
342
+ * each distinct effective request (method and path after the lease's own
343
+ * beforeRequest) at most once, so a mock held by several leases runs its
344
+ * handler — and emits its lifecycle events — once per request it is asked.
334
345
  */
335
346
  export function createFetchInterceptor(handle, options = {}, admitRequest, owner) {
336
347
  // The options live in a mutable cell that each request reads once at its
@@ -338,7 +349,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
338
349
  // hooks without re-registering — re-registration would move the lease to the
339
350
  // front of the dispatch order and steal precedence from other mocks.
340
351
  let currentOptions = options;
341
- return registerInterceptor(async ({ request, url, origin }) => {
352
+ return registerInterceptor(async ({ request: { request, url, origin }, claim, }) => {
342
353
  const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
343
354
  const path = canonicalizePath(url.pathname);
344
355
  // BaseUrl filter — non-matching requests go straight to real fetch.
@@ -348,32 +359,74 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
348
359
  // - path form ("/api"): match pathname prefix only.
349
360
  // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
350
361
  if (baseUrl) {
351
- const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
352
- if (baseOrigin && origin !== baseOrigin) {
362
+ const base = parsePathPrefix(baseUrl);
363
+ if (base.origin && origin !== base.origin) {
353
364
  return FILTERED;
354
365
  }
355
- if (basePath) {
356
- const isMatch = path === basePath || path.startsWith(`${basePath}/`);
357
- if (!isMatch) {
358
- return FILTERED;
359
- }
366
+ if (!matchPathPrefix(base, path)) {
367
+ return FILTERED;
360
368
  }
361
369
  }
362
370
  throwIfAborted(request.signal);
363
- const initialMethod = toHttpMethod(request.method);
371
+ const context = {
372
+ method: request.method,
373
+ url: responseUrlOf(url),
374
+ };
375
+ const initialMethod = request.method.toUpperCase();
376
+ // Without a beforeRequest hook (which may change the method or path)
377
+ // the effective request is already known, so it is claimed before this
378
+ // lease can answer anything: once a newer lease of the same mock has
379
+ // passed it through, an older passthrough:false lease must not answer
380
+ // it with a 404 or a malformed-JSON 400. A lease with a hook claims only
381
+ // after the hook, so its own 400 for a malformed JSON body (which comes
382
+ // before the hook) still answers.
383
+ if (beforeRequest === undefined &&
384
+ !claim(effectiveRequestKey(initialMethod, path))) {
385
+ return ALREADY_CONSULTED;
386
+ }
387
+ // No route can ever match a method outside the supported set (WebDAV's
388
+ // PROPFIND, a CDN PURGE), so it is a miss like any other rather than a
389
+ // rejected fetch. Checked before admission, which it never needs.
390
+ if (!isHttpMethod(initialMethod)) {
391
+ return unroutedResult(passthrough, context);
392
+ }
393
+ context.method = initialMethod;
364
394
  const admission = admitRequest?.();
365
395
  const admittedHandle = admission?.handle ?? handle;
366
- let effectiveMethod = initialMethod;
396
+ // Without a beforeRequest hook (which receives the body and may change
397
+ // the method or path), the request handle() will see is already known,
398
+ // so a passthrough lease asks the admission whether any route answers
399
+ // it. On a definite miss the body is never read: the request reaches
400
+ // the network untouched, and handle() still runs, without a body, so
401
+ // request:start/notfound/end are emitted exactly as before.
402
+ const routeProbe = passthrough && !beforeRequest ? routeProbeOf(admission) : undefined;
403
+ // The request handed to errorFormatter: the latest one this lease built,
404
+ // so it reflects beforeRequest once that hook has returned.
405
+ let formatterRequest;
367
406
  try {
368
- const body = await awaitWithAbort(extractBody(request), request.signal);
407
+ const body = routeProbe !== undefined && !routeProbe(initialMethod, path)
408
+ ? { value: undefined, malformedJson: false }
409
+ : await awaitWithAbort(extractBody(request), request.signal);
369
410
  throwIfAborted(request.signal);
411
+ // With passthrough off the lease owns every request that reaches it,
412
+ // as the Node server does, so a JSON body that does not parse gets
413
+ // the server's 400 before any route runs or history records it.
414
+ if (body.malformedJson && !passthrough) {
415
+ return jsonErrorResponse({
416
+ status: 400,
417
+ error: "Malformed JSON request body",
418
+ code: "MALFORMED_JSON",
419
+ context,
420
+ });
421
+ }
370
422
  let adapterRequest = {
371
423
  method: request.method,
372
424
  path,
373
425
  headers: extractHeaders(request),
374
- body,
426
+ body: body.value,
375
427
  query: extractQuery(url),
376
428
  };
429
+ formatterRequest = adapterRequest;
377
430
  // Apply beforeRequest hook
378
431
  if (beforeRequest) {
379
432
  throwIfAborted(request.signal);
@@ -381,16 +434,27 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
381
434
  throwIfAborted(request.signal);
382
435
  if (modified) {
383
436
  adapterRequest = modified;
437
+ formatterRequest = modified;
384
438
  }
385
439
  }
386
440
  throwIfAborted(request.signal);
441
+ // A hook may produce a method no route can match; that is a miss
442
+ // too, as the Angular adapter treats it.
443
+ const effectiveMethod = adapterRequest.method.toUpperCase();
444
+ if (!isHttpMethod(effectiveMethod)) {
445
+ return unroutedResult(passthrough, context);
446
+ }
447
+ context.method = effectiveMethod;
448
+ if (beforeRequest !== undefined &&
449
+ !claim(effectiveRequestKey(effectiveMethod, adapterRequest.path))) {
450
+ return ALREADY_CONSULTED;
451
+ }
387
452
  const requestOptions = {
388
453
  headers: adapterRequest.headers,
389
454
  body: adapterRequest.body,
390
455
  query: adapterRequest.query,
391
456
  signal: request.signal,
392
457
  };
393
- effectiveMethod = toHttpMethod(adapterRequest.method);
394
458
  const schmockResponse = await awaitWithAbort(admittedHandle(effectiveMethod, adapterRequest.path, requestOptions), request.signal);
395
459
  throwIfAborted(request.signal);
396
460
  // Exception provenance is carried on the response as a non-enumerable
@@ -401,17 +465,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
401
465
  const internalError = getResponseException(schmockResponse);
402
466
  // Route not found — passthrough or 404
403
467
  if (isRouteNotFound(schmockResponse)) {
404
- if (passthrough) {
405
- return PASSTHROUGH;
406
- }
407
- return toFetchResponse({
408
- status: 404,
409
- body: {
410
- error: "No matching mock route found",
411
- code: "ROUTE_NOT_FOUND",
412
- },
413
- headers: { "content-type": "application/json" },
414
- }, effectiveMethod);
468
+ return unroutedResult(passthrough, context);
415
469
  }
416
470
  // Apply beforeResponse hook
417
471
  let response = schmockResponse;
@@ -429,9 +483,22 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
429
483
  // Angular): a beforeResponse that rewrites an exception into a 503 or
430
484
  // a 200 is honoured instead of being forced back to a formatted 500.
431
485
  if (errorFormatter && internalError && response.status === 500) {
432
- return formatInterceptedError(errorFormatter, internalError, response.headers, effectiveMethod);
486
+ const seenRequest = adapterRequest;
487
+ return formattedErrorResponse({
488
+ formatter: (error) => errorFormatter(error, seenRequest),
489
+ error: internalError,
490
+ responseHeaders: response.headers,
491
+ context,
492
+ });
493
+ }
494
+ // A schmock() admission's handle() already normalized its own output
495
+ // for this method; with no hook to replace or mutate it, a second
496
+ // pass would only re-validate the same tree. Anything else, including
497
+ // a hand-written admission's response, is re-normalized.
498
+ if (isNormalizedAdmission(admission) && beforeResponse === undefined) {
499
+ return createFetchResponse(withDefaultContentType(schmockResponse), context);
433
500
  }
434
- return toFetchResponse(response, effectiveMethod);
501
+ return toFetchResponse(response, context);
435
502
  }
436
503
  catch (error) {
437
504
  throwIfAborted(request.signal);
@@ -439,13 +506,23 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
439
506
  throw error;
440
507
  }
441
508
  if (errorFormatter) {
442
- const formatted = errorFormatter(error instanceof Error ? error : new Error(String(error)));
509
+ // A formatter that throws here propagates and rejects the fetch; a
510
+ // body it returns that cannot be serialized falls back to the same
511
+ // INTERNAL_ERROR body the core-marked path uses.
512
+ const failure = error instanceof Error ? error : new Error(String(error));
513
+ // No request was built when the body itself could not be read.
514
+ const formatted = errorFormatter(failure, formatterRequest ?? {
515
+ method: request.method,
516
+ path,
517
+ headers: extractHeaders(request),
518
+ query: extractQuery(url),
519
+ });
443
520
  throwIfAborted(request.signal);
444
- return toFetchResponse({
445
- status: 500,
446
- body: formatted,
447
- headers: { "content-type": "application/json" },
448
- }, effectiveMethod);
521
+ return formattedErrorResponse({
522
+ formatter: () => formatted,
523
+ error: failure,
524
+ context,
525
+ });
449
526
  }
450
527
  throw error;
451
528
  }
@@ -0,0 +1,27 @@
1
+ import type { DebugLogger } from "./debug-logger.js";
2
+ interface NodeServerControllerOptions {
3
+ /**
4
+ * Admit one request against the mock. Called on arrival, before the request
5
+ * is parsed, and released once it is answered.
6
+ */
7
+ readonly admitRequest: () => Schmock.RequestAdmission;
8
+ readonly logger: DebugLogger;
9
+ }
10
+ /**
11
+ * The standalone HTTP server behind `mock.listen()` / `mock.close()`.
12
+ *
13
+ * Owns the start and close state machines: at most one server is running or
14
+ * starting, a `close()` during start-up cancels the start, and a new start
15
+ * waits for every earlier server to finish closing (the close barrier) so a
16
+ * restart on the same port never races the old socket.
17
+ *
18
+ * `node:http` is imported lazily, on the first `listen()`, so a browser bundle
19
+ * that never listens never pulls it in (issue #395).
20
+ */
21
+ export declare class NodeServerController {
22
+ #private;
23
+ constructor(options: NodeServerControllerOptions);
24
+ listen(port: number, hostname: string): Promise<Schmock.ServerInfo>;
25
+ close(): void;
26
+ }
27
+ export {};