@schmock/core 2.3.0 → 2.4.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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/abort.d.ts +3 -0
  4. package/dist/abort.d.ts.map +1 -0
  5. package/dist/abort.js +30 -0
  6. package/dist/builder.d.ts +31 -10
  7. package/dist/builder.d.ts.map +1 -1
  8. package/dist/builder.js +760 -190
  9. package/dist/constants.d.ts +38 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/constants.js +123 -2
  12. package/dist/errors.d.ts +14 -3
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +41 -6
  15. package/dist/helpers.d.ts +9 -0
  16. package/dist/helpers.d.ts.map +1 -1
  17. package/dist/helpers.js +18 -2
  18. package/dist/http-helpers.d.ts +39 -4
  19. package/dist/http-helpers.d.ts.map +1 -1
  20. package/dist/http-helpers.js +147 -49
  21. package/dist/index.d.ts +292 -34
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +8 -3
  24. package/dist/interceptor.d.ts +11 -1
  25. package/dist/interceptor.d.ts.map +1 -1
  26. package/dist/interceptor.js +316 -169
  27. package/dist/parser.d.ts.map +1 -1
  28. package/dist/parser.js +12 -3
  29. package/dist/plugin-pipeline.d.ts +3 -3
  30. package/dist/plugin-pipeline.d.ts.map +1 -1
  31. package/dist/plugin-pipeline.js +29 -14
  32. package/dist/response-normalizer.d.ts +16 -0
  33. package/dist/response-normalizer.d.ts.map +1 -0
  34. package/dist/response-normalizer.js +316 -0
  35. package/dist/response-parser.d.ts.map +1 -1
  36. package/dist/response-parser.js +39 -2
  37. package/dist/route-matcher.d.ts +3 -0
  38. package/dist/route-matcher.d.ts.map +1 -1
  39. package/dist/route-matcher.js +12 -6
  40. package/dist/types.d.ts +7 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +18 -6
  43. package/src/audit-core-builder.test.ts +0 -218
  44. package/src/audit-response-guard.test.ts +0 -38
  45. package/src/binary.test.ts +0 -126
  46. package/src/binary.ts +0 -11
  47. package/src/builder.test.ts +0 -289
  48. package/src/builder.ts +0 -748
  49. package/src/constants.test.ts +0 -99
  50. package/src/constants.ts +0 -73
  51. package/src/debug.test.ts +0 -241
  52. package/src/delay.test.ts +0 -319
  53. package/src/dist-shape.test.ts +0 -35
  54. package/src/errors.test.ts +0 -223
  55. package/src/errors.ts +0 -130
  56. package/src/factory.test.ts +0 -133
  57. package/src/helpers.test.ts +0 -147
  58. package/src/helpers.ts +0 -58
  59. package/src/http-helpers.test.ts +0 -39
  60. package/src/http-helpers.ts +0 -149
  61. package/src/index.ts +0 -152
  62. package/src/interceptor.test.ts +0 -488
  63. package/src/interceptor.ts +0 -407
  64. package/src/namespace.test.ts +0 -274
  65. package/src/parser.property.test.ts +0 -630
  66. package/src/parser.test.ts +0 -148
  67. package/src/parser.ts +0 -64
  68. package/src/plugin-pipeline.ts +0 -208
  69. package/src/plugin-system.test.ts +0 -697
  70. package/src/response-parser.ts +0 -114
  71. package/src/response-parsing.test.ts +0 -333
  72. package/src/route-matcher.ts +0 -69
  73. package/src/route-matching.test.ts +0 -394
  74. package/src/server.test.ts +0 -234
  75. package/src/smart-defaults.test.ts +0 -361
  76. package/src/steps/async-support.steps.ts +0 -421
  77. package/src/steps/audit-core-builder.steps.ts +0 -188
  78. package/src/steps/audit-onerror-tuple.steps.ts +0 -83
  79. package/src/steps/basic-usage.steps.ts +0 -245
  80. package/src/steps/developer-experience.steps.ts +0 -204
  81. package/src/steps/error-handling.steps.ts +0 -555
  82. package/src/steps/fetch-interceptor.steps.ts +0 -457
  83. package/src/steps/fluent-api.steps.ts +0 -255
  84. package/src/steps/http-methods.steps.ts +0 -342
  85. package/src/steps/lifecycle-events.steps.ts +0 -149
  86. package/src/steps/performance-reliability.steps.ts +0 -90
  87. package/src/steps/plugin-integration.steps.ts +0 -334
  88. package/src/steps/request-history.steps.ts +0 -458
  89. package/src/steps/response-delay.steps.ts +0 -88
  90. package/src/steps/route-key-format.steps.ts +0 -99
  91. package/src/steps/standalone-server.steps.ts +0 -227
  92. package/src/steps/state-concurrency.steps.ts +0 -167
  93. package/src/types.ts +0 -36
