@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,5 +1,7 @@
1
- import { isBinaryBody } from "./binary.js";
2
- import { serializeResponseBody } from "./response-normalizer.js";
1
+ import { HTTP_METHODS, isHttpMethod } from "./constants.js";
2
+ import { SchmockError } from "./errors.js";
3
+ import { getHeader, hasHeader } from "./headers.js";
4
+ import { buildJsonErrorResponse, serializeResponseBody, withDefaultContentType, } from "./response-normalizer.js";
3
5
  /** An HTTP client error raised while collecting an incoming request body. */
4
6
  export class HttpIngressError extends Error {
5
7
  status;
@@ -23,6 +25,7 @@ export function parseNodeHeaders(req) {
23
25
  }
24
26
  /**
25
27
  * Extract query parameters from a URL as a flat Record<string, string>.
28
+ * A repeated key resolves to its LAST value; every adapter follows this rule.
26
29
  */
27
30
  export function parseNodeQuery(url) {
28
31
  // Own-property definition for the same reason as parseNodeHeaders, and it
@@ -30,7 +33,7 @@ export function parseNodeQuery(url) {
30
33
  return Object.fromEntries(url.searchParams);
31
34
  }
32
35
  /** Default body size limit: 10 MB */
33
- const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
36
+ export const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
34
37
  const DECIMAL_CONTENT_LENGTH = /^\d+$/;
35
38
  function payloadTooLargeError() {
36
39
  return new HttpIngressError(413, "PAYLOAD_TOO_LARGE", "Request body too large");
@@ -40,21 +43,109 @@ function requestAbortedError() {
40
43
  error.name = "AbortError";
41
44
  return error;
42
45
  }
43
- function isJsonMediaType(contentType) {
44
- const baseMediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
45
- return (baseMediaType === "application/json" || baseMediaType.endsWith("+json"));
46
+ /**
47
+ * Deepest JSON nesting a request body may carry. A body nested far deeper
48
+ * parses fine but cannot be serialized back (JSON.stringify overflows the
49
+ * stack), so a handler that stores it poisons every later response that
50
+ * includes it. Rejecting it at ingress keeps that from ever happening.
51
+ */
52
+ const MAX_JSON_BODY_DEPTH = 256;
53
+ function baseMediaTypeOf(contentType) {
54
+ return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
55
+ }
56
+ function isJsonMediaType(mediaType) {
57
+ return mediaType === "application/json" || mediaType.endsWith("+json");
58
+ }
59
+ /** Whether a parsed JSON value nests deeper than `maxDepth` containers. */
60
+ function exceedsJsonDepth(value, maxDepth) {
61
+ const pending = [
62
+ { value, depth: 0 },
63
+ ];
64
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
65
+ if (typeof next.value !== "object" || next.value === null)
66
+ continue;
67
+ const depth = next.depth + 1;
68
+ if (depth > maxDepth)
69
+ return true;
70
+ for (const child of Object.values(next.value)) {
71
+ pending.push({ value: child, depth });
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+ function parseJsonBody(text) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(text);
80
+ }
81
+ catch {
82
+ throw new HttpIngressError(400, "MALFORMED_JSON", "Malformed JSON request body");
83
+ }
84
+ if (exceedsJsonDepth(parsed, MAX_JSON_BODY_DEPTH)) {
85
+ throw new HttpIngressError(400, "JSON_TOO_DEEP", `JSON request body nests deeper than ${MAX_JSON_BODY_DEPTH} levels`);
86
+ }
87
+ return parsed;
88
+ }
89
+ async function parseMultipartBody(bytes, contentType) {
90
+ try {
91
+ return await new Response(bytes, {
92
+ headers: { "content-type": contentType },
93
+ }).formData();
94
+ }
95
+ catch {
96
+ throw new HttpIngressError(400, "MALFORMED_MULTIPART", "Malformed multipart request body");
97
+ }
98
+ }
99
+ /**
100
+ * Turn the collected bytes into the body shape the fetch interceptor gives
101
+ * the same request, so `mock.listen()`, the CLI and `mock.intercept()` hand a
102
+ * handler the same value:
103
+ *
104
+ * - `application/json` and `+json`: the parsed value
105
+ * - `application/x-www-form-urlencoded`: a flat object, last duplicate wins
106
+ * - `text/*`: a UTF-8 string
107
+ * - `multipart/*`: `FormData`
108
+ * - anything else, including no content type: an `ArrayBuffer`
109
+ */
110
+ function decodeRequestBody(bytes, contentType) {
111
+ const mediaType = baseMediaTypeOf(contentType);
112
+ if (isJsonMediaType(mediaType)) {
113
+ return parseJsonBody(new TextDecoder().decode(bytes));
114
+ }
115
+ if (mediaType === "application/x-www-form-urlencoded") {
116
+ return Object.fromEntries(new URLSearchParams(new TextDecoder().decode(bytes)));
117
+ }
118
+ if (mediaType.startsWith("text/")) {
119
+ return new TextDecoder().decode(bytes);
120
+ }
121
+ if (mediaType.startsWith("multipart/")) {
122
+ return parseMultipartBody(bytes, contentType);
123
+ }
124
+ return bytes.buffer;
125
+ }
126
+ /** Copy the chunks into one buffer that the body exclusively owns. */
127
+ function concatChunks(chunks, totalSize) {
128
+ const bytes = new Uint8Array(totalSize);
129
+ let offset = 0;
130
+ for (const chunk of chunks) {
131
+ bytes.set(chunk, offset);
132
+ offset += chunk.byteLength;
133
+ }
134
+ return bytes;
46
135
  }
47
136
  /**
48
137
  * Collect and parse the request body from a Node.js IncomingMessage.
49
- * Returns parsed JSON for application/json and +json media types, otherwise the
50
- * raw string.
138
+ * The body takes the shape the fetch interceptor gives it: parsed JSON for
139
+ * application/json and +json, an object for urlencoded forms, a string for
140
+ * text/*, FormData for multipart/*, and an ArrayBuffer for anything else.
51
141
  * Returns undefined for empty bodies.
52
142
  * @param req - Node.js IncomingMessage
53
- * @param headers - Parsed request headers
143
+ * @param headers - Parsed request headers; content-length and content-type
144
+ * are looked up case-insensitively
54
145
  * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
55
146
  */
56
147
  export function collectBody(req, headers, maxBodySize = DEFAULT_MAX_BODY_SIZE) {
57
- const contentLength = headers["content-length"];
148
+ const contentLength = getHeader(headers, "content-length");
58
149
  const declaredBodyTooLarge = contentLength !== undefined &&
59
150
  DECIMAL_CONTENT_LENGTH.test(contentLength) &&
60
151
  Number(contentLength) > maxBodySize;
@@ -95,22 +186,19 @@ export function collectBody(req, headers, maxBodySize = DEFAULT_MAX_BODY_SIZE) {
95
186
  req.on("end", () => {
96
187
  if (settled)
97
188
  return;
98
- const raw = Buffer.concat(chunks).toString();
99
- if (!raw) {
189
+ if (totalSize === 0) {
100
190
  resolveOnce(undefined);
101
191
  return;
102
192
  }
103
- const contentType = headers["content-type"] ?? "";
104
- if (isJsonMediaType(contentType)) {
105
- try {
106
- resolveOnce(JSON.parse(raw));
107
- }
108
- catch {
109
- rejectOnce(new HttpIngressError(400, "MALFORMED_JSON", "Malformed JSON request body"));
110
- }
193
+ const bytes = concatChunks(chunks, totalSize);
194
+ try {
195
+ // A multipart body decodes asynchronously. Resolving with that promise
196
+ // settles collection now, so the `close` Node emits right after `end`
197
+ // cannot pre-empt the parse as an abort.
198
+ resolveOnce(decodeRequestBody(bytes, getHeader(headers, "content-type") ?? ""));
111
199
  }
112
- else {
113
- resolveOnce(raw);
200
+ catch (error) {
201
+ rejectOnce(error instanceof Error ? error : new Error(String(error)));
114
202
  }
115
203
  });
116
204
  });
@@ -119,33 +207,36 @@ export function collectBody(req, headers, maxBodySize = DEFAULT_MAX_BODY_SIZE) {
119
207
  const REJECTED_REQUEST_IDLE_MS = 400;
120
208
  /** Hard cap for a client that keeps streaming after a rejected request. */
121
209
  const REJECTED_REQUEST_DRAIN_GRACE_MS = 5_000;
122
- function prepareWriteableResponse(response, extraHeaders) {
123
- const responseHeaders = { ...response.headers };
124
- if (extraHeaders) {
125
- const names = new Map(Object.keys(responseHeaders).map((name) => [name.toLowerCase(), name]));
126
- for (const [name, value] of Object.entries(extraHeaders)) {
127
- const previousName = names.get(name.toLowerCase());
128
- if (previousName !== undefined)
129
- delete responseHeaders[previousName];
130
- responseHeaders[name] = value;
131
- names.set(name.toLowerCase(), name);
132
- }
210
+ /** Merge `extraHeaders` over `headers`; an extra header replaces any case variant. */
211
+ function mergeExtraHeaders(headers, extraHeaders) {
212
+ const merged = { ...headers };
213
+ if (!extraHeaders)
214
+ return merged;
215
+ const names = new Map(Object.keys(merged).map((name) => [name.toLowerCase(), name]));
216
+ for (const [name, value] of Object.entries(extraHeaders)) {
217
+ const previousName = names.get(name.toLowerCase());
218
+ if (previousName !== undefined)
219
+ delete merged[previousName];
220
+ merged[name] = value;
221
+ names.set(name.toLowerCase(), name);
133
222
  }
134
- const hasContentType = Object.keys(responseHeaders).some((header) => header.toLowerCase() === "content-type");
135
- if (!hasContentType &&
136
- response.body !== undefined &&
137
- isBinaryBody(response.body)) {
138
- responseHeaders["content-type"] = "application/octet-stream";
139
- }
140
- else if (!hasContentType &&
141
- response.body !== undefined &&
142
- typeof response.body !== "string") {
143
- responseHeaders["content-type"] = "application/json";
144
- }
145
- const body = serializeResponseBody({
223
+ return merged;
224
+ }
225
+ function prepareWriteableResponse(response, extraHeaders) {
226
+ // Extra headers first, so an extra content type counts before a default one
227
+ // is inferred; the length is declared last, from the serialized bytes.
228
+ const typed = withDefaultContentType({
146
229
  ...response,
147
- headers: responseHeaders,
230
+ headers: mergeExtraHeaders(response.headers, extraHeaders),
148
231
  });
232
+ const responseHeaders = typed.headers;
233
+ const body = serializeResponseBody(typed);
234
+ // Declare the length up front: writeHead() commits the header block before
235
+ // end() sees the body, so without it Node frames every response as chunked,
236
+ // unlike Express's res.end(buffer).
237
+ if (body !== undefined && !hasHeader(responseHeaders, "content-length")) {
238
+ responseHeaders["content-length"] = String(body.byteLength);
239
+ }
149
240
  return { headers: responseHeaders, body };
150
241
  }
151
242
  /**
@@ -204,3 +295,194 @@ export function writeRejectedSchmockResponse(req, res, response, extraHeaders) {
204
295
  graceTimer.unref?.();
205
296
  req.resume();
206
297
  }
298
+ /** Placeholder origin for origin-form request targets; never contacted. */
299
+ const REQUEST_TARGET_ORIGIN = "http://schmock.invalid";
300
+ const ALLOWED_METHODS_HEADER = HTTP_METHODS.join(", ");
301
+ /**
302
+ * A client error `serveNodeRequest` answers before the mock sees the request:
303
+ * 400 for a request it cannot parse, 405 for a verb Schmock does not route.
304
+ */
305
+ class NodeRequestError extends SchmockError {
306
+ status;
307
+ headers;
308
+ constructor(status, code, message, headers = {}) {
309
+ super(message, code);
310
+ this.status = status;
311
+ this.headers = headers;
312
+ this.name = "NodeRequestError";
313
+ }
314
+ }
315
+ /** Whether a Host header value parses as an authority. */
316
+ function isParseableHost(host) {
317
+ try {
318
+ return new URL(`http://${host}`).host !== "";
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ }
324
+ /**
325
+ * Parse a Node request target into a URL without letting it pick the host.
326
+ *
327
+ * Resolving an origin-form target (`/path?q`) against a base reads a leading
328
+ * `//` as a protocol-relative URL: `//users` would become host "users" with
329
+ * path "/" and be served by `GET /`. Appending it to a fixed origin keeps the
330
+ * whole target as the path. The asterisk-form (`OPTIONS *`) keeps the `/*`
331
+ * path it always resolved to; any other target must be an absolute URL.
332
+ */
333
+ function parseRequestTarget(target) {
334
+ try {
335
+ if (target.startsWith("/"))
336
+ return new URL(`${REQUEST_TARGET_ORIGIN}${target}`);
337
+ if (target === "*")
338
+ return new URL(`${REQUEST_TARGET_ORIGIN}/*`);
339
+ return new URL(target);
340
+ }
341
+ catch {
342
+ throw new NodeRequestError(400, "BAD_REQUEST", "Malformed request target");
343
+ }
344
+ }
345
+ /**
346
+ * Check the Host header, then parse the target. The Host must be present and
347
+ * parse as an authority, but only the target's path and query are used.
348
+ */
349
+ function parseNodeRequestUrl(req) {
350
+ const host = req.headers.host;
351
+ if (!host) {
352
+ throw new NodeRequestError(400, "BAD_REQUEST", "Missing Host header");
353
+ }
354
+ if (!isParseableHost(host)) {
355
+ throw new NodeRequestError(400, "BAD_REQUEST", "Malformed Host header");
356
+ }
357
+ return parseRequestTarget(req.url ?? "/");
358
+ }
359
+ function parseNodeRequestMethod(method) {
360
+ const upper = (method ?? "GET").toUpperCase();
361
+ if (!isHttpMethod(upper)) {
362
+ throw new NodeRequestError(405, "METHOD_NOT_ALLOWED", `Unsupported HTTP method: ${upper}`, { allow: ALLOWED_METHODS_HEADER });
363
+ }
364
+ return upper;
365
+ }
366
+ function defaultErrorReply(error) {
367
+ if (error instanceof NodeRequestError) {
368
+ return {
369
+ status: error.status,
370
+ code: error.code,
371
+ message: error.message,
372
+ headers: error.headers,
373
+ };
374
+ }
375
+ if (error instanceof HttpIngressError) {
376
+ return { status: error.status, code: error.code, message: error.message };
377
+ }
378
+ return {
379
+ status: 500,
380
+ code: "SERVER_ERROR",
381
+ message: error instanceof Error ? error.message : "Internal Server Error",
382
+ };
383
+ }
384
+ /**
385
+ * Answer a request that failed. TOTAL: an answer that cannot be written
386
+ * destroys the socket instead, so the client is never left waiting and no
387
+ * error escapes as an unhandled rejection.
388
+ */
389
+ function answerFailedRequest(input) {
390
+ const { req, res, error, method, path, options } = input;
391
+ try {
392
+ if (res.headersSent || res.writableEnded) {
393
+ // Bytes are already on the wire, so no answer can replace them.
394
+ if (!res.writableEnded)
395
+ res.end();
396
+ return;
397
+ }
398
+ const reply = options.classifyError?.(error) ?? defaultErrorReply(error);
399
+ // An ingress failure leaves the request body unread or unusable, so the
400
+ // connection cannot carry another request. `shouldKeepAlive = false`
401
+ // alone emits no Connection header when writeHead is given a header
402
+ // object, so the close is announced explicitly, on the transport's own
403
+ // header channel: normalizeResponse strips hop-by-hop headers from
404
+ // everything a route produces.
405
+ const closeConnection = error instanceof HttpIngressError || reply.status === 413;
406
+ if (closeConnection)
407
+ res.shouldKeepAlive = false;
408
+ const response = buildJsonErrorResponse({
409
+ status: reply.status,
410
+ error: reply.message,
411
+ code: reply.code,
412
+ method,
413
+ headers: reply.headers,
414
+ });
415
+ const extraHeaders = {
416
+ ...options.extraHeaders?.({ isError: true, path }),
417
+ ...(closeConnection ? { connection: "close" } : {}),
418
+ };
419
+ if (reply.status === 413) {
420
+ writeRejectedSchmockResponse(req, res, response, extraHeaders);
421
+ }
422
+ else {
423
+ writeSchmockResponse(res, response, extraHeaders);
424
+ }
425
+ }
426
+ catch {
427
+ res.destroy();
428
+ }
429
+ }
430
+ /** Read a parsed request's headers, query and body, then route it. */
431
+ async function handleWithBody(req, url, method, path, options) {
432
+ const headers = parseNodeHeaders(req);
433
+ const query = parseNodeQuery(url);
434
+ const body = await collectBody(req, headers, options.maxBodySize);
435
+ return options.handle(method, path, {
436
+ headers,
437
+ body,
438
+ query,
439
+ signal: options.signal,
440
+ });
441
+ }
442
+ /**
443
+ * Serve one Node.js request through a mock: the bridge `mock.listen()` runs,
444
+ * usable with any `http.createServer` callback.
445
+ *
446
+ * It rejects a request without a parseable Host header or target (400) and a
447
+ * method Schmock does not route (405, with `allow`), gives `answerBeforeBody`
448
+ * the chance to answer without the body, then parses headers, query and body
449
+ * (400 for a malformed JSON or multipart body, 413 over `maxBodySize`, 10 MB
450
+ * by default) and calls `handle` with an abort signal that fires when the
451
+ * client goes away. Every failure is answered as `{ error, code }` JSON; an
452
+ * ingress failure also closes the connection, and a 413 is flushed while the
453
+ * client may still be uploading so it can read it.
454
+ *
455
+ * The returned promise never rejects. It settles once the response has been
456
+ * handed to Node, which is when per-request resources (a request admission)
457
+ * can be released.
458
+ */
459
+ export async function serveNodeRequest(req, res, options) {
460
+ const abortController = new AbortController();
461
+ const abortRequest = () => abortController.abort();
462
+ req.once("aborted", abortRequest);
463
+ res.once("close", abortRequest);
464
+ // The method an error answer is shaped for until the verb parses: a HEAD
465
+ // request keeps its bodyless answer even when it is rejected.
466
+ let method = req.method?.toUpperCase() === "HEAD" ? "HEAD" : "GET";
467
+ let path;
468
+ try {
469
+ // Client errors are answered in this order: the target, then the verb.
470
+ const url = parseNodeRequestUrl(req);
471
+ path = url.pathname;
472
+ method = parseNodeRequestMethod(req.method);
473
+ const response = options.answerBeforeBody?.(method, path) ??
474
+ (await handleWithBody(req, url, method, path, {
475
+ handle: options.handle,
476
+ maxBodySize: options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE,
477
+ signal: abortController.signal,
478
+ }));
479
+ writeSchmockResponse(res, response, options.extraHeaders?.({ isError: false, path }));
480
+ }
481
+ catch (error) {
482
+ answerFailedRequest({ req, res, error, method, path, options });
483
+ }
484
+ finally {
485
+ req.off("aborted", abortRequest);
486
+ res.off("close", abortRequest);
487
+ }
488
+ }