@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
@@ -1,15 +1,36 @@
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";
5
+ import { snapshotRequestBody } from "./snapshot.js";
6
6
  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".
7
+ // A lease whose baseUrl filter rejected the request never reached its handler:
8
+ // it was not interested in the request at all, and claimed nothing.
10
9
  const FILTERED = Symbol("schmock.fetch.filtered");
10
+ // A newer lease of the same mock already asked it this exact request, so
11
+ // asking again would only repeat the answer and its lifecycle events.
12
+ const ALREADY_CONSULTED = Symbol("schmock.fetch.already-consulted");
11
13
  const RELATIVE_REQUEST_BASE = "http://schmock.invalid/";
14
+ /**
15
+ * Marks an admission whose `handle()` already returns responses normalized
16
+ * for the request method (hop-by-hop headers dropped, a HEAD body stripped):
17
+ * only the admissions a `schmock()` instance of this copy creates. The
18
+ * interceptor re-normalizes the responses of any other admission, such as a
19
+ * hand-written one passed to `createFetchInterceptor`. Deliberately
20
+ * unregistered: an admission from a second copy of `@schmock/core` is simply
21
+ * normalized again.
22
+ */
23
+ export const NORMALIZED_ADMISSION_KEY = Symbol("schmock.normalized-admission");
24
+ function isNormalizedAdmission(admission) {
25
+ return (admission !== undefined &&
26
+ Reflect.get(admission, NORMALIZED_ADMISSION_KEY) === true);
27
+ }
12
28
  let activeSession;
29
+ /**
30
+ * Holds taken through acquireFetchRelay(). Module-wide rather than per
31
+ * session, so a dispatcher that a third-party wrapper captured obeys them too.
32
+ */
33
+ const fetchRelayHolds = new Set();
13
34
  function getRelativeRequestBase() {
14
35
  const candidates = [
15
36
  typeof document === "undefined" ? undefined : document.baseURI,
@@ -96,45 +117,101 @@ function normalizeFetchRequest(input, init) {
96
117
  origin,
97
118
  };
98
119
  }
120
+ /**
121
+ * Whether `error` is the signal's own abort. An abort that lands after the
122
+ * lease already rejected with another error leaves that error the outcome.
123
+ */
124
+ function isAbortOf(signal, error) {
125
+ if (!signal.aborted)
126
+ return false;
127
+ if ("reason" in signal && signal.reason !== undefined) {
128
+ return error === signal.reason;
129
+ }
130
+ return error instanceof Error && error.name === "AbortError";
131
+ }
132
+ async function routeThroughLeases(leases, normalizedRequest, startTime) {
133
+ const { signal } = normalizedRequest.request;
134
+ throwIfAborted(signal);
135
+ // A mock is asked each distinct effective request at most once, however
136
+ // many leases it holds: without this, nested providers on one mock would
137
+ // run handle() — and emit request:start/notfound/end — once per lease.
138
+ // The key is the request each lease would issue after its own baseUrl
139
+ // filter and beforeRequest, so an older lease whose hook rewrites the
140
+ // request (an outer provider stripping "/api") still gets its turn.
141
+ const consultedRequests = new Map();
142
+ const claimFor = (owner) => (requestKey) => {
143
+ if (owner === undefined)
144
+ return true;
145
+ let keys = consultedRequests.get(owner);
146
+ if (keys === undefined) {
147
+ keys = new Set();
148
+ consultedRequests.set(owner, keys);
149
+ }
150
+ if (keys.has(requestKey))
151
+ return false;
152
+ keys.add(requestKey);
153
+ return true;
154
+ };
155
+ for (let index = leases.length - 1; index >= 0; index -= 1) {
156
+ const registered = leases[index];
157
+ // Synchronously before the call: the handler admits before its first await,
158
+ // so the generation the opener captures is the one the request runs in.
159
+ const observe = registered.observe?.();
160
+ const draft = { observed: observe !== undefined };
161
+ let result;
162
+ try {
163
+ result = await awaitWithAbort(registered.intercept({
164
+ request: normalizedRequest,
165
+ claim: claimFor(registered.owner),
166
+ draft,
167
+ }), signal);
168
+ throwIfAborted(signal);
169
+ }
170
+ catch (error) {
171
+ if (observe !== undefined) {
172
+ if (!isAbortOf(signal, error)) {
173
+ notify(observe, () => failedExchange(normalizedRequest, draft, error, startTime));
174
+ }
175
+ else if (draft.response !== undefined || draft.answers?.() === true) {
176
+ notify(observe, () => abortedExchange(normalizedRequest, draft, startTime));
177
+ }
178
+ }
179
+ throw error;
180
+ }
181
+ // FILTERED: this lease was not interested, so a sibling lease may be.
182
+ // ALREADY_CONSULTED: the mock already answered this exact request.
183
+ // PASSTHROUGH: the mock has no route for it. All three move on.
184
+ if (result === FILTERED ||
185
+ result === ALREADY_CONSULTED ||
186
+ result === PASSTHROUGH) {
187
+ continue;
188
+ }
189
+ const response = result;
190
+ if (observe !== undefined) {
191
+ notify(observe, () => answeredExchange(normalizedRequest, draft, response, startTime));
192
+ }
193
+ return response;
194
+ }
195
+ return PASSTHROUGH;
196
+ }
99
197
  function createInterceptorSession() {
100
198
  const baselineFetch = globalThis.fetch;
101
199
  const interceptors = [];
102
200
  const dispatchFetch = async (input, init) => {
103
201
  const snapshot = interceptors.slice();
104
- if (snapshot.length === 0) {
202
+ if (snapshot.length === 0 || fetchRelayHolds.size > 0) {
105
203
  return baselineFetch(input, init);
106
204
  }
205
+ const startTime = performance.now();
107
206
  const normalizedRequest = normalizeFetchRequest(input, init);
108
- 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();
113
- for (let index = snapshot.length - 1; index >= 0; index -= 1) {
114
- 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);
120
- 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) {
124
- continue;
125
- }
126
- if (result !== PASSTHROUGH) {
127
- return result;
128
- }
129
- if (owner !== undefined) {
130
- consultedOwners.add(owner);
131
- }
132
- }
207
+ const answer = await routeThroughLeases(snapshot, normalizedRequest, startTime);
208
+ if (answer !== PASSTHROUGH)
209
+ return answer;
133
210
  return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
134
211
  };
135
212
  return { baselineFetch, dispatchFetch, interceptors };
136
213
  }