@@ -1,31 +1,140 @@
1
1
  /// <reference path="../schmock.d.ts" />
2
+ import { awaitWithAbort, throwIfAborted } from "./abort.js";
2
3
  import { isBinaryBody } from "./binary.js";
3
- import { isRouteNotFound, toHttpMethod } from "./constants.js";
4
+ import { canonicalizePath, getResponseException, isRouteNotFound, toHttpMethod, } from "./constants.js";
5
+ import { normalizeResponse, serializeResponseBody, } from "./response-normalizer.js";
4
6
  const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
5
- function toFetchBinaryBody(body) {
6
- if (body instanceof ArrayBuffer)
7
- return body;
8
- const bytes = new Uint8Array(body.byteLength);
9
- bytes.set(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
10
- return bytes;
11
- }
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".
10
+ const FILTERED = Symbol("schmock.fetch.filtered");
11
+ const RELATIVE_REQUEST_BASE = "http://schmock.invalid/";
12
12
  let activeSession;
13
+ function getRelativeRequestBase() {
14
+ const candidates = [
15
+ typeof document === "undefined" ? undefined : document.baseURI,
16
+ typeof location === "undefined" ? undefined : location.href,
17
+ ];
18
+ for (const candidate of candidates) {
19
+ if (!candidate)
20
+ continue;
21
+ try {
22
+ return new URL(candidate).href;
23
+ }
24
+ catch {
25
+ // Ignore invalid environment globals and use the next fallback.
26
+ }
27
+ }
28
+ return RELATIVE_REQUEST_BASE;
29
+ }
30
+ // The Fetch standard stamps a content type when the body is extracted from a
31
+ // string or URLSearchParams. Node's Request constructor conforms; Bun's omits
32
+ // the header, so identical consumer code would otherwise deliver a string
33
+ // body on Node and an opaque ArrayBuffer on Bun.
34
+ function stampBodyContentType(request, body) {
35
+ if (body == null || request.headers.has("content-type"))
36
+ return;
37
+ if (typeof body === "string") {
38
+ request.headers.set("content-type", "text/plain;charset=UTF-8");
39
+ }
40
+ else if (body instanceof URLSearchParams) {
41
+ request.headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
42
+ }
43
+ }
44
+ function normalizeFetchRequest(input, init) {
45
+ if (input instanceof Request) {
46
+ // Constructing a Request from another Request transfers its body. Use a
47
+ // clone when the body is inherited so the original remains passthrough-safe.
48
+ const source = init?.body == null ? input.clone() : input;
49
+ const request = new Request(source, init);
50
+ if (init?.body != null) {
51
+ stampBodyContentType(request, init.body);
52
+ }
53
+ else if (request.body != null && !request.headers.has("content-type")) {
54
+ // The body was inherited from the input Request, but init.headers
55
+ // replaces the whole header list, dropping the content type stamped
56
+ // when that body was extracted. Restore it so the handler still sees
57
+ // the body as its original kind. (Bun never stamps at construction,
58
+ // so a string body on a type-less input Request stays opaque there.)
59
+ const inheritedType = input.headers.get("content-type");
60
+ if (inheritedType !== null) {
61
+ request.headers.set("content-type", inheritedType);
62
+ }
63
+ }
64
+ const url = new URL(request.url);
65
+ return {
66
+ request,
67
+ url,
68
+ origin: url.origin,
69
+ };
70
+ }
71
+ if (input instanceof URL) {
72
+ const request = new Request(input, init);
73
+ stampBodyContentType(request, init?.body);
74
+ const url = new URL(request.url);
75
+ return {
76
+ request,
77
+ url,
78
+ origin: url.origin,
79
+ };
80
+ }
81
+ let inputUrl;
82
+ let origin;
83
+ try {
84
+ inputUrl = new URL(input);
85
+ origin = inputUrl.origin;
86
+ }
87
+ catch {
88
+ inputUrl = new URL(input, getRelativeRequestBase());
89
+ origin = null;
90
+ }
91
+ const request = new Request(inputUrl, init);
92
+ stampBodyContentType(request, init?.body);
93
+ return {
94
+ request,
95
+ url: new URL(request.url),
96
+ origin,
97
+ };
98
+ }
13
99
  function createInterceptorSession() {
14
100
  const baselineFetch = globalThis.fetch;
15
101
  const interceptors = [];
16
102
  const dispatchFetch = async (input, init) => {
17
103
  const snapshot = interceptors.slice();
104
+ if (snapshot.length === 0) {
105
+ return baselineFetch(input, init);
106
+ }
107
+ 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();
18
113
  for (let index = snapshot.length - 1; index >= 0; index -= 1) {
19
- const result = await snapshot[index].intercept(input, init);
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
+ }
20
126
  if (result !== PASSTHROUGH) {
21
127
  return result;
22
128
  }
129
+ if (owner !== undefined) {
130
+ consultedOwners.add(owner);
131
+ }
23
132
  }
24
- return baselineFetch(input, init);
133
+ return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
25
134
  };
26
135
  return { baselineFetch, dispatchFetch, interceptors };
27
136
  }
