@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
@@ -0,0 +1,56 @@
1
+ /** What history records about the request, captured before any hook runs. */
2
+ export interface RequestHistorySnapshot {
3
+ readonly query: Record<string, string>;
4
+ readonly headers: Record<string, string>;
5
+ readonly body: unknown;
6
+ }
7
+ /** A matched request as it is committed to history. */
8
+ interface RequestHistoryEntry {
9
+ /** The history generation the request was admitted under. */
10
+ readonly generation: symbol;
11
+ readonly method: Schmock.HttpMethod;
12
+ /** The namespace-stripped, normalized path the route matched. */
13
+ readonly path: string;
14
+ readonly params: Record<string, string>;
15
+ readonly snapshot: RequestHistorySnapshot;
16
+ readonly response: Schmock.Response;
17
+ }
18
+ /**
19
+ * The mock's request log and its spy API.
20
+ *
21
+ * Every read returns deep copies, so a caller can never corrupt the records.
22
+ * A request is recorded only under the history generation it was admitted
23
+ * with: `startGeneration()` ends the current one, so a request still in
24
+ * flight when history is reset cannot write into the new log.
25
+ */
26
+ export declare class RequestHistory {
27
+ #private;
28
+ /**
29
+ * @param limit `maxHistorySize`: FIFO bound on the number of records,
30
+ * `0` disables history, `undefined` keeps every record.
31
+ * @throws SchmockError `INVALID_CONFIG` when the limit is not a
32
+ * non-negative integer.
33
+ */
34
+ constructor(limit: number | undefined);
35
+ /** The token an admitted request captures and records under. */
36
+ get generation(): symbol;
37
+ /**
38
+ * Copy what the CLIENT sent, before any plugin or the generator gets the
39
+ * live objects and can edit them. `undefined` when history is disabled.
40
+ */
41
+ snapshotRequest(request: RequestHistorySnapshot): RequestHistorySnapshot | undefined;
42
+ /** Record a matched request, unless its history generation has ended. */
43
+ record(entry: RequestHistoryEntry): void;
44
+ /**
45
+ * Start a new generation: a request admitted before it can no longer
46
+ * record. The records themselves stay until `clear()`.
47
+ */
48
+ startGeneration(): void;
49
+ /** Drop every record. */
50
+ clear(): void;
51
+ history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[];
52
+ called(method?: Schmock.HttpMethod, path?: string): boolean;
53
+ callCount(method?: Schmock.HttpMethod, path?: string): number;
54
+ lastRequest(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord | undefined;
55
+ }
56
+ export {};
@@ -0,0 +1,230 @@
1
+ import { canonicalizePath, normalizePath } from "./constants.js";
2
+ import { SchmockError } from "./errors.js";
3
+ function unavailableHistoryValue(value) {
4
+ let type = typeof value;
5
+ if (typeof value === "object" && value !== null) {
6
+ try {
7
+ type = Object.prototype.toString.call(value);
8
+ }
9
+ catch {
10
+ type = "object";
11
+ }
12
+ }
13
+ return {
14
+ kind: "unavailable",
15
+ reason: "not-structured-cloneable",
16
+ type,
17
+ };
18
+ }
19
+ function removeSharedMemory(value, seen = new WeakMap()) {
20
+ if (typeof value !== "object" || value === null)
21
+ return value;
22
+ const existing = seen.get(value);
23
+ if (existing !== undefined)
24
+ return existing;
25
+ if (typeof SharedArrayBuffer !== "undefined" &&
26
+ value instanceof SharedArrayBuffer) {
27
+ const copy = Uint8Array.from(new Uint8Array(value)).buffer;
28
+ seen.set(value, copy);
29
+ return copy;
30
+ }
31
+ if (ArrayBuffer.isView(value) &&
32
+ typeof SharedArrayBuffer !== "undefined" &&
33
+ value.buffer instanceof SharedArrayBuffer) {
34
+ const copy = Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
35
+ seen.set(value, copy);
36
+ return copy;
37
+ }
38
+ seen.set(value, value);
39
+ if (value instanceof Map) {
40
+ const entries = [...value.entries()];
41
+ value.clear();
42
+ for (const [key, entryValue] of entries) {
43
+ value.set(removeSharedMemory(key, seen), removeSharedMemory(entryValue, seen));
44
+ }
45
+ return value;
46
+ }
47
+ if (value instanceof Set) {
48
+ const entries = [...value.values()];
49
+ value.clear();
50
+ for (const entryValue of entries) {
51
+ value.add(removeSharedMemory(entryValue, seen));
52
+ }
53
+ return value;
54
+ }
55
+ for (const key of Reflect.ownKeys(value)) {
56
+ Reflect.set(value, key, removeSharedMemory(Reflect.get(value, key), seen));
57
+ }
58
+ return value;
59
+ }
60
+ /**
61
+ * Reject a history limit that cannot bound anything.
62
+ *
63
+ * A negative limit used to read as "unbounded" and a fractional one evicted a
64
+ * fractional number of records, so a typo silently disabled the cap instead of
65
+ * failing. `Number.isInteger` also rejects NaN and Infinity. `0` stays valid
66
+ * and keeps meaning "history disabled".
67
+ */
68
+ function assertValidHistoryLimit(limit) {
69
+ if (limit === undefined)
70
+ return;
71
+ if (!Number.isInteger(limit) || limit < 0) {
72
+ throw new SchmockError(`Invalid maxHistorySize: ${String(limit)}. Expected a non-negative integer (0 disables history).`, "INVALID_CONFIG", { maxHistorySize: limit });
73
+ }
74
+ }
75
+ function snapshotHistoryValue(value) {
76
+ try {
77
+ return removeSharedMemory(structuredClone(value));
78
+ }
79
+ catch {
80
+ return unavailableHistoryValue(value);
81
+ }
82
+ }
83
+ /**
84
+ * Snapshot a body that already went through `normalizeResponse`.
85
+ *
86
+ * A normalized body is a string, a `JSON.parse` tree or a fresh byte copy, so
87
+ * it can never hold shared memory: the `removeSharedMemory` walk that caller
88
+ * supplied values need would only re-visit every node for nothing.
89
+ */
90
+ function snapshotNormalizedBody(value) {
91
+ try {
92
+ return structuredClone(value);
93
+ }
94
+ catch {
95
+ return unavailableHistoryValue(value);
96
+ }
97
+ }
98
+ function cloneRecord(r) {
99
+ return {
100
+ method: r.method,
101
+ path: r.path,
102
+ params: { ...r.params },
103
+ query: { ...r.query },
104
+ headers: { ...r.headers },
105
+ body: snapshotHistoryValue(r.body),
106
+ timestamp: r.timestamp,
107
+ response: {
108
+ status: r.response.status,
109
+ body: snapshotNormalizedBody(r.response.body),
110
+ },
111
+ };
112
+ }
113
+ /**
114
+ * History stores the canonical request path — percent-encoded and
115
+ * trailing-slash-normalized exactly as `handle()` produced it — so a spy
116
+ * filter must be put into the same form before it is compared, or the very
117
+ * string the caller passed to `handle()` would not match its own record.
118
+ * `canonicalizePath` is idempotent, so an already-encoded filter keeps
119
+ * matching and both spellings work.
120
+ */
121
+ function historyMatcher(method, path) {
122
+ const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
123
+ return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
124
+ }
125
+ /**
126
+ * The mock's request log and its spy API.
127
+ *
128
+ * Every read returns deep copies, so a caller can never corrupt the records.
129
+ * A request is recorded only under the history generation it was admitted
130
+ * with: `startGeneration()` ends the current one, so a request still in
131
+ * flight when history is reset cannot write into the new log.
132
+ */
133
+ export class RequestHistory {
134
+ #records = [];
135
+ #generation = Symbol("schmock.history.generation");
136
+ #limit;
137
+ /**
138
+ * @param limit `maxHistorySize`: FIFO bound on the number of records,
139
+ * `0` disables history, `undefined` keeps every record.
140
+ * @throws SchmockError `INVALID_CONFIG` when the limit is not a
141
+ * non-negative integer.
142
+ */
143
+ constructor(limit) {
144
+ assertValidHistoryLimit(limit);
145
+ this.#limit = limit;
146
+ }
147
+ /** The token an admitted request captures and records under. */
148
+ get generation() {
149
+ return this.#generation;
150
+ }
151
+ /**
152
+ * Copy what the CLIENT sent, before any plugin or the generator gets the
153
+ * live objects and can edit them. `undefined` when history is disabled.
154
+ */
155
+ snapshotRequest(request) {
156
+ if (this.#limit === 0)
157
+ return undefined;
158
+ return {
159
+ query: { ...request.query },
160
+ headers: { ...request.headers },
161
+ body: snapshotHistoryValue(request.body),
162
+ };
163
+ }
164
+ /** Record a matched request, unless its history generation has ended. */
165
+ record(entry) {
166
+ if (entry.generation !== this.#generation || this.#limit === 0)
167
+ return;
168
+ const limit = this.#limit;
169
+ this.#records.push({
170
+ method: entry.method,
171
+ path: entry.path,
172
+ params: { ...entry.params },
173
+ query: entry.snapshot.query,
174
+ headers: entry.snapshot.headers,
175
+ body: entry.snapshot.body,
176
+ timestamp: Date.now(),
177
+ response: {
178
+ status: entry.response.status,
179
+ body: snapshotNormalizedBody(entry.response.body),
180
+ },
181
+ });
182
+ // The constructor already rejected a limit that is not a non-negative
183
+ // integer, so a plain comparison is enough here.
184
+ if (limit !== undefined && this.#records.length > limit) {
185
+ this.#records.splice(0, this.#records.length - limit);
186
+ }
187
+ }
188
+ /**
189
+ * Start a new generation: a request admitted before it can no longer
190
+ * record. The records themselves stay until `clear()`.
191
+ */
192
+ startGeneration() {
193
+ this.#generation = Symbol("schmock.history.generation");
194
+ }
195
+ /** Drop every record. */
196
+ clear() {
197
+ this.#records = [];
198
+ }
199
+ history(method, path) {
200
+ if (method || path) {
201
+ return this.#records
202
+ .filter(historyMatcher(method, path))
203
+ .map((r) => cloneRecord(r));
204
+ }
205
+ return this.#records.map((r) => cloneRecord(r));
206
+ }
207
+ called(method, path) {
208
+ if (method || path) {
209
+ return this.#records.some(historyMatcher(method, path));
210
+ }
211
+ return this.#records.length > 0;
212
+ }
213
+ callCount(method, path) {
214
+ if (method || path) {
215
+ return this.#records.filter(historyMatcher(method, path)).length;
216
+ }
217
+ return this.#records.length;
218
+ }
219
+ lastRequest(method, path) {
220
+ if (method || path) {
221
+ const filtered = this.#records.filter(historyMatcher(method, path));
222
+ const last = filtered[filtered.length - 1];
223
+ // FIX 2.3: return a deep clone so callers cannot corrupt internal history
224
+ return last ? cloneRecord(last) : undefined;
225
+ }
226
+ const last = this.#records[this.#records.length - 1];
227
+ // FIX 2.3: return a deep clone so callers cannot corrupt internal history
228
+ return last ? cloneRecord(last) : undefined;
229
+ }
230
+ }
@@ -15,7 +15,7 @@ interface ResponseWritable {
15
15
  writeHead(status: number, headers: Record<string, string>): this;
16
16
  end(body?: string | Uint8Array): this;
17
17
  }
18
- export type HttpIngressErrorCode = "MALFORMED_JSON" | "PAYLOAD_TOO_LARGE";
18
+ export type HttpIngressErrorCode = "MALFORMED_JSON" | "JSON_TOO_DEEP" | "MALFORMED_MULTIPART" | "PAYLOAD_TOO_LARGE";
19
19
  /** An HTTP client error raised while collecting an incoming request body. */
20
20
  export declare class HttpIngressError extends Error {
21
21
  readonly status: 400 | 413;
@@ -29,15 +29,20 @@ export declare class HttpIngressError extends Error {
29
29
  export declare function parseNodeHeaders(req: RequestWithHeaders): Record<string, string>;
30
30
  /**
31
31
  * Extract query parameters from a URL as a flat Record<string, string>.
32
+ * A repeated key resolves to its LAST value; every adapter follows this rule.
32
33
  */
33
34
  export declare function parseNodeQuery(url: URL): Record<string, string>;
35
+ /** Default body size limit: 10 MB */
36
+ export declare const DEFAULT_MAX_BODY_SIZE: number;
34
37
  /**
35
38
  * Collect and parse the request body from a Node.js IncomingMessage.
36
- * Returns parsed JSON for application/json and +json media types, otherwise the
37
- * raw string.
39
+ * The body takes the shape the fetch interceptor gives it: parsed JSON for
40
+ * application/json and +json, an object for urlencoded forms, a string for
41
+ * text/*, FormData for multipart/*, and an ArrayBuffer for anything else.
38
42
  * Returns undefined for empty bodies.
39
43
  * @param req - Node.js IncomingMessage
40
- * @param headers - Parsed request headers
44
+ * @param headers - Parsed request headers; content-length and content-type
45
+ * are looked up case-insensitively
41
46
  * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
42
47
  */
43
48
  export declare function collectBody(req: BodyReadable, headers: Record<string, string>, maxBodySize?: number): Promise<unknown>;
@@ -68,5 +73,105 @@ export declare function writeSchmockResponse(res: ResponseWritable, response: Sc
68
73
  * request finishes, goes idle, or exhausts the grace cap.
69
74
  */
70
75
  export declare function writeRejectedSchmockResponse(req: RejectedRequestReadable, res: RejectedResponseWritable, response: Schmock.Response, extraHeaders?: Record<string, string>): void;
76
+ /** The parts of a Node.js IncomingMessage `serveNodeRequest` uses. */
77
+ type NodeRequest = RequestWithHeaders & BodyReadable & RejectedRequestReadable & {
78
+ readonly headers: {
79
+ readonly host?: string;
80
+ };
81
+ readonly method?: string;
82
+ readonly url?: string;
83
+ once(event: "aborted", listener: () => void): unknown;
84
+ off(event: "aborted", listener: () => void): unknown;
85
+ };
86
+ /** The parts of a Node.js ServerResponse `serveNodeRequest` uses. */
87
+ type NodeResponse = RejectedResponseWritable & {
88
+ readonly headersSent: boolean;
89
+ shouldKeepAlive: boolean;
90
+ destroy(error?: Error): unknown;
91
+ off(event: "close", listener: () => void): unknown;
92
+ };
93
+ /**
94
+ * The request `serveNodeRequest` accepts: the parts of a Node.js
95
+ * IncomingMessage it uses. Structural, so a request typed by any `@types/node`
96
+ * copy fits.
97
+ */
98
+ export type NodeRequestLike = NodeRequest;
99
+ /**
100
+ * The response `serveNodeRequest` writes to: the parts of a Node.js
101
+ * ServerResponse it uses. Structural, so a response typed by any
102
+ * `@types/node` copy fits.
103
+ */
104
+ export type NodeResponseLike = NodeResponse;
105
+ /** What `serveNodeRequest` tells `extraHeaders` about the response it writes. */
106
+ export interface ServeNodeResponseContext {
107
+ /**
108
+ * `true` for an error answer `serveNodeRequest` writes itself, `false` for
109
+ * the response `handle` produced.
110
+ */
111
+ readonly isError: boolean;
112
+ /** The request pathname, or `undefined` when the request did not parse. */
113
+ readonly path: string | undefined;
114
+ }
115
+ /**
116
+ * How `serveNodeRequest` answers a failed request. The body is the JSON
117
+ * `{ "error": message, "code": code }`.
118
+ */
119
+ export interface HttpErrorReply {
120
+ readonly status: number;
121
+ readonly code: string;
122
+ readonly message: string;
123
+ /** Headers the answer carries besides its content type (a 405's `allow`). */
124
+ readonly headers?: Readonly<Record<string, string>>;
125
+ }
126
+ export interface ServeNodeRequestOptions {
127
+ /** Routes the parsed request: `mock.handle`, or a request admission's `handle`. */
128
+ readonly handle: Schmock.MockRequestHandler;
129
+ /**
130
+ * Largest request body accepted, in bytes. A larger one is answered 413 and
131
+ * the connection is closed. Defaults to 10 MB, the limit `mock.listen()`
132
+ * applies.
133
+ */
134
+ readonly maxBodySize?: number;
135
+ /**
136
+ * Answer a request from its verb and path alone, before its body is read.
137
+ * Return a response to send it as is (through `extraHeaders`, like any
138
+ * answer from `handle`), or `undefined` to read the body and call `handle`.
139
+ * A throw is answered like a `handle` failure.
140
+ *
141
+ * For answers that never depend on the body — a CORS preflight, an
142
+ * authorization refusal — so an oversized, malformed or stalled upload
143
+ * cannot delay or replace them.
144
+ */
145
+ readonly answerBeforeBody?: (method: Schmock.HttpMethod, path: string) => Schmock.Response | undefined;
146
+ /**
147
+ * Headers written over every response for this request (CORS headers, for
148
+ * example), replacing any case variant of the same name.
149
+ */
150
+ readonly extraHeaders?: (context: ServeNodeResponseContext) => Record<string, string> | undefined;
151
+ /**
152
+ * Choose the answer for an error. Return `undefined` for the default: 400
153
+ * for a request that does not parse, 405 with `allow` for a method Schmock
154
+ * does not route, the ingress status for a body error (400, or 413 over
155
+ * `maxBodySize`), and 500 `SERVER_ERROR` with the error's message otherwise.
156
+ */
157
+ readonly classifyError?: (error: unknown) => HttpErrorReply | undefined;
158
+ }
159
+ /**
160
+ * Serve one Node.js request through a mock: the bridge `mock.listen()` runs,
161
+ * usable with any `http.createServer` callback.
162
+ *
163
+ * It rejects a request without a parseable Host header or target (400) and a
164
+ * method Schmock does not route (405, with `allow`), gives `answerBeforeBody`
165
+ * the chance to answer without the body, then parses headers, query and body
166
+ * (400 for a malformed JSON or multipart body, 413 over `maxBodySize`, 10 MB
167
+ * by default) and calls `handle` with an abort signal that fires when the
168
+ * client goes away. Every failure is answered as `{ error, code }` JSON; an
169
+ * ingress failure also closes the connection, and a 413 is flushed while the
170
+ * client may still be uploading so it can read it.
171
+ *
172
+ * The returned promise never rejects. It settles once the response has been
173
+ * handed to Node, which is when per-request resources (a request admission)
174
+ * can be released.
175
+ */
176
+ export declare function serveNodeRequest(req: NodeRequest, res: NodeResponse, options: ServeNodeRequestOptions): Promise<void>;
71
177
  export {};
72
- //# sourceMappingURL=http-helpers.d.ts.map