@rexeus/typeweaver-server 0.10.2 → 0.10.4
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/dist/index.cjs +3 -2
- package/dist/index.mjs +4 -3
- package/dist/index.mjs.map +1 -1
- package/dist/lib/BodyLimitPolicy.ts +118 -0
- package/dist/lib/Errors.ts +16 -0
- package/dist/lib/FetchApiAdapter.ts +54 -22
- package/dist/lib/NodeAdapter.ts +570 -36
- package/dist/lib/PathMatcher.ts +54 -10
- package/dist/lib/Router.ts +16 -4
- package/dist/lib/TypeweaverApp.ts +32 -9
- package/dist/lib/TypeweaverAppRuntime.ts +37 -0
- package/dist/lib/TypeweaverInternals.ts +45 -0
- package/dist/lib/index.ts +1 -0
- package/dist/lib/middleware/basicAuth.ts +11 -2
- package/dist/lib/middleware/bearerAuth.ts +11 -2
- package/dist/lib/middleware/cors.ts +120 -12
- package/dist/lib/middleware/header.ts +59 -0
- package/dist/lib/middleware/logger.ts +4 -2
- package/dist/lib/middleware/poweredBy.ts +6 -1
- package/dist/lib/middleware/requestId.ts +8 -8
- package/dist/lib/middleware/scoped.ts +27 -12
- package/dist/lib/middleware/secureHeaders.ts +3 -1
- package/package.json +8 -5
package/dist/lib/NodeAdapter.ts
CHANGED
|
@@ -6,14 +6,50 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
badRequestDefaultError,
|
|
9
10
|
createDefaultErrorBody,
|
|
10
11
|
internalServerErrorDefaultError,
|
|
11
12
|
payloadTooLargeDefaultError,
|
|
12
13
|
} from "@rexeus/typeweaver-core";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createNodeBodyLimitPolicy,
|
|
16
|
+
isBodySizeOverLimit,
|
|
17
|
+
markRequestBodyPrevalidated,
|
|
18
|
+
parseContentLength,
|
|
19
|
+
} from "./BodyLimitPolicy.js";
|
|
20
|
+
import {
|
|
21
|
+
PayloadTooLargeError,
|
|
22
|
+
RequestBodyDrainTimeoutError,
|
|
23
|
+
} from "./Errors.js";
|
|
24
|
+
import {
|
|
25
|
+
getTypeweaverAppErrorReporter,
|
|
26
|
+
getTypeweaverAppRuntimeContext,
|
|
27
|
+
} from "./TypeweaverInternals.js";
|
|
14
28
|
import type { TypeweaverApp } from "./TypeweaverApp.js";
|
|
15
29
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
30
|
|
|
31
|
+
type DrainRequestResult = {
|
|
32
|
+
readonly exceededLimit: boolean;
|
|
33
|
+
readonly timedOut: boolean;
|
|
34
|
+
readonly totalBytes: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type DrainRequestOptions = {
|
|
38
|
+
readonly destroyOnLimitExceeded?: boolean;
|
|
39
|
+
readonly timeoutMs?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const REQUEST_DRAIN_TIMEOUT_MS = 5_000;
|
|
43
|
+
const ORIGIN_FORM_BASE_URL_PROTOCOL = "http:";
|
|
44
|
+
const AUTHORITY_LIKE_REQUEST_TARGET_PREFIX = /^[\\/]{2}/;
|
|
45
|
+
const ASTERISK_FORM_REQUEST_TARGET = "*";
|
|
46
|
+
|
|
47
|
+
type ParsedAuthority = {
|
|
48
|
+
readonly host: string;
|
|
49
|
+
readonly hostname: string;
|
|
50
|
+
readonly port: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
17
53
|
/**
|
|
18
54
|
* Adapts a `TypeweaverApp` to Node.js `http.createServer`.
|
|
19
55
|
*
|
|
@@ -29,8 +65,6 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
29
65
|
* createServer(nodeAdapter(app)).listen(3000);
|
|
30
66
|
* ```
|
|
31
67
|
*/
|
|
32
|
-
const DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
|
|
33
|
-
|
|
34
68
|
export type NodeAdapterOptions = {
|
|
35
69
|
readonly maxBodySize?: number;
|
|
36
70
|
};
|
|
@@ -39,9 +73,14 @@ export function nodeAdapter(
|
|
|
39
73
|
app: TypeweaverApp<any>,
|
|
40
74
|
options?: NodeAdapterOptions
|
|
41
75
|
): (req: IncomingMessage, res: ServerResponse) => void {
|
|
42
|
-
const
|
|
76
|
+
const appRuntimeContext = getTypeweaverAppRuntimeContext(app);
|
|
77
|
+
const maxBodySize =
|
|
78
|
+
options?.maxBodySize ?? appRuntimeContext?.bodyLimitPolicy.maxBodySize;
|
|
79
|
+
const bodyLimitPolicy = createNodeBodyLimitPolicy(maxBodySize);
|
|
80
|
+
const reportError = getTypeweaverAppErrorReporter(app);
|
|
81
|
+
|
|
43
82
|
return (req, res) => {
|
|
44
|
-
void handleRequest(app, req, res,
|
|
83
|
+
void handleRequest(app, req, res, bodyLimitPolicy, reportError);
|
|
45
84
|
};
|
|
46
85
|
}
|
|
47
86
|
|
|
@@ -49,20 +88,56 @@ async function handleRequest(
|
|
|
49
88
|
app: TypeweaverApp<any>,
|
|
50
89
|
req: IncomingMessage,
|
|
51
90
|
res: ServerResponse,
|
|
52
|
-
|
|
91
|
+
bodyLimitPolicy: ReturnType<typeof createNodeBodyLimitPolicy>,
|
|
92
|
+
reportError: (error: unknown) => void
|
|
53
93
|
): Promise<void> {
|
|
54
94
|
try {
|
|
55
|
-
const url =
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
const url = createRequestUrl(req);
|
|
96
|
+
if (url === undefined) {
|
|
97
|
+
writeBadRequestResponse(req, res, bodyLimitPolicy.maxBodySize);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const shouldValidateBody = shouldValidateRequestBody(req.method);
|
|
101
|
+
|
|
102
|
+
enforceContentLengthLimit(req, bodyLimitPolicy.maxBodySize);
|
|
103
|
+
|
|
104
|
+
if (!shouldValidateBody && hasReadableRequestBody(req)) {
|
|
105
|
+
const drainResult = await drainRequest(req, bodyLimitPolicy.maxBodySize, {
|
|
106
|
+
destroyOnLimitExceeded: false,
|
|
107
|
+
});
|
|
108
|
+
if (drainResult.exceededLimit) {
|
|
109
|
+
throw new PayloadTooLargeError(
|
|
110
|
+
drainResult.totalBytes,
|
|
111
|
+
bodyLimitPolicy.maxBodySize
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (drainResult.timedOut) {
|
|
115
|
+
throw new RequestBodyDrainTimeoutError(
|
|
116
|
+
bodyLimitPolicy.maxBodySize,
|
|
117
|
+
REQUEST_DRAIN_TIMEOUT_MS
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const body = !shouldValidateBody
|
|
123
|
+
? undefined
|
|
124
|
+
: await collectBody(req, bodyLimitPolicy.maxBodySize);
|
|
58
125
|
|
|
59
126
|
const request = new Request(url, {
|
|
60
127
|
method: req.method,
|
|
61
|
-
headers: req.headers
|
|
128
|
+
headers: createRequestHeaders(req.headers),
|
|
62
129
|
body,
|
|
63
130
|
});
|
|
131
|
+
if (shouldValidateBody) {
|
|
132
|
+
markRequestBodyPrevalidated(request, bodyLimitPolicy);
|
|
133
|
+
}
|
|
64
134
|
|
|
65
135
|
const response = await app.fetch(request);
|
|
136
|
+
const responseBody = await readWritableResponseBody(
|
|
137
|
+
req.method,
|
|
138
|
+
response,
|
|
139
|
+
reportError
|
|
140
|
+
);
|
|
66
141
|
|
|
67
142
|
response.headers.forEach((value, key) => {
|
|
68
143
|
if (key.toLowerCase() !== "set-cookie") {
|
|
@@ -74,32 +149,445 @@ async function handleRequest(
|
|
|
74
149
|
res.setHeader("set-cookie", cookies);
|
|
75
150
|
}
|
|
76
151
|
res.writeHead(response.status);
|
|
77
|
-
res.end(
|
|
152
|
+
res.end(responseBody);
|
|
78
153
|
} catch (error) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
154
|
+
reportError(error);
|
|
155
|
+
|
|
156
|
+
if (isRequestBodyLimitError(error)) {
|
|
157
|
+
writeDefaultErrorResponse(res, payloadTooLargeDefaultError, {
|
|
158
|
+
method: req.method,
|
|
159
|
+
onFinished: () => {
|
|
160
|
+
void drainRequest(req, bodyLimitPolicy.maxBodySize, {
|
|
161
|
+
destroyOnLimitExceeded: true,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
88
165
|
return;
|
|
89
166
|
}
|
|
90
167
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
168
|
+
writeDefaultErrorResponse(res, internalServerErrorDefaultError, {
|
|
169
|
+
method: req.method,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createRequestUrl(req: IncomingMessage): URL | undefined {
|
|
175
|
+
const rawUrl = req.url ?? "/";
|
|
176
|
+
|
|
177
|
+
if (rawUrl === ASTERISK_FORM_REQUEST_TARGET) {
|
|
178
|
+
return createAsteriskFormRequestUrl(req);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (hasAuthorityLikeRequestTargetPrefix(rawUrl)) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const url = new URL(rawUrl);
|
|
187
|
+
return isAbsoluteRequestHostAllowed(url, req) ? url : undefined;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (!(error instanceof TypeError) || !rawUrl.startsWith("/")) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
|
|
195
|
+
if (host === undefined) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return new URL(rawUrl, `${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function createAsteriskFormRequestUrl(req: IncomingMessage): URL | undefined {
|
|
203
|
+
if (req.method !== "OPTIONS") {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
|
|
208
|
+
if (host === undefined) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return new URL(
|
|
213
|
+
ASTERISK_FORM_REQUEST_TARGET,
|
|
214
|
+
`${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}/`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasAuthorityLikeRequestTargetPrefix(rawUrl: string): boolean {
|
|
219
|
+
return AUTHORITY_LIKE_REQUEST_TARGET_PREFIX.test(rawUrl);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isAbsoluteRequestHostAllowed(url: URL, req: IncomingMessage): boolean {
|
|
223
|
+
const host = parseRequestHostHeader(req, url.protocol);
|
|
224
|
+
if (host === undefined) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const urlAuthority = getUrlAuthority(url);
|
|
229
|
+
return (
|
|
230
|
+
host.hostname.toLowerCase() === urlAuthority.hostname.toLowerCase() &&
|
|
231
|
+
host.port === urlAuthority.port
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseRequestHostHeader(
|
|
236
|
+
req: IncomingMessage,
|
|
237
|
+
protocol: string
|
|
238
|
+
): ParsedAuthority | undefined {
|
|
239
|
+
if (!hasExactlyOneHostHeaderLine(req)) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return parseHostHeader(req.headers.host, protocol);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function hasExactlyOneHostHeaderLine(req: IncomingMessage): boolean {
|
|
247
|
+
const headersDistinctHostCount = getHeadersDistinctHostCount(req);
|
|
248
|
+
if (
|
|
249
|
+
headersDistinctHostCount !== undefined &&
|
|
250
|
+
headersDistinctHostCount !== 1
|
|
251
|
+
) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const rawHostHeaderCount = countRawHostHeaderLines(req.rawHeaders);
|
|
256
|
+
if (rawHostHeaderCount > 0) {
|
|
257
|
+
return rawHostHeaderCount === 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return headersDistinctHostCount === 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getHeadersDistinctHostCount(req: IncomingMessage): number | undefined {
|
|
264
|
+
const hostHeader = req.headersDistinct?.host;
|
|
265
|
+
if (hostHeader === undefined) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return Array.isArray(hostHeader) ? hostHeader.length : 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function countRawHostHeaderLines(rawHeaders: readonly string[]): number {
|
|
273
|
+
let count = 0;
|
|
274
|
+
|
|
275
|
+
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
276
|
+
if (rawHeaders[index]?.toLowerCase() === "host") {
|
|
277
|
+
count += 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return count;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseHostHeader(
|
|
285
|
+
hostHeader: IncomingMessage["headers"]["host"],
|
|
286
|
+
protocol: string
|
|
287
|
+
): ParsedAuthority | undefined {
|
|
288
|
+
if (hostHeader === undefined || Array.isArray(hostHeader)) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const host = hostHeader.trim();
|
|
293
|
+
if (host === "" || host !== hostHeader) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const parsed = new URL(`${protocol}//${host}`);
|
|
299
|
+
if (
|
|
300
|
+
parsed.username !== "" ||
|
|
301
|
+
parsed.password !== "" ||
|
|
302
|
+
parsed.pathname !== "/" ||
|
|
303
|
+
parsed.search !== "" ||
|
|
304
|
+
parsed.hash !== ""
|
|
305
|
+
) {
|
|
306
|
+
return undefined;
|
|
96
307
|
}
|
|
97
|
-
|
|
98
|
-
|
|
308
|
+
|
|
309
|
+
return getUrlAuthority(parsed);
|
|
310
|
+
} catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getUrlAuthority(url: URL): ParsedAuthority {
|
|
316
|
+
return {
|
|
317
|
+
host: url.host,
|
|
318
|
+
hostname: url.hostname,
|
|
319
|
+
port: getEffectivePort(url),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getEffectivePort(url: URL): string {
|
|
324
|
+
return url.port === "" ? getDefaultPort(url.protocol) : url.port;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function getDefaultPort(protocol: string): string {
|
|
328
|
+
switch (protocol) {
|
|
329
|
+
case "http:":
|
|
330
|
+
case "ws:":
|
|
331
|
+
return "80";
|
|
332
|
+
case "https:":
|
|
333
|
+
case "wss:":
|
|
334
|
+
return "443";
|
|
335
|
+
case "ftp:":
|
|
336
|
+
return "21";
|
|
337
|
+
default:
|
|
338
|
+
return "";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function shouldValidateRequestBody(method?: string): boolean {
|
|
343
|
+
return method !== "GET" && method !== "HEAD";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function shouldWriteResponseBody(
|
|
347
|
+
method: string | undefined,
|
|
348
|
+
status: number
|
|
349
|
+
): boolean {
|
|
350
|
+
return method !== "HEAD" && status !== 204 && status !== 304;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function readWritableResponseBody(
|
|
354
|
+
method: string | undefined,
|
|
355
|
+
response: Response,
|
|
356
|
+
reportError: (error: unknown) => void
|
|
357
|
+
): Promise<Buffer | undefined> {
|
|
358
|
+
if (shouldWriteResponseBody(method, response.status)) {
|
|
359
|
+
return Buffer.from(await response.arrayBuffer());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
cancelSuppressedResponseBody(response, reportError);
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function cancelSuppressedResponseBody(
|
|
367
|
+
response: Response,
|
|
368
|
+
reportError: (error: unknown) => void
|
|
369
|
+
): void {
|
|
370
|
+
try {
|
|
371
|
+
void response.body?.cancel().catch(error => {
|
|
372
|
+
reportSuppressedResponseBodyCancelError(error, reportError);
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
reportSuppressedResponseBodyCancelError(error, reportError);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function reportSuppressedResponseBodyCancelError(
|
|
380
|
+
error: unknown,
|
|
381
|
+
reportError: (error: unknown) => void
|
|
382
|
+
): void {
|
|
383
|
+
try {
|
|
384
|
+
reportError(error);
|
|
385
|
+
} catch (onErrorFailure) {
|
|
386
|
+
console.error(
|
|
387
|
+
"TypeweaverApp: onError callback threw while handling error",
|
|
388
|
+
{ onErrorFailure, originalError: error }
|
|
99
389
|
);
|
|
100
390
|
}
|
|
101
391
|
}
|
|
102
392
|
|
|
393
|
+
function hasReadableRequestBody(req: IncomingMessage): boolean {
|
|
394
|
+
return (
|
|
395
|
+
req.headers["content-length"] !== undefined ||
|
|
396
|
+
req.headers["transfer-encoding"] !== undefined
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function createRequestHeaders(headers: IncomingMessage["headers"]): Headers {
|
|
401
|
+
const requestHeaders = new Headers();
|
|
402
|
+
|
|
403
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
404
|
+
if (value === undefined) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (Array.isArray(value)) {
|
|
409
|
+
if (name.toLowerCase() === "cookie") {
|
|
410
|
+
requestHeaders.set(name, value.join("; "));
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const item of value) {
|
|
415
|
+
requestHeaders.append(name, item);
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
requestHeaders.set(name, value);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return requestHeaders;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isRequestBodyLimitError(
|
|
427
|
+
error: unknown
|
|
428
|
+
): error is PayloadTooLargeError | RequestBodyDrainTimeoutError {
|
|
429
|
+
return (
|
|
430
|
+
error instanceof PayloadTooLargeError ||
|
|
431
|
+
error instanceof RequestBodyDrainTimeoutError
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function enforceContentLengthLimit(
|
|
436
|
+
req: IncomingMessage,
|
|
437
|
+
maxBodySize: number
|
|
438
|
+
): void {
|
|
439
|
+
const contentLength = parseContentLength(req.headers["content-length"]);
|
|
440
|
+
if (contentLength === undefined) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (isBodySizeOverLimit(contentLength, maxBodySize)) {
|
|
445
|
+
throw new PayloadTooLargeError(contentLength, maxBodySize);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function writeDefaultErrorResponse(
|
|
450
|
+
res: ServerResponse,
|
|
451
|
+
error:
|
|
452
|
+
| typeof badRequestDefaultError
|
|
453
|
+
| typeof payloadTooLargeDefaultError
|
|
454
|
+
| typeof internalServerErrorDefaultError,
|
|
455
|
+
options: {
|
|
456
|
+
readonly method?: string;
|
|
457
|
+
readonly onFinished?: () => void;
|
|
458
|
+
} = {}
|
|
459
|
+
): void {
|
|
460
|
+
if (!res.headersSent) {
|
|
461
|
+
res.writeHead(error.statusCode, {
|
|
462
|
+
"content-type": "application/json",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (options.onFinished !== undefined) {
|
|
467
|
+
res.once("finish", options.onFinished);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const body = shouldWriteResponseBody(options.method, error.statusCode)
|
|
471
|
+
? JSON.stringify(createDefaultErrorBody(error))
|
|
472
|
+
: undefined;
|
|
473
|
+
res.end(body);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function writeBadRequestResponse(
|
|
477
|
+
req: IncomingMessage,
|
|
478
|
+
res: ServerResponse,
|
|
479
|
+
maxBodySize: number
|
|
480
|
+
): void {
|
|
481
|
+
writeDefaultErrorResponse(res, badRequestDefaultError, {
|
|
482
|
+
method: req.method,
|
|
483
|
+
onFinished: createRejectedRequestBodyCleanup(req, maxBodySize),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createRejectedRequestBodyCleanup(
|
|
488
|
+
req: IncomingMessage,
|
|
489
|
+
maxBodySize: number
|
|
490
|
+
): (() => void) | undefined {
|
|
491
|
+
if (!hasReadableRequestBody(req)) {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return () => {
|
|
496
|
+
const contentLength = parseContentLength(req.headers["content-length"]);
|
|
497
|
+
if (
|
|
498
|
+
contentLength !== undefined &&
|
|
499
|
+
isBodySizeOverLimit(contentLength, maxBodySize)
|
|
500
|
+
) {
|
|
501
|
+
req.destroy();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
void drainRequest(req, maxBodySize, { destroyOnLimitExceeded: true });
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function drainRequest(
|
|
510
|
+
req: IncomingMessage,
|
|
511
|
+
maxBodySize: number,
|
|
512
|
+
options: DrainRequestOptions = {}
|
|
513
|
+
): Promise<DrainRequestResult> {
|
|
514
|
+
if (req.readableEnded || req.destroyed) {
|
|
515
|
+
return { exceededLimit: false, timedOut: false, totalBytes: 0 };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return await new Promise<DrainRequestResult>(resolve => {
|
|
519
|
+
const destroyOnLimitExceeded = options.destroyOnLimitExceeded ?? true;
|
|
520
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_DRAIN_TIMEOUT_MS;
|
|
521
|
+
let drainedBytes = 0;
|
|
522
|
+
let isSettled = false;
|
|
523
|
+
|
|
524
|
+
const settle = (result: Omit<DrainRequestResult, "totalBytes">): void => {
|
|
525
|
+
if (isSettled) return;
|
|
526
|
+
isSettled = true;
|
|
527
|
+
cleanup();
|
|
528
|
+
resolve({ ...result, totalBytes: drainedBytes });
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const stopReading = (
|
|
532
|
+
result: Omit<DrainRequestResult, "totalBytes">
|
|
533
|
+
): void => {
|
|
534
|
+
if (isSettled) return;
|
|
535
|
+
isSettled = true;
|
|
536
|
+
cleanup();
|
|
537
|
+
if (destroyOnLimitExceeded) {
|
|
538
|
+
req.destroy();
|
|
539
|
+
}
|
|
540
|
+
resolve({ ...result, totalBytes: drainedBytes });
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const drainTimeout = setTimeout(() => {
|
|
544
|
+
stopReading({ exceededLimit: false, timedOut: true });
|
|
545
|
+
}, timeoutMs);
|
|
546
|
+
drainTimeout.unref();
|
|
547
|
+
|
|
548
|
+
const handleData = (chunk: Buffer | string): void => {
|
|
549
|
+
drainedBytes +=
|
|
550
|
+
typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
|
|
551
|
+
|
|
552
|
+
if (isBodySizeOverLimit(drainedBytes, maxBodySize)) {
|
|
553
|
+
stopReading({ exceededLimit: true, timedOut: false });
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const handleEnd = (): void => {
|
|
558
|
+
settle({ exceededLimit: false, timedOut: false });
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const handleClose = (): void => {
|
|
562
|
+
settle({ exceededLimit: false, timedOut: false });
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const handleAborted = (): void => {
|
|
566
|
+
settle({ exceededLimit: false, timedOut: false });
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const handleError = (): void => {
|
|
570
|
+
settle({ exceededLimit: false, timedOut: false });
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const cleanup = (): void => {
|
|
574
|
+
clearTimeout(drainTimeout);
|
|
575
|
+
req.off("data", handleData);
|
|
576
|
+
req.off("end", handleEnd);
|
|
577
|
+
req.off("error", handleError);
|
|
578
|
+
req.off("aborted", handleAborted);
|
|
579
|
+
req.off("close", handleClose);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
req.on("data", handleData);
|
|
583
|
+
req.on("end", handleEnd);
|
|
584
|
+
req.on("error", handleError);
|
|
585
|
+
req.on("aborted", handleAborted);
|
|
586
|
+
req.on("close", handleClose);
|
|
587
|
+
req.resume();
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
103
591
|
function collectBody(
|
|
104
592
|
req: IncomingMessage,
|
|
105
593
|
maxBodySize: number
|
|
@@ -107,26 +595,72 @@ function collectBody(
|
|
|
107
595
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
108
596
|
const chunks: Buffer[] = [];
|
|
109
597
|
let totalBytes = 0;
|
|
598
|
+
let isSettled = false;
|
|
599
|
+
|
|
600
|
+
const cleanup = (): void => {
|
|
601
|
+
req.off("data", handleData);
|
|
602
|
+
req.off("end", handleEnd);
|
|
603
|
+
req.off("error", handleError);
|
|
604
|
+
req.off("aborted", handleAborted);
|
|
605
|
+
req.off("close", handleClose);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const rejectOnce = (error: unknown): void => {
|
|
609
|
+
if (isSettled) return;
|
|
610
|
+
isSettled = true;
|
|
611
|
+
cleanup();
|
|
612
|
+
reject(error);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const resolveOnce = (body: ArrayBuffer): void => {
|
|
616
|
+
if (isSettled) return;
|
|
617
|
+
isSettled = true;
|
|
618
|
+
cleanup();
|
|
619
|
+
resolve(body);
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const handleData = (chunk: Buffer): void => {
|
|
623
|
+
if (isSettled) return;
|
|
110
624
|
|
|
111
|
-
req.on("data", (chunk: Buffer) => {
|
|
112
625
|
totalBytes += chunk.byteLength;
|
|
113
|
-
if (totalBytes
|
|
114
|
-
req.
|
|
115
|
-
|
|
626
|
+
if (isBodySizeOverLimit(totalBytes, maxBodySize)) {
|
|
627
|
+
req.pause();
|
|
628
|
+
cleanup();
|
|
629
|
+
req.resume();
|
|
630
|
+
rejectOnce(new PayloadTooLargeError(totalBytes, maxBodySize));
|
|
116
631
|
return;
|
|
117
632
|
}
|
|
118
633
|
chunks.push(chunk);
|
|
119
|
-
}
|
|
634
|
+
};
|
|
120
635
|
|
|
121
|
-
|
|
636
|
+
const handleEnd = (): void => {
|
|
122
637
|
const combined = Buffer.concat(chunks, totalBytes);
|
|
123
|
-
|
|
638
|
+
resolveOnce(
|
|
124
639
|
combined.buffer.slice(
|
|
125
640
|
combined.byteOffset,
|
|
126
641
|
combined.byteOffset + combined.byteLength
|
|
127
642
|
) as ArrayBuffer
|
|
128
643
|
);
|
|
129
|
-
}
|
|
130
|
-
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const handleError = (error: Error): void => {
|
|
647
|
+
rejectOnce(error);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const handleAborted = (): void => {
|
|
651
|
+
rejectOnce(new Error("Request aborted while reading body"));
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const handleClose = (): void => {
|
|
655
|
+
if (!req.readableEnded) {
|
|
656
|
+
rejectOnce(new Error("Request closed before body was fully read"));
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
req.on("data", handleData);
|
|
661
|
+
req.on("end", handleEnd);
|
|
662
|
+
req.on("error", handleError);
|
|
663
|
+
req.on("aborted", handleAborted);
|
|
664
|
+
req.on("close", handleClose);
|
|
131
665
|
});
|
|
132
666
|
}
|