28
- function registerInterceptor(intercept) {
137
+ function registerInterceptor(intercept, applyOptions, owner) {
29
138
  let session = activeSession;
30
139
  if (!session || globalThis.fetch !== session.dispatchFetch) {
31
140
  session = createInterceptorSession();
@@ -33,7 +142,7 @@ function registerInterceptor(intercept) {
33
142
  globalThis.fetch = session.dispatchFetch;
34
143
  }
35
144
  const token = Symbol("schmock.fetch.interceptor");
36
- session.interceptors.push({ token, intercept });
145
+ session.interceptors.push({ token, owner, intercept });
37
146
  let active = true;
38
147
  return {
39
148
  restore() {
@@ -55,44 +164,18 @@ function registerInterceptor(intercept) {
55
164
  globalThis.fetch = session.baselineFetch;
56
165
  }
57
166
  },
167
+ update(options) {
168
+ // Reconfiguring never touches session.interceptors, so the lease keeps
169
+ // the dispatch position it was registered with.
170
+ if (!active)
171
+ return;
172
+ applyOptions(options);
173
+ },
58
174
  get active() {
59
175
  return active;
60
176
  },
61
177
  };
62
178
  }
63
- /**
64
- * Extract pathname from a URL string, handling both absolute and relative URLs.
65
- */
66
- function extractPathname(url) {
67
- const queryStart = url.indexOf("?");
68
- const urlWithoutQuery = queryStart === -1 ? url : url.slice(0, queryStart);
69
- if (urlWithoutQuery.includes("://")) {
70
- try {
71
- return new URL(urlWithoutQuery).pathname;
72
- }
73
- catch {
74
- // Fall through to simple extraction
75
- }
76
- }
77
- if (!urlWithoutQuery.startsWith("/")) {
78
- return `/${urlWithoutQuery}`;
79
- }
80
- return urlWithoutQuery;
81
- }
82
- /**
83
- * Extract origin (scheme + host + port) from a URL string, or null for
84
- * relative URLs that don't carry one.
85
- */
86
- function extractOrigin(url) {
87
- if (!url.includes("://"))
88
- return null;
89
- try {
90
- return new URL(url).origin;
91
- }
92
- catch {
93
- return null;
94
- }
95
- }
96
179
  /**
97
180
  * Parse the user-supplied baseUrl option into its origin and path parts.
98
181
  * - "/api" → { origin: null, path: "/api" }
@@ -106,109 +189,158 @@ function parseBaseUrl(baseUrl) {
106
189
  if (baseUrl.includes("://")) {
107
190
  try {
108
191
  const parsed = new URL(baseUrl);
109
- const rawPath = parsed.pathname;
110
- const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
192
+ const canonicalPath = canonicalizePath(parsed.pathname);
193
+ const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
111
194
  return { origin: parsed.origin, path };
112
195
  }
113
196
  catch {
114
197
  // Fall through to path-only handling
115
198
  }
116
199
  }
117
- return { origin: null, path: baseUrl.replace(/\/$/, "") };
200
+ const canonicalPath = canonicalizePath(baseUrl);
201
+ const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
202
+ return { origin: null, path };
118
203
  }
119
- /**
120
- * Extract query parameters from a URL string.
121
- */
122
204
  function extractQuery(url) {
123
- const queryStart = url.indexOf("?");
124
- if (queryStart === -1)
125
- return {};
126
- const params = new URLSearchParams(url.slice(queryStart + 1));
127
- const result = {};
128
- params.forEach((value, key) => {
129
- result[key] = value;
130
- });
131
- return result;
205
+ return Object.fromEntries(url.searchParams);
132
206
  }
133
- /**
134
- * Extract headers from fetch init or Request object.
135
- */
136
- function extractHeaders(input, init) {
207
+ function extractHeaders(request) {
137
208
  const headers = {};
138
- const raw = init?.headers ?? (input instanceof Request ? input.headers : undefined);
139
- if (!raw)
140
- return headers;
141
- if (raw instanceof Headers) {
142
- raw.forEach((value, key) => {
143
- headers[key.toLowerCase()] = value;
144
- });
145
- }
146
- else if (Array.isArray(raw)) {
147
- for (const [key, value] of raw) {
148
- headers[key.toLowerCase()] = value;
149
- }
150
- }
151
- else {
152
- for (const key of Object.keys(raw)) {
153
- headers[key.toLowerCase()] = raw[key];
154
- }
155
- }
209
+ request.headers.forEach((value, key) => {
210
+ headers[key.toLowerCase()] = value;
211
+ });
156
212
  return headers;
157
213
  }
158
- /**
159
- * Extract body from fetch init, parsing JSON when possible.
160
- */
161
- async function extractBody(input, init) {
162
- // Per Fetch spec, init.body overrides Request.body when both are present
163
- const bodyInit = init?.body ?? (input instanceof Request ? input.body : null);
164
- if (bodyInit === null || bodyInit === undefined)
214
+ function normalizeMediaType(contentType) {
215
+ return contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
216
+ }
217
+ async function extractBody(request) {
218
+ if (request.body === null)
165
219
  return undefined;
166
- // String body — try to parse as JSON
167
- if (typeof bodyInit === "string") {
220
+ const body = request.clone();
221
+ const mediaType = normalizeMediaType(request.headers.get("content-type"));
222
+ if (mediaType === "application/json" || mediaType.endsWith("+json")) {
223
+ const text = await body.text();
168
224
  try {
169
- return JSON.parse(bodyInit);
225
+ return JSON.parse(text);
170
226
  }
171
227
  catch {
172
- return bodyInit;
228
+ return text;
173
229
  }
174
230
  }
175
- // URLSearchParams — convert to key/value object
176
- if (bodyInit instanceof URLSearchParams) {
177
- const result = {};
178
- bodyInit.forEach((value, key) => {
179
- result[key] = value;
180
- });
181
- return result;
231
+ if (mediaType === "application/x-www-form-urlencoded") {
232
+ return Object.fromEntries(new URLSearchParams(await body.text()));
233
+ }
234
+ if (mediaType.startsWith("text/")) {
235
+ return body.text();
236
+ }
237
+ if (mediaType.startsWith("multipart/")) {
238
+ return body.formData();
239
+ }
240
+ return body.arrayBuffer();
241
+ }
242
+ function isAbortError(error) {
243
+ return (typeof error === "object" &&
244
+ error !== null &&
245
+ "name" in error &&
246
+ error.name === "AbortError");
247
+ }
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
+ }
182
260
  }
183
- // Request with body — clone and read
184
- if (input instanceof Request && !init?.body && input.body) {
261
+ const normalized = normalizeResponse({ ...response, headers }, method);
262
+ return new Response(serializeResponseBody(normalized) ?? null, {
263
+ status: normalized.status,
264
+ headers: normalized.headers,
265
+ });
266
+ }
267
+ /**
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.
272
+ */
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;
282
+ }
283
+ /**
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.
300
+ */
301
+ function formatInterceptedError(errorFormatter, error, responseHeaders, method) {
302
+ try {
303
+ const formatted = errorFormatter(error);
185
304
  try {
186
- return await input.clone().json();
305
+ return toFetchResponse({
306
+ status: 500,
307
+ body: formatted,
308
+ headers: withJsonContentType(responseHeaders),
309
+ }, method);
187
310
  }
188
311
  catch {
189
- try {
190
- return await input.clone().text();
191
- }
192
- catch {
193
- return undefined;
194
- }
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);
195
318
  }
196
319
  }
197
- return undefined;
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
+ }
198
327
  }
199
328
  /**
200
329
  * Create a fetch interceptor that routes requests through mock.handle().
330
+ *
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.
201
334
  */
202
- export function createFetchInterceptor(handle, options = {}) {
203
- const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = options;
204
- return registerInterceptor(async (input, init) => {
205
- // Resolve the URL string
206
- const urlString = input instanceof Request
207
- ? input.url
208
- : input instanceof URL
209
- ? input.href
210
- : input;
211
- const path = extractPathname(urlString);
335
+ export function createFetchInterceptor(handle, options = {}, admitRequest, owner) {
336
+ // The options live in a mutable cell that each request reads once at its
337
+ // start. Reconfiguring a lease in place is what lets an adapter apply new
338
+ // hooks without re-registering — re-registration would move the lease to the
339
+ // front of the dispatch order and steal precedence from other mocks.
340
+ let currentOptions = options;
341
+ return registerInterceptor(async ({ request, url, origin }) => {
342
+ const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
343
+ const path = canonicalizePath(url.pathname);
212
344
  // BaseUrl filter — non-matching requests go straight to real fetch.
213
345
  // Two modes:
214
346
  // - origin form ("https://api.example.com/v1"): require matching
@@ -217,95 +349,110 @@ export function createFetchInterceptor(handle, options = {}) {
217
349
  // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
218
350
  if (baseUrl) {
219
351
  const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
220
- if (baseOrigin) {
221
- const reqOrigin = extractOrigin(urlString);
222
- if (reqOrigin !== baseOrigin) {
223
- return PASSTHROUGH;
224
- }
352
+ if (baseOrigin && origin !== baseOrigin) {
353
+ return FILTERED;
225
354
  }
226
355
  if (basePath) {
227
356
  const isMatch = path === basePath || path.startsWith(`${basePath}/`);
228
357
  if (!isMatch) {
229
- return PASSTHROUGH;
358
+ return FILTERED;
230
359
  }
231
360
  }
232
361
  }
233
- // Build adapter request
234
- const method = input instanceof Request ? input.method : (init?.method ?? "GET");
235
- const headers = extractHeaders(input, init);
236
- const query = extractQuery(urlString);
237
- const body = await extractBody(input, init);
238
- let adapterRequest = {
239
- method,
240
- path,
241
- headers,
242
- body,
243
- query,
244
- };
362
+ throwIfAborted(request.signal);
363
+ const initialMethod = toHttpMethod(request.method);
364
+ const admission = admitRequest?.();
365
+ const admittedHandle = admission?.handle ?? handle;
366
+ let effectiveMethod = initialMethod;
245
367
  try {
368
+ const body = await awaitWithAbort(extractBody(request), request.signal);
369
+ throwIfAborted(request.signal);
370
+ let adapterRequest = {
371
+ method: request.method,
372
+ path,
373
+ headers: extractHeaders(request),
374
+ body,
375
+ query: extractQuery(url),
376
+ };
246
377
  // Apply beforeRequest hook
247
378
  if (beforeRequest) {
248
- const modified = await beforeRequest(adapterRequest);
379
+ throwIfAborted(request.signal);
380
+ const modified = await awaitWithAbort(beforeRequest(adapterRequest), request.signal);
381
+ throwIfAborted(request.signal);
249
382
  if (modified) {
250
383
  adapterRequest = modified;
251
384
  }
252
385
  }
253
- const schmockResponse = await handle(toHttpMethod(adapterRequest.method), adapterRequest.path, {
386
+ throwIfAborted(request.signal);
387
+ const requestOptions = {
254
388
  headers: adapterRequest.headers,
255
389
  body: adapterRequest.body,
256
390
  query: adapterRequest.query,
257
- });
391
+ signal: request.signal,
392
+ };
393
+ effectiveMethod = toHttpMethod(adapterRequest.method);
394
+ const schmockResponse = await awaitWithAbort(admittedHandle(effectiveMethod, adapterRequest.path, requestOptions), request.signal);
395
+ throwIfAborted(request.signal);
396
+ // Exception provenance is carried on the response as a non-enumerable
397
+ // symbol, so it must be read BEFORE beforeResponse runs: the
398
+ // documented `{...response}` hook pattern copies only own enumerable
399
+ // properties and would otherwise strip the mark, silently bypassing
400
+ // errorFormatter.
401
+ const internalError = getResponseException(schmockResponse);
258
402
  // Route not found — passthrough or 404
259
403
  if (isRouteNotFound(schmockResponse)) {
260
404
  if (passthrough) {
261
405
  return PASSTHROUGH;
262
406
  }
263
- return new Response(JSON.stringify({
264
- error: "No matching mock route found",
265
- code: "ROUTE_NOT_FOUND",
266
- }), {
407
+ return toFetchResponse({
267
408
  status: 404,
409
+ body: {
410
+ error: "No matching mock route found",
411
+ code: "ROUTE_NOT_FOUND",
412
+ },
268
413
  headers: { "content-type": "application/json" },
269
- });
414
+ }, effectiveMethod);
270
415
  }
271
416
  // Apply beforeResponse hook
272
417
  let response = schmockResponse;
273
418
  if (beforeResponse) {
274
- const modified = await beforeResponse(response, adapterRequest);
419
+ throwIfAborted(request.signal);
420
+ const modified = await awaitWithAbort(beforeResponse(response, adapterRequest), request.signal);
421
+ throwIfAborted(request.signal);
275
422
  if (modified) {
276
423
  response = modified;
277
424
  }
278
425
  }
279
- // Build fetch Response
280
- const responseHeaders = new Headers(response.headers);
281
- if (!responseHeaders.has("content-type") &&
282
- response.body !== null &&
283
- response.body !== undefined) {
284
- responseHeaders.set("content-type", isBinaryBody(response.body)
285
- ? "application/octet-stream"
286
- : "application/json");
426
+ // Only core-marked exceptions reach errorFormatter; a user-defined 500
427
+ // with an error-shaped body stays an ordinary domain response. The
428
+ // POST-hook status gates the replacement (matching Express and
429
+ // Angular): a beforeResponse that rewrites an exception into a 503 or
430
+ // a 200 is honoured instead of being forced back to a formatted 500.
431
+ if (errorFormatter && internalError && response.status === 500) {
432
+ return formatInterceptedError(errorFormatter, internalError, response.headers, effectiveMethod);
287
433
  }
288
- const responseBody = response.body === null || response.body === undefined
289
- ? null
290
- : typeof response.body === "string"
291
- ? response.body
292
- : isBinaryBody(response.body)
293
- ? toFetchBinaryBody(response.body)
294
- : JSON.stringify(response.body);
295
- return new Response(responseBody, {
296
- status: response.status,
297
- headers: responseHeaders,
298
- });
434
+ return toFetchResponse(response, effectiveMethod);
299
435
  }
300
436
  catch (error) {
437
+ throwIfAborted(request.signal);
438
+ if (isAbortError(error)) {
439
+ throw error;
440
+ }
301
441
  if (errorFormatter) {
302
442
  const formatted = errorFormatter(error instanceof Error ? error : new Error(String(error)));
303
- return new Response(JSON.stringify(formatted), {
443
+ throwIfAborted(request.signal);
444
+ return toFetchResponse({
304
445
  status: 500,
446
+ body: formatted,
305
447
  headers: { "content-type": "application/json" },
306
- });
448
+ }, effectiveMethod);
307
449
  }
308
450
  throw error;
309
451
  }
310
- });
452
+ finally {
453
+ admission?.release();
454
+ }
455
+ }, (nextOptions) => {
456
+ currentOptions = nextOptions ?? {};
457
+ }, owner);
311
458
  }
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAyC3D"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAmD3D"}
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { toHttpMethod } from "./constants.js";
1
+ import { canonicalizePath, normalizePath, toHttpMethod } from "./constants.js";
2
2
  import { RouteParseError } from "./errors.js";
3
3
  /**
4
4
  * Parse 'METHOD /path' route key format
@@ -12,11 +12,20 @@ import { RouteParseError } from "./errors.js";
12
12
  * // => { method: 'GET', path: '/users/:id', pattern: /^\/users\/([^/]+)$/, params: ['id'] }
13
13
  */
14
14
  export function parseRouteKey(routeKey) {
15
- const match = routeKey.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (.+)$/);
15
+ // The path group must start with "/": every transport (server, interceptor,
16
+ // adapters) delivers a leading-slash pathname, so a slash-less key such as
17
+ // "GET users" compiles a route no request can ever reach. `.*` is kept
18
+ // deliberately — hostile-but-reachable segments (spaces, tabs, unicode) must
19
+ // still parse.
20
+ const match = routeKey.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (\/.*)$/);
16
21
  if (!match) {
17
22
  throw new RouteParseError(routeKey, 'Expected format: "METHOD /path" (e.g., "GET /users")');
18
23
  }
19
- const [, method, path] = match;
24
+ const [, method, rawPath] = match;
25
+ // Canonicalize once, here, so `path`, `pattern`, the duplicate check, the
26
+ // static-route Map key and getRoutes() all agree on one spelling: the
27
+ // percent-encoded transport form with a single trailing slash stripped.
28
+ const path = normalizePath(canonicalizePath(rawPath));
20
29
  // Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
21
30
  // literals (".json", brackets, parens, etc.) terminate the name and can
22
31
  // be escaped without bleeding into the parameter regex. Build the
@@ -9,14 +9,14 @@ interface PipelineResult {
9
9
  requestShortCircuited?: boolean;
10
10
  }
11
11
  /** Run request guards before route code is allowed to execute. */
12
- export declare function runPluginBeforeRequest(plugins: Schmock.Plugin[], context: Schmock.PluginContext, logger: PipelineLogger): Promise<PipelineResult>;
12
+ export declare function runPluginBeforeRequest(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
13
13
  /** Give pipeline error handlers a chance to recover a route-generator error. */
14
- export declare function recoverGeneratorError(plugins: Schmock.Plugin[], context: Schmock.PluginContext, error: unknown, logger: PipelineLogger): Promise<PipelineResult>;
14
+ export declare function recoverGeneratorError(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, error: unknown, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
15
15
  /**
16
16
  * Run all registered plugins in sequence
17
17
  * First plugin to set response becomes generator, subsequent plugins transform
18
18
  * Handles plugin errors via onError hooks
19
19
  */
20
- export declare function runPluginPipeline(plugins: Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger): Promise<PipelineResult>;
20
+ export declare function runPluginPipeline(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
21
21
  export {};
22
22
  //# sourceMappingURL=plugin-pipeline.d.ts.map