@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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/abort.d.ts +3 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +30 -0
- package/dist/builder.d.ts +31 -10
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +760 -190
- package/dist/constants.d.ts +38 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +123 -2
- package/dist/errors.d.ts +14 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +41 -6
- package/dist/helpers.d.ts +9 -0
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +18 -2
- package/dist/http-helpers.d.ts +39 -4
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +147 -49
- package/dist/index.d.ts +292 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/interceptor.d.ts +11 -1
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +316 -169
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +12 -3
- package/dist/plugin-pipeline.d.ts +3 -3
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +29 -14
- package/dist/response-normalizer.d.ts +16 -0
- package/dist/response-normalizer.d.ts.map +1 -0
- package/dist/response-normalizer.js +316 -0
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +39 -2
- package/dist/route-matcher.d.ts +3 -0
- package/dist/route-matcher.d.ts.map +1 -1
- package/dist/route-matcher.js +12 -6
- package/dist/types.d.ts +7 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -6
- package/src/audit-core-builder.test.ts +0 -218
- package/src/audit-response-guard.test.ts +0 -38
- package/src/binary.test.ts +0 -126
- package/src/binary.ts +0 -11
- package/src/builder.test.ts +0 -289
- package/src/builder.ts +0 -748
- package/src/constants.test.ts +0 -99
- package/src/constants.ts +0 -73
- package/src/debug.test.ts +0 -241
- package/src/delay.test.ts +0 -319
- package/src/dist-shape.test.ts +0 -35
- package/src/errors.test.ts +0 -223
- package/src/errors.ts +0 -130
- package/src/factory.test.ts +0 -133
- package/src/helpers.test.ts +0 -147
- package/src/helpers.ts +0 -58
- package/src/http-helpers.test.ts +0 -39
- package/src/http-helpers.ts +0 -149
- package/src/index.ts +0 -152
- package/src/interceptor.test.ts +0 -488
- package/src/interceptor.ts +0 -407
- package/src/namespace.test.ts +0 -274
- package/src/parser.property.test.ts +0 -630
- package/src/parser.test.ts +0 -148
- package/src/parser.ts +0 -64
- package/src/plugin-pipeline.ts +0 -208
- package/src/plugin-system.test.ts +0 -697
- package/src/response-parser.ts +0 -114
- package/src/response-parsing.test.ts +0 -333
- package/src/route-matcher.ts +0 -69
- package/src/route-matching.test.ts +0 -394
- package/src/server.test.ts +0 -234
- package/src/smart-defaults.test.ts +0 -361
- package/src/steps/async-support.steps.ts +0 -421
- package/src/steps/audit-core-builder.steps.ts +0 -188
- package/src/steps/audit-onerror-tuple.steps.ts +0 -83
- package/src/steps/basic-usage.steps.ts +0 -245
- package/src/steps/developer-experience.steps.ts +0 -204
- package/src/steps/error-handling.steps.ts +0 -555
- package/src/steps/fetch-interceptor.steps.ts +0 -457
- package/src/steps/fluent-api.steps.ts +0 -255
- package/src/steps/http-methods.steps.ts +0 -342
- package/src/steps/lifecycle-events.steps.ts +0 -149
- package/src/steps/performance-reliability.steps.ts +0 -90
- package/src/steps/plugin-integration.steps.ts +0 -334
- package/src/steps/request-history.steps.ts +0 -458
- package/src/steps/response-delay.steps.ts +0 -88
- package/src/steps/route-key-format.steps.ts +0 -99
- package/src/steps/standalone-server.steps.ts +0 -227
- package/src/steps/state-concurrency.steps.ts +0 -167
- package/src/types.ts +0 -36
package/dist/http-helpers.js
CHANGED
|
@@ -1,81 +1,136 @@
|
|
|
1
1
|
import { isBinaryBody } from "./binary.js";
|
|
2
|
+
import { serializeResponseBody } from "./response-normalizer.js";
|
|
3
|
+
/** An HTTP client error raised while collecting an incoming request body. */
|
|
4
|
+
export class HttpIngressError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
constructor(status, code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "HttpIngressError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
2
14
|
/**
|
|
3
15
|
* Convert Node.js IncomingMessage headers to a flat Record<string, string>.
|
|
4
16
|
* Drops array-valued headers (keeps only string values).
|
|
5
17
|
*/
|
|
6
18
|
export function parseNodeHeaders(req) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
return headers;
|
|
19
|
+
// Object.fromEntries defines own properties, so a header literally named
|
|
20
|
+
// `__proto__` is preserved instead of being swallowed by the prototype
|
|
21
|
+
// setter. The prototype is retained so consumers keep Object.prototype.
|
|
22
|
+
return Object.fromEntries(Object.entries(req.headers).filter((entry) => typeof entry[1] === "string"));
|
|
14
23
|
}
|
|
15
24
|
/**
|
|
16
25
|
* Extract query parameters from a URL as a flat Record<string, string>.
|
|
17
26
|
*/
|
|
18
27
|
export function parseNodeQuery(url) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
23
|
-
return query;
|
|
28
|
+
// Own-property definition for the same reason as parseNodeHeaders, and it
|
|
29
|
+
// matches how the fetch interceptor builds its query record.
|
|
30
|
+
return Object.fromEntries(url.searchParams);
|
|
24
31
|
}
|
|
25
32
|
/** Default body size limit: 10 MB */
|
|
26
33
|
const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
34
|
+
const DECIMAL_CONTENT_LENGTH = /^\d+$/;
|
|
35
|
+
function payloadTooLargeError() {
|
|
36
|
+
return new HttpIngressError(413, "PAYLOAD_TOO_LARGE", "Request body too large");
|
|
37
|
+
}
|
|
38
|
+
function requestAbortedError() {
|
|
39
|
+
const error = new Error("Request body collection aborted");
|
|
40
|
+
error.name = "AbortError";
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
function isJsonMediaType(contentType) {
|
|
44
|
+
const baseMediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
45
|
+
return (baseMediaType === "application/json" || baseMediaType.endsWith("+json"));
|
|
46
|
+
}
|
|
27
47
|
/**
|
|
28
48
|
* Collect and parse the request body from a Node.js IncomingMessage.
|
|
29
|
-
* Returns parsed JSON
|
|
49
|
+
* Returns parsed JSON for application/json and +json media types, otherwise the
|
|
50
|
+
* raw string.
|
|
30
51
|
* Returns undefined for empty bodies.
|
|
31
52
|
* @param req - Node.js IncomingMessage
|
|
32
53
|
* @param headers - Parsed request headers
|
|
33
54
|
* @param maxBodySize - Maximum body size in bytes (default: 10 MB)
|
|
34
55
|
*/
|
|
35
56
|
export function collectBody(req, headers, maxBodySize = DEFAULT_MAX_BODY_SIZE) {
|
|
57
|
+
const contentLength = headers["content-length"];
|
|
58
|
+
const declaredBodyTooLarge = contentLength !== undefined &&
|
|
59
|
+
DECIMAL_CONTENT_LENGTH.test(contentLength) &&
|
|
60
|
+
Number(contentLength) > maxBodySize;
|
|
36
61
|
return new Promise((resolve, reject) => {
|
|
37
62
|
const chunks = [];
|
|
38
63
|
let totalSize = 0;
|
|
39
|
-
|
|
64
|
+
let settled = false;
|
|
65
|
+
const rejectOnce = (error) => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
chunks.length = 0;
|
|
70
|
+
reject(error);
|
|
71
|
+
};
|
|
72
|
+
const resolveOnce = (body) => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
chunks.length = 0;
|
|
77
|
+
resolve(body);
|
|
78
|
+
};
|
|
79
|
+
if (declaredBodyTooLarge) {
|
|
80
|
+
rejectOnce(payloadTooLargeError());
|
|
81
|
+
}
|
|
82
|
+
req.on("error", rejectOnce);
|
|
83
|
+
req.on("aborted", () => rejectOnce(requestAbortedError()));
|
|
84
|
+
req.on("close", () => rejectOnce(requestAbortedError()));
|
|
40
85
|
req.on("data", (chunk) => {
|
|
41
|
-
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
totalSize += chunk.byteLength;
|
|
42
89
|
if (totalSize > maxBodySize) {
|
|
43
|
-
|
|
44
|
-
reject(Object.assign(new Error("Request body too large"), { status: 413 }));
|
|
90
|
+
rejectOnce(payloadTooLargeError());
|
|
45
91
|
return;
|
|
46
92
|
}
|
|
47
93
|
chunks.push(chunk);
|
|
48
94
|
});
|
|
49
95
|
req.on("end", () => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
50
98
|
const raw = Buffer.concat(chunks).toString();
|
|
51
99
|
if (!raw) {
|
|
52
|
-
|
|
100
|
+
resolveOnce(undefined);
|
|
53
101
|
return;
|
|
54
102
|
}
|
|
55
103
|
const contentType = headers["content-type"] ?? "";
|
|
56
|
-
if (contentType
|
|
104
|
+
if (isJsonMediaType(contentType)) {
|
|
57
105
|
try {
|
|
58
|
-
|
|
106
|
+
resolveOnce(JSON.parse(raw));
|
|
59
107
|
}
|
|
60
108
|
catch {
|
|
61
|
-
|
|
109
|
+
rejectOnce(new HttpIngressError(400, "MALFORMED_JSON", "Malformed JSON request body"));
|
|
62
110
|
}
|
|
63
111
|
}
|
|
64
112
|
else {
|
|
65
|
-
|
|
113
|
+
resolveOnce(raw);
|
|
66
114
|
}
|
|
67
115
|
});
|
|
68
116
|
});
|
|
69
117
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const responseHeaders = {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
118
|
+
/** How long a rejected request may stay silent before the response ends. */
|
|
119
|
+
const REJECTED_REQUEST_IDLE_MS = 400;
|
|
120
|
+
/** Hard cap for a client that keeps streaming after a rejected request. */
|
|
121
|
+
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
|
+
}
|
|
133
|
+
}
|
|
79
134
|
const hasContentType = Object.keys(responseHeaders).some((header) => header.toLowerCase() === "content-type");
|
|
80
135
|
if (!hasContentType &&
|
|
81
136
|
response.body !== undefined &&
|
|
@@ -87,22 +142,65 @@ export function writeSchmockResponse(res, response, extraHeaders) {
|
|
|
87
142
|
typeof response.body !== "string") {
|
|
88
143
|
responseHeaders["content-type"] = "application/json";
|
|
89
144
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
145
|
+
const body = serializeResponseBody({
|
|
146
|
+
...response,
|
|
147
|
+
headers: responseHeaders,
|
|
148
|
+
});
|
|
149
|
+
return { headers: responseHeaders, body };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Write a Schmock Response to a Node.js ServerResponse.
|
|
153
|
+
* Serializes non-string bodies as JSON and sets content-type when missing.
|
|
154
|
+
*/
|
|
155
|
+
export function writeSchmockResponse(res, response, extraHeaders) {
|
|
156
|
+
const { headers, body } = prepareWriteableResponse(response, extraHeaders);
|
|
157
|
+
res.writeHead(response.status, headers);
|
|
158
|
+
res.end(body);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Write a rejection (e.g. 413) while the client may still be uploading.
|
|
162
|
+
*
|
|
163
|
+
* Ending the response immediately makes Node tear the socket down while
|
|
164
|
+
* request bytes are in flight; the resulting TCP reset discards the
|
|
165
|
+
* already-written error from the client's receive buffer, so the client
|
|
166
|
+
* observes ECONNRESET instead of the response. Instead the response body is
|
|
167
|
+
* flushed right away and the end is deferred — a lingering close — until the
|
|
168
|
+
* request finishes, goes idle, or exhausts the grace cap.
|
|
169
|
+
*/
|
|
170
|
+
export function writeRejectedSchmockResponse(req, res, response, extraHeaders) {
|
|
171
|
+
const { headers, body } = prepareWriteableResponse(response, extraHeaders);
|
|
172
|
+
res.writeHead(response.status, headers);
|
|
173
|
+
if (body !== undefined)
|
|
174
|
+
res.write(body);
|
|
175
|
+
let idleTimer;
|
|
176
|
+
let graceTimer;
|
|
177
|
+
let finished = false;
|
|
178
|
+
const onData = () => {
|
|
179
|
+
// The client is still sending: keep the socket open so its bytes have
|
|
180
|
+
// somewhere to go, pushing the deferred end out with every chunk.
|
|
181
|
+
if (idleTimer !== undefined)
|
|
182
|
+
clearTimeout(idleTimer);
|
|
183
|
+
idleTimer = setTimeout(finish, REJECTED_REQUEST_IDLE_MS);
|
|
184
|
+
idleTimer.unref?.();
|
|
185
|
+
};
|
|
186
|
+
const finish = () => {
|
|
187
|
+
if (finished)
|
|
188
|
+
return;
|
|
189
|
+
finished = true;
|
|
190
|
+
req.off("data", onData);
|
|
191
|
+
if (idleTimer !== undefined)
|
|
192
|
+
clearTimeout(idleTimer);
|
|
193
|
+
if (graceTimer !== undefined)
|
|
194
|
+
clearTimeout(graceTimer);
|
|
195
|
+
if (!res.writableEnded)
|
|
196
|
+
res.end();
|
|
197
|
+
};
|
|
198
|
+
req.on("data", onData);
|
|
199
|
+
req.once("end", finish);
|
|
200
|
+
req.once("close", finish);
|
|
201
|
+
res.once("close", finish);
|
|
202
|
+
onData();
|
|
203
|
+
graceTimer = setTimeout(finish, REJECTED_REQUEST_DRAIN_GRACE_MS);
|
|
204
|
+
graceTimer.unref?.();
|
|
205
|
+
req.resume();
|
|
108
206
|
}
|