137
- function registerInterceptor(intercept, applyOptions, owner) {
214
+ function registerInterceptor(intercept, applyOptions, owner, observe) {
138
215
  let session = activeSession;
139
216
  if (!session || globalThis.fetch !== session.dispatchFetch) {
140
217
  session = createInterceptorSession();
@@ -142,7 +219,7 @@ function registerInterceptor(intercept, applyOptions, owner) {
142
219
  globalThis.fetch = session.dispatchFetch;
143
220
  }
144
221
  const token = Symbol("schmock.fetch.interceptor");
145
- session.interceptors.push({ token, owner, intercept });
222
+ session.interceptors.push({ token, owner, intercept, observe });
146
223
  let active = true;
147
224
  return {
148
225
  restore() {
@@ -177,57 +254,124 @@ function registerInterceptor(intercept, applyOptions, owner) {
177
254
  };
178
255
  }
179
256
  /**
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/".
257
+ * Makes every intercepted fetch skip routing and go straight to the baseline
258
+ * until released. Holds stack: fetches resume once every hold is released.
259
+ * It never touches `globalThis.fetch`.
187
260
  */
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 };
261
+ export function acquireFetchRelay() {
262
+ const token = Symbol("schmock.fetch.relay");
263
+ fetchRelayHolds.add(token);
264
+ return {
265
+ release() {
266
+ fetchRelayHolds.delete(token);
267
+ },
268
+ get active() {
269
+ return fetchRelayHolds.has(token);
270
+ },
271
+ };
272
+ }
273
+ /**
274
+ * Routes a request that a service worker relayed to the page through the
275
+ * newest session's leases. Resolves `undefined` when nothing answers it (no
276
+ * lease, or a route miss with passthrough) and never calls the baseline fetch,
277
+ * so the caller decides how the request reaches the network. Rejects with the
278
+ * request's abort reason when it is aborted mid-route.
279
+ */
280
+ export async function routeRelayedRequest(request) {
281
+ const startTime = performance.now();
282
+ const leases = activeSession?.interceptors.slice() ?? [];
283
+ if (leases.length === 0)
284
+ return undefined;
285
+ const normalizedRequest = normalizeFetchRequest(request);
286
+ const answer = await routeThroughLeases(leases, normalizedRequest, startTime);
287
+ return answer === PASSTHROUGH ? undefined : answer;
203
288
  }
204
289
  function extractQuery(url) {
205
290
  return Object.fromEntries(url.searchParams);
206
291
  }
207
- function extractHeaders(request) {
208
- const headers = {};
209
- request.headers.forEach((value, key) => {
210
- headers[key.toLowerCase()] = value;
292
+ function headerRecordOf(headers) {
293
+ const record = {};
294
+ headers.forEach((value, key) => {
295
+ record[key.toLowerCase()] = value;
211
296
  });
212
- return headers;
297
+ return record;
298
+ }
299
+ function extractHeaders(request) {
300
+ return headerRecordOf(request.headers);
301
+ }
302
+ /** Hand an exchange to its observer. */
303
+ function notify(observe, build) {
304
+ try {
305
+ observe(build());
306
+ }
307
+ catch {
308
+ // observation never changes the fetch outcome
309
+ }
310
+ }
311
+ function exchangeRequestOf({ request, url }, draft) {
312
+ return {
313
+ method: request.method,
314
+ url: responseUrlOf(url),
315
+ headers: extractHeaders(request),
316
+ ...(draft.requestBody !== undefined ? { body: draft.requestBody } : {}),
317
+ };
318
+ }
319
+ function answeredExchange(normalizedRequest, draft, response, startTime) {
320
+ return {
321
+ outcome: "answered",
322
+ request: exchangeRequestOf(normalizedRequest, draft),
323
+ response: {
324
+ status: response.status,
325
+ headers: headerRecordOf(response.headers),
326
+ ...(draft.response?.body !== undefined
327
+ ? { body: draft.response.body }
328
+ : {}),
329
+ },
330
+ startTime,
331
+ endTime: performance.now(),
332
+ };
333
+ }
334
+ function failedExchange(normalizedRequest, draft, error, startTime) {
335
+ return {
336
+ outcome: "failed",
337
+ request: exchangeRequestOf(normalizedRequest, draft),
338
+ error,
339
+ startTime,
340
+ endTime: performance.now(),
341
+ };
342
+ }
343
+ function abortedExchange(normalizedRequest, draft, startTime) {
344
+ return {
345
+ outcome: "aborted",
346
+ request: exchangeRequestOf(normalizedRequest, draft),
347
+ startTime,
348
+ endTime: performance.now(),
349
+ };
213
350
  }
214
351
  function normalizeMediaType(contentType) {
215
352
  return contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
216
353
  }
217
354
  async function extractBody(request) {
218
355
  if (request.body === null)
219
- return undefined;
220
- const body = request.clone();
356
+ return { value: undefined, malformedJson: false };
221
357
  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
- }
358
+ if (mediaType !== "application/json" && !mediaType.endsWith("+json")) {
359
+ return { value: await extractNonJsonBody(request), malformedJson: false };
230
360
  }
361
+ const text = await request.clone().text();
362
+ // An empty JSON body is no body at all, as the Node ingress reads it.
363
+ if (text === "")
364
+ return { value: undefined, malformedJson: false };
365
+ try {
366
+ return { value: JSON.parse(text), malformedJson: false };
367
+ }
368
+ catch {
369
+ return { value: text, malformedJson: true };
370
+ }
371
+ }
372
+ async function extractNonJsonBody(request) {
373
+ const body = request.clone();
374
+ const mediaType = normalizeMediaType(request.headers.get("content-type"));
231
375
  if (mediaType === "application/x-www-form-urlencoded") {
232
376
  return Object.fromEntries(new URLSearchParams(await body.text()));
233
377
  }
@@ -239,106 +383,118 @@ async function extractBody(request) {
239
383
  }
240
384
  return body.arrayBuffer();
241
385
  }
386
+ /** An admission's route probe (`hasRoute`), when it carries one. */
387
+ function routeProbeOf(admission) {
388
+ if (admission === undefined)
389
+ return undefined;
390
+ let probe;
391
+ try {
392
+ probe = Reflect.get(admission, "hasRoute");
393
+ }
394
+ catch {
395
+ return undefined;
396
+ }
397
+ if (typeof probe !== "function")
398
+ return undefined;
399
+ // Anything but a definite `false` counts as a route, so an unexpected
400
+ // answer, or a throw, only costs the body read the probe would have saved.
401
+ return (method, path) => {
402
+ try {
403
+ return Reflect.apply(probe, admission, [method, path]) !== false;
404
+ }
405
+ catch {
406
+ return true;
407
+ }
408
+ };
409
+ }
242
410
  function isAbortError(error) {
243
411
  return (typeof error === "object" &&
244
412
  error !== null &&
245
413
  "name" in error &&
246
414
  error.name === "AbortError");
247
415
  }
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, {
416
+ /**
417
+ * Build the fetch Response from an already-normalized Schmock response.
418
+ */
419
+ function createFetchResponse(normalized, context) {
420
+ const response = new Response(serializeResponseBody(normalized) ?? null, {
263
421
  status: normalized.status,
264
422
  headers: normalized.headers,
265
423
  });
424
+ // A constructed Response has an empty url. Real fetch reports the request
425
+ // URL, and code resolving links with `new URL(next, res.url)` needs it.
426
+ Object.defineProperty(response, "url", { value: context.url });
427
+ context.draft.response = normalized;
428
+ return response;
429
+ }
430
+ function toFetchResponse(response, context) {
431
+ return createFetchResponse(normalizeResponse(withDefaultContentType(response), context.method), context);
432
+ }
433
+ function jsonErrorResponse(input) {
434
+ return createFetchResponse(buildJsonErrorResponse({
435
+ status: input.status,
436
+ error: input.error,
437
+ code: input.code,
438
+ method: input.context.method,
439
+ }), input.context);
266
440
  }
267
441
  /**
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.
442
+ * A request this lease owns but cannot route: pass it on, or answer the same
443
+ * 404 a route miss gets when passthrough is off.
272
444
  */
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;
445
+ function unroutedResult(passthrough, context) {
446
+ if (passthrough)
447
+ return PASSTHROUGH;
448
+ return jsonErrorResponse({
449
+ status: 404,
450
+ error: "No matching mock route found",
451
+ code: "ROUTE_NOT_FOUND",
452
+ context,
453
+ });
282
454
  }
283
455
  /**
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.
456
+ * The fetch Response for an errorFormatter result, built by the shared
457
+ * {@link buildFormattedErrorResponse}. TOTAL: it never throws, and the
458
+ * formatter runs exactly once. It runs inside the interceptor's `try`, so an
459
+ * escaping error would land in the catch below and invoke the formatter a
460
+ * second time.
300
461
  */
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
- }
462
+ function formattedErrorResponse(input) {
463
+ return createFetchResponse(buildFormattedErrorResponse({
464
+ formatter: input.formatter,
465
+ error: input.error,
466
+ inheritedHeaders: input.responseHeaders,
467
+ method: input.context.method,
468
+ }), input.context);
469
+ }
470
+ /** Fetch reports the request URL without its fragment. */
471
+ function responseUrlOf(url) {
472
+ const responseUrl = new URL(url.href);
473
+ responseUrl.hash = "";
474
+ return responseUrl.href;
475
+ }
476
+ function effectiveRequestKey(method, path) {
477
+ return `${method} ${canonicalizePath(path)}`;
327
478
  }
328
479
  /**
329
480
  * Create a fetch interceptor that routes requests through mock.handle().
330
481
  *
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.
482
+ * `owner` identifies the mock behind the lease. Leases sharing an owner ask it
483
+ * each distinct effective request (method and path after the lease's own
484
+ * beforeRequest) at most once, so a mock held by several leases runs its
485
+ * handler — and emits its lifecycle events — once per request it is asked.
334
486
  */
335
487
  export function createFetchInterceptor(handle, options = {}, admitRequest, owner) {
488
+ return createFetchLease({ handle, options, admitRequest, owner });
489
+ }
490
+ export function createFetchLease(spec) {
491
+ const { handle, admitRequest, owner, observe } = spec;
336
492
  // The options live in a mutable cell that each request reads once at its
337
493
  // start. Reconfiguring a lease in place is what lets an adapter apply new
338
494
  // hooks without re-registering — re-registration would move the lease to the
339
495
  // front of the dispatch order and steal precedence from other mocks.
340
- let currentOptions = options;
341
- return registerInterceptor(async ({ request, url, origin }) => {
496
+ let currentOptions = spec.options ?? {};
497
+ return registerInterceptor(async ({ request: { request, url, origin }, claim, draft, }) => {
342
498
  const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
343
499
  const path = canonicalizePath(url.pathname);
344
500
  // BaseUrl filter — non-matching requests go straight to real fetch.
@@ -348,32 +504,83 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
348
504
  // - path form ("/api"): match pathname prefix only.
349
505
  // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
350
506
  if (baseUrl) {
351
- const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
352
- if (baseOrigin && origin !== baseOrigin) {
507
+ const base = parsePathPrefix(baseUrl);
508
+ if (base.origin && origin !== base.origin) {
353
509
  return FILTERED;
354
510
  }
355
- if (basePath) {
356
- const isMatch = path === basePath || path.startsWith(`${basePath}/`);
357
- if (!isMatch) {
358
- return FILTERED;
359
- }
511
+ if (!matchPathPrefix(base, path)) {
512
+ return FILTERED;
360
513
  }
361
514
  }
362
515
  throwIfAborted(request.signal);
363
- const initialMethod = toHttpMethod(request.method);
516
+ const context = {
517
+ method: request.method,
518
+ url: responseUrlOf(url),
519
+ draft,
520
+ };
521
+ const initialMethod = request.method.toUpperCase();
522
+ // Without a beforeRequest hook (which may change the method or path)
523
+ // the effective request is already known, so it is claimed before this
524
+ // lease can answer anything: once a newer lease of the same mock has
525
+ // passed it through, an older passthrough:false lease must not answer
526
+ // it with a 404 or a malformed-JSON 400. A lease with a hook claims only
527
+ // after the hook, so its own 400 for a malformed JSON body (which comes
528
+ // before the hook) still answers.
529
+ if (beforeRequest === undefined &&
530
+ !claim(effectiveRequestKey(initialMethod, path))) {
531
+ return ALREADY_CONSULTED;
532
+ }
533
+ // No route can ever match a method outside the supported set (WebDAV's
534
+ // PROPFIND, a CDN PURGE), so it is a miss like any other rather than a
535
+ // rejected fetch. Checked before admission, which it never needs.
536
+ if (!isHttpMethod(initialMethod)) {
537
+ return unroutedResult(passthrough, context);
538
+ }
539
+ context.method = initialMethod;
364
540
  const admission = admitRequest?.();
365
541
  const admittedHandle = admission?.handle ?? handle;
366
- let effectiveMethod = initialMethod;
542
+ // Without a beforeRequest hook (which receives the body and may change
543
+ // the method or path), the request handle() will see is already known,
544
+ // so a passthrough lease asks the admission whether any route answers
545
+ // it. On a definite miss the body is never read: the request reaches
546
+ // the network untouched, and handle() still runs, without a body, so
547
+ // request:start/notfound/end are emitted exactly as before.
548
+ const routeExists = routeProbeOf(admission);
549
+ const answersFor = (method, routedPath) => () => !passthrough ||
550
+ routeExists === undefined ||
551
+ routeExists(method, routedPath);
552
+ if (beforeRequest === undefined)
553
+ draft.answers = answersFor(initialMethod, path);
554
+ const routeProbe = passthrough && !beforeRequest ? routeExists : undefined;
555
+ // The request handed to errorFormatter: the latest one this lease built,
556
+ // so it reflects beforeRequest once that hook has returned.
557
+ let formatterRequest;
367
558
  try {
368
- const body = await awaitWithAbort(extractBody(request), request.signal);
559
+ const body = routeProbe !== undefined && !routeProbe(initialMethod, path)
560
+ ? { value: undefined, malformedJson: false }
561
+ : await awaitWithAbort(extractBody(request), request.signal);
562
+ if (draft.observed)
563
+ draft.requestBody = snapshotRequestBody(body.value);
369
564
  throwIfAborted(request.signal);
565
+ // With passthrough off the lease owns every request that reaches it,
566
+ // as the Node server does, so a JSON body that does not parse gets
567
+ // the server's 400 before any route runs or history records it.
568
+ if (body.malformedJson && !passthrough) {
569
+ return jsonErrorResponse({
570
+ status: 400,
571
+ error: "Malformed JSON request body",
572
+ code: "MALFORMED_JSON",
573
+ context,
574
+ });
575
+ }
370
576
  let adapterRequest = {
371
577
  method: request.method,
372
578
  path,
373
579
  headers: extractHeaders(request),
374
- body,
580
+ body: body.value,
375
581
  query: extractQuery(url),
376
582
  };
583
+ formatterRequest = adapterRequest;
377
584
  // Apply beforeRequest hook
378
585
  if (beforeRequest) {
379
586
  throwIfAborted(request.signal);
@@ -381,16 +588,30 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
381
588
  throwIfAborted(request.signal);
382
589
  if (modified) {
383
590
  adapterRequest = modified;
591
+ formatterRequest = modified;
384
592
  }
385
593
  }
386
594
  throwIfAborted(request.signal);
595
+ // A hook may produce a method no route can match; that is a miss
596
+ // too, as the Angular adapter treats it.
597
+ const effectiveMethod = adapterRequest.method.toUpperCase();
598
+ if (!isHttpMethod(effectiveMethod)) {
599
+ return unroutedResult(passthrough, context);
600
+ }
601
+ context.method = effectiveMethod;
602
+ if (beforeRequest !== undefined &&
603
+ !claim(effectiveRequestKey(effectiveMethod, adapterRequest.path))) {
604
+ return ALREADY_CONSULTED;
605
+ }
606
+ if (beforeRequest !== undefined) {
607
+ draft.answers = answersFor(effectiveMethod, adapterRequest.path);
608
+ }
387
609
  const requestOptions = {
388
610
  headers: adapterRequest.headers,
389
611
  body: adapterRequest.body,
390
612
  query: adapterRequest.query,
391
613
  signal: request.signal,
392
614
  };
393
- effectiveMethod = toHttpMethod(adapterRequest.method);
394
615
  const schmockResponse = await awaitWithAbort(admittedHandle(effectiveMethod, adapterRequest.path, requestOptions), request.signal);
395
616
  throwIfAborted(request.signal);
396
617
  // Exception provenance is carried on the response as a non-enumerable
@@ -401,17 +622,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
401
622
  const internalError = getResponseException(schmockResponse);
402
623
  // Route not found — passthrough or 404
403
624
  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);
625
+ return unroutedResult(passthrough, context);
415
626
  }
416
627
  // Apply beforeResponse hook
417
628
  let response = schmockResponse;
@@ -429,9 +640,22 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
429
640
  // Angular): a beforeResponse that rewrites an exception into a 503 or
430
641
  // a 200 is honoured instead of being forced back to a formatted 500.
431
642
  if (errorFormatter && internalError && response.status === 500) {
432
- return formatInterceptedError(errorFormatter, internalError, response.headers, effectiveMethod);
643
+ const seenRequest = adapterRequest;
644
+ return formattedErrorResponse({
645
+ formatter: (error) => errorFormatter(error, seenRequest),
646
+ error: internalError,
647
+ responseHeaders: response.headers,
648
+ context,
649
+ });
650
+ }
651
+ // A schmock() admission's handle() already normalized its own output
652
+ // for this method; with no hook to replace or mutate it, a second
653
+ // pass would only re-validate the same tree. Anything else, including
654
+ // a hand-written admission's response, is re-normalized.
655
+ if (isNormalizedAdmission(admission) && beforeResponse === undefined) {
656
+ return createFetchResponse(withDefaultContentType(schmockResponse), context);
433
657
  }
434
- return toFetchResponse(response, effectiveMethod);
658
+ return toFetchResponse(response, context);
435
659
  }
436
660
  catch (error) {
437
661
  throwIfAborted(request.signal);
@@ -439,13 +663,23 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
439
663
  throw error;
440
664
  }
441
665
  if (errorFormatter) {
442
- const formatted = errorFormatter(error instanceof Error ? error : new Error(String(error)));
666
+ // A formatter that throws here propagates and rejects the fetch; a
667
+ // body it returns that cannot be serialized falls back to the same
668
+ // INTERNAL_ERROR body the core-marked path uses.
669
+ const failure = error instanceof Error ? error : new Error(String(error));
670
+ // No request was built when the body itself could not be read.
671
+ const formatted = errorFormatter(failure, formatterRequest ?? {
672
+ method: request.method,
673
+ path,
674
+ headers: extractHeaders(request),
675
+ query: extractQuery(url),
676
+ });
443
677
  throwIfAborted(request.signal);
444
- return toFetchResponse({
445
- status: 500,
446
- body: formatted,
447
- headers: { "content-type": "application/json" },
448
- }, effectiveMethod);
678
+ return formattedErrorResponse({
679
+ formatter: () => formatted,
680
+ error: failure,
681
+ context,
682
+ });
449
683
  }
450
684
  throw error;
451
685
  }
@@ -454,5 +688,5 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
454
688
  }
455
689
  }, (nextOptions) => {
456
690
  currentOptions = nextOptions ?? {};
457
- }, owner);
691
+ }, owner, observe);
458
692
  }