@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.
- package/README.md +129 -0
- package/dist/abort.d.ts +11 -1
- package/dist/abort.js +13 -2
- package/dist/adapter.d.ts +21 -0
- package/dist/adapter.js +19 -0
- package/dist/admission.d.ts +21 -0
- package/dist/admission.js +39 -0
- package/dist/binary.d.ts +0 -1
- package/dist/builder.d.ts +16 -32
- package/dist/builder.js +425 -904
- package/dist/constants.d.ts +33 -2
- package/dist/constants.js +73 -1
- package/dist/debug-logger.d.ts +10 -0
- package/dist/debug-logger.js +31 -0
- package/dist/delay.d.ts +12 -0
- package/dist/delay.js +37 -0
- package/dist/errors.d.ts +13 -2
- package/dist/errors.js +21 -2
- package/dist/events.d.ts +17 -0
- package/dist/events.js +58 -0
- package/dist/generations.d.ts +48 -0
- package/dist/generations.js +82 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +57 -0
- package/dist/helpers.d.ts +9 -10
- package/dist/helpers.js +4 -1
- package/dist/history.d.ts +56 -0
- package/dist/history.js +151 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +295 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +40 -10
- package/dist/interceptor.js +412 -178
- package/dist/node-server.d.ts +27 -0
- package/dist/node-server.js +166 -0
- package/dist/parser.d.ts +0 -1
- package/dist/parser.js +145 -22
- package/dist/plugin-hooks.d.ts +57 -0
- package/dist/plugin-hooks.js +276 -0
- package/dist/plugin-pipeline.d.ts +0 -1
- package/dist/plugin-pipeline.js +25 -4
- package/dist/response-normalizer.d.ts +36 -1
- package/dist/response-normalizer.js +102 -0
- package/dist/response-parser.d.ts +19 -1
- package/dist/response-parser.js +77 -19
- package/dist/route-matcher.d.ts +0 -1
- package/dist/route-table.d.ts +64 -0
- package/dist/route-table.js +220 -0
- package/dist/snapshot.d.ts +14 -0
- package/dist/snapshot.js +98 -0
- package/dist/types.d.ts +34 -1
- package/package.json +8 -3
- package/dist/abort.d.ts.map +0 -1
- package/dist/binary.d.ts.map +0 -1
- package/dist/builder.d.ts.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/helpers.d.ts.map +0 -1
- package/dist/http-helpers.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/interceptor.d.ts.map +0 -1
- package/dist/parser.d.ts.map +0 -1
- package/dist/plugin-pipeline.d.ts.map +0 -1
- package/dist/response-normalizer.d.ts.map +0 -1
- package/dist/response-parser.d.ts.map +0 -1
- package/dist/route-matcher.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/http-helpers.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
|
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
|
-
|
|
99
|
-
if (!raw) {
|
|
189
|
+
if (totalSize === 0) {
|
|
100
190
|
resolveOnce(undefined);
|
|
101
191
|
return;
|
|
102
192
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
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
|
+
}
|