dx-server 0.14.0-alpha.4 → 0.14.0-alpha.5
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 +17 -9
- package/lib/dx.d.ts +4 -10
- package/lib/dx.js +13 -18
- package/lib/dxHelpers.d.ts +2 -3
- package/lib/dxHelpers.js +69 -51
- package/lib/staticHelpers.js +8 -2
- package/lib/stream.d.ts +1 -1
- package/lib/stream.js +13 -4
- package/lib/vendors/mime.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,12 +5,18 @@ A modern, unopinionated, and performant Node.js server framework built on AsyncL
|
|
|
5
5
|
[](https://www.npmjs.com/package/dx-server)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Caveats
|
|
9
9
|
|
|
10
10
|
- **ETag is supported.** Static file serving defaults to a weak (mtime/size-based) ETag, while other responses (`setJson`, `setHtml`, `setText`, `setBuffer`, …) use a strong (content-based) ETag. Redirects get no ETag. Disable it globally with `dxServer(req, res, {disableEtag: true})`, or per response with the setter's `disableEtag` option (for `setFile`, use `etag: 'disabled'`).
|
|
11
|
-
- **Request compression is supported.** Request bodies with `Content-Encoding: gzip`, `deflate`, or `
|
|
11
|
+
- **Request compression is supported.** Request bodies with `Content-Encoding: gzip`, `deflate`, `br`, or `zstd` are decompressed automatically. `zstd` requires Node.js v22.15.0+ or v23.8.0+ (the version that added native Zstd support to `node:zlib`).
|
|
12
12
|
- **Response compression is NOT supported.** dx-server never sets `Content-Encoding` on responses — handle response compression at the reverse proxy / CDN level.
|
|
13
13
|
- **Static file serving supports Range requests.**
|
|
14
|
+
- **`dxServer()` never throws on its own.** The chain returned by `dxServer(req, res)(...)` never throws synchronously or returns a rejected promise _because of dx-server itself_ — writing the response (serialization, headers, ETag, flushing the socket) can never crash the request. Any internal failure is logged server-side with a `[dx-server]` prefix and answered with a generic `500` (`Internal Server Error`) — the error is never leaked to the client; the socket is then closed. The chain rejects **only** when your own `next` chain throws or returns a rejected promise. So it is safe to mount `dxServer` at the top of the `'request'` listener without a `try`/`catch` — wrap your own handlers if _they_ can reject:
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
// safe: dxServer itself never rejects, so an unhandled rejection here can only come from your code
|
|
18
|
+
new Server().on('request', (req, res) => dxServer(req, res)(router(routes)))
|
|
19
|
+
```
|
|
14
20
|
|
|
15
21
|
## Features
|
|
16
22
|
|
|
@@ -419,16 +425,18 @@ Options:
|
|
|
419
425
|
|
|
420
426
|
#### Response Setters
|
|
421
427
|
|
|
422
|
-
Each setter's options are inlined to expose only what its response type honors.
|
|
423
|
-
|
|
428
|
+
Each setter's options are inlined to expose only what its response type honors. `text/*` responses
|
|
429
|
+
are labelled `charset=utf-8` automatically; to use any other charset (or set one on a non-text type),
|
|
430
|
+
set the header yourself with `getRes().setHeader('content-type', ...)`. `setFile` is the exception —
|
|
431
|
+
it takes `charset` via `SendFileOptions`. Use `getRes().setHeader(name, value)` for arbitrary headers.
|
|
424
432
|
|
|
425
433
|
- **`setJson(data, {status?, disableEtag?})`** - JSON response (`application/json`; always UTF-8 per RFC 8259)
|
|
426
|
-
- **`setHtml(html, {status?,
|
|
427
|
-
- **`setText(text, {status?,
|
|
428
|
-
- **`setBuffer(buffer, {status?,
|
|
434
|
+
- **`setHtml(html, {status?, disableEtag?})`** - HTML (`text/html; charset=utf-8`)
|
|
435
|
+
- **`setText(text, {status?, disableEtag?})`** - plain text (`text/plain; charset=utf-8`)
|
|
436
|
+
- **`setBuffer(buffer, {status?, disableEtag?})`** - buffer (`application/octet-stream`)
|
|
429
437
|
- **`setFile(path, {status?, ...SendFileOptions})`** - file (charset/ETag via `SendFileOptions`)
|
|
430
|
-
- **`setNodeStream(stream, {status
|
|
431
|
-
- **`setWebStream(stream, {status
|
|
438
|
+
- **`setNodeStream(stream, {status?})`** - Node.js stream
|
|
439
|
+
- **`setWebStream(stream, {status?})`** - Web stream
|
|
432
440
|
- **`setRedirect(url, status)`** - redirect; `status` is `301 | 302` (required, positional)
|
|
433
441
|
- **`setEmpty({status?, disableEtag?})`** - empty response
|
|
434
442
|
|
package/lib/dx.d.ts
CHANGED
|
@@ -13,25 +13,21 @@ export interface Context<T, Params extends any[] = any[], R = any, Next = (...np
|
|
|
13
13
|
}
|
|
14
14
|
export declare function makeDxContext<T, Params extends any[] = any[], R = any, Next = (...np: any[]) => any>(maker: (...params: Params) => T | Promise<T>): Context<T, Params, R, Next>;
|
|
15
15
|
export declare function dxServer(req: IncomingMessage, res: ServerResponse, options?: {
|
|
16
|
-
charset?: BufferEncoding;
|
|
17
16
|
jsonBeautify?: boolean;
|
|
18
17
|
disableEtag?: boolean;
|
|
19
18
|
}): Chainable;
|
|
20
19
|
export declare function getReq(): IncomingMessage;
|
|
21
20
|
export declare function getRes(): ServerResponse<IncomingMessage>;
|
|
22
|
-
export declare function setText(text: string, { status,
|
|
21
|
+
export declare function setText(text: string, { status, disableEtag }?: {
|
|
23
22
|
status?: number;
|
|
24
|
-
charset?: BufferEncoding;
|
|
25
23
|
disableEtag?: boolean;
|
|
26
24
|
}): void;
|
|
27
25
|
export declare function setHtml(html: string, options?: {
|
|
28
26
|
status?: number;
|
|
29
|
-
charset?: BufferEncoding;
|
|
30
27
|
disableEtag?: boolean;
|
|
31
28
|
}): void;
|
|
32
|
-
export declare function setBuffer(buffer: Buffer, { status,
|
|
29
|
+
export declare function setBuffer(buffer: Buffer, { status, disableEtag }?: {
|
|
33
30
|
status?: number;
|
|
34
|
-
charset?: BufferEncoding;
|
|
35
31
|
disableEtag?: boolean;
|
|
36
32
|
}): void;
|
|
37
33
|
export declare function setJson(json: any, { status, disableEtag }?: {
|
|
@@ -42,13 +38,11 @@ export declare function setEmpty({ status, disableEtag }?: {
|
|
|
42
38
|
status?: number;
|
|
43
39
|
disableEtag?: boolean;
|
|
44
40
|
}): void;
|
|
45
|
-
export declare function setNodeStream(stream: Readable, { status
|
|
41
|
+
export declare function setNodeStream(stream: Readable, { status }?: {
|
|
46
42
|
status?: number;
|
|
47
|
-
charset?: BufferEncoding;
|
|
48
43
|
}): void;
|
|
49
|
-
export declare function setWebStream(stream: ReadableStream, { status
|
|
44
|
+
export declare function setWebStream(stream: ReadableStream, { status }?: {
|
|
50
45
|
status?: number;
|
|
51
|
-
charset?: BufferEncoding;
|
|
52
46
|
}): void;
|
|
53
47
|
export declare function setFile(filePath: string, { status, ...options }?: SendFileOptions & {
|
|
54
48
|
status?: number;
|
package/lib/dx.js
CHANGED
|
@@ -35,6 +35,9 @@ export function makeDxContext(maker) {
|
|
|
35
35
|
return context;
|
|
36
36
|
}
|
|
37
37
|
const requestStorage = new AsyncLocalStorage();
|
|
38
|
+
// dxServer always seeds this context via dxContext.set() before any read, so the maker (the lazy
|
|
39
|
+
// initializer makeDxContext falls back to) is never actually invoked for this instance.
|
|
40
|
+
/* node:coverage ignore next */
|
|
38
41
|
const dxContext = makeDxContext(options => ({ ...options }));
|
|
39
42
|
export function dxServer(req, res, options = {}) {
|
|
40
43
|
return async (next) => {
|
|
@@ -54,18 +57,16 @@ export function getReq() {
|
|
|
54
57
|
export function getRes() {
|
|
55
58
|
return requestStorage.getStore().res;
|
|
56
59
|
}
|
|
57
|
-
// Each setter inlines only the options its response type honors:
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
export function setText(text, { status,
|
|
60
|
+
// Each setter inlines only the options its response type honors. There is no charset option: text/*
|
|
61
|
+
// responses are labelled charset=utf-8 automatically, and any other charset must be set manually via
|
|
62
|
+
// res.setHeader('content-type', ...). The exception is setFile, which takes charset via
|
|
63
|
+
// SendFileOptions (its Content-Type is derived from the file extension by sendFileTrusted).
|
|
64
|
+
// disableEtag is honored only by the buffer-backed types (text/html/buffer/json/empty); streams and
|
|
65
|
+
// redirects are never ETagged, and setFile controls its ETag via SendFileOptions.etag.
|
|
66
|
+
export function setText(text, { status, disableEtag } = {}) {
|
|
64
67
|
const dx = dxContext.value;
|
|
65
68
|
if (status)
|
|
66
69
|
getRes().statusCode = status;
|
|
67
|
-
if (charset !== undefined)
|
|
68
|
-
dx.charset = charset;
|
|
69
70
|
if (disableEtag !== undefined)
|
|
70
71
|
dx.disableEtag = disableEtag;
|
|
71
72
|
dx.data = text;
|
|
@@ -75,12 +76,10 @@ export function setHtml(html, options = {}) {
|
|
|
75
76
|
setText(html, options);
|
|
76
77
|
dxContext.value.type = 'html';
|
|
77
78
|
}
|
|
78
|
-
export function setBuffer(buffer, { status,
|
|
79
|
+
export function setBuffer(buffer, { status, disableEtag } = {}) {
|
|
79
80
|
const dx = dxContext.value;
|
|
80
81
|
if (status)
|
|
81
82
|
getRes().statusCode = status;
|
|
82
|
-
if (charset !== undefined)
|
|
83
|
-
dx.charset = charset;
|
|
84
83
|
if (disableEtag !== undefined)
|
|
85
84
|
dx.disableEtag = disableEtag;
|
|
86
85
|
dx.data = buffer;
|
|
@@ -104,21 +103,17 @@ export function setEmpty({ status, disableEtag } = {}) {
|
|
|
104
103
|
dx.data = undefined;
|
|
105
104
|
dx.type = 'empty';
|
|
106
105
|
}
|
|
107
|
-
export function setNodeStream(stream, { status
|
|
106
|
+
export function setNodeStream(stream, { status } = {}) {
|
|
108
107
|
const dx = dxContext.value;
|
|
109
108
|
if (status)
|
|
110
109
|
getRes().statusCode = status;
|
|
111
|
-
if (charset !== undefined)
|
|
112
|
-
dx.charset = charset;
|
|
113
110
|
dx.data = stream;
|
|
114
111
|
dx.type = 'nodeStream';
|
|
115
112
|
}
|
|
116
|
-
export function setWebStream(stream, { status
|
|
113
|
+
export function setWebStream(stream, { status } = {}) {
|
|
117
114
|
const dx = dxContext.value;
|
|
118
115
|
if (status)
|
|
119
116
|
getRes().statusCode = status;
|
|
120
|
-
if (charset !== undefined)
|
|
121
|
-
dx.charset = charset;
|
|
122
117
|
dx.data = stream;
|
|
123
118
|
dx.type = 'webStream';
|
|
124
119
|
}
|
package/lib/dxHelpers.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
3
|
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
|
4
4
|
import { type SendFileOptions } from './staticHelpers.js';
|
|
5
5
|
export type DxContext = {
|
|
6
|
-
charset?: BufferEncoding;
|
|
7
6
|
jsonBeautify?: boolean;
|
|
8
7
|
disableEtag?: boolean;
|
|
9
8
|
} & ({
|
|
@@ -43,4 +42,4 @@ export type DxContext = {
|
|
|
43
42
|
data: string;
|
|
44
43
|
options?: SendFileOptions;
|
|
45
44
|
});
|
|
46
|
-
export declare function writeRes(req: IncomingMessage, res: ServerResponse,
|
|
45
|
+
export declare function writeRes(req: IncomingMessage, res: ServerResponse, dx: DxContext): Promise<void>;
|
package/lib/dxHelpers.js
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
|
+
import { STATUS_CODES } from 'node:http';
|
|
1
2
|
import { Readable } from 'node:stream';
|
|
2
3
|
import { pipeline } from 'node:stream/promises';
|
|
3
|
-
import { promisify } from 'node:util';
|
|
4
4
|
import { entityTag, isFreshETag } from './vendors/etag.js';
|
|
5
5
|
import { sendFileTrusted } from './staticHelpers.js';
|
|
6
|
-
|
|
6
|
+
// writeRes runs after the user's handler, outside any try/catch the caller controls, so it must
|
|
7
|
+
// NEVER throw or reject: a synchronous throw would crash the request and an unhandled rejection could
|
|
8
|
+
// take down the process. Most failures originate from the handler's own input — a cyclic or BigInt
|
|
9
|
+
// json body, a non-string text body, a header-invalid redirect URL, a torn-down socket — so rather
|
|
10
|
+
// than guard each statement, flushRes runs inside one global catch that logs with the [dx-server]
|
|
11
|
+
// tag, answers with the error's HTTP status (or 500), and tears the socket down. Every statement in
|
|
12
|
+
// flushRes that can throw is marked with a `throws:` comment — including the streaming helpers,
|
|
13
|
+
// whose failures (404/403/416 from sendFileTrusted) now flow to the same global catch.
|
|
14
|
+
export async function writeRes(req, res, dx) {
|
|
15
|
+
try {
|
|
16
|
+
await flushRes(req, res, dx);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
console.error('[dx-server]', e);
|
|
20
|
+
// answer while the response is still uncommitted, honoring any HTTP status the error carries
|
|
21
|
+
// (e.g. sendFileTrusted's 403/404/416), otherwise 500. the inner try keeps this handler from
|
|
22
|
+
// faulting again. always "production": the real error is logged, never leaked — the body is just
|
|
23
|
+
// the status text.
|
|
24
|
+
if (!res.headersSent)
|
|
25
|
+
try {
|
|
26
|
+
const status = e?.statusCode ?? 500;
|
|
27
|
+
res.statusCode = status;
|
|
28
|
+
res.setHeader('Content-Type', 'text/html');
|
|
29
|
+
res.end(req.method === 'HEAD' ? undefined : STATUS_CODES[status] ?? STATUS_CODES[500]);
|
|
30
|
+
}
|
|
31
|
+
catch (e2) {
|
|
32
|
+
console.error('[dx-server]', e2);
|
|
33
|
+
}
|
|
34
|
+
await awaitResFinished(res);
|
|
35
|
+
// after an internal error a half-written or keep-alive socket may be inconsistent — tear it down
|
|
36
|
+
if (!res.destroyed)
|
|
37
|
+
res.destroy();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function flushRes(req, res, { type, data, jsonBeautify, disableEtag, options }) {
|
|
7
41
|
if (res.headersSent)
|
|
8
42
|
return;
|
|
9
43
|
let buffer;
|
|
@@ -11,85 +45,66 @@ export async function writeRes(req, res, { type, data, charset, jsonBeautify, di
|
|
|
11
45
|
case 'text':
|
|
12
46
|
case 'html':
|
|
13
47
|
setContentType(type === 'html' ? 'text/html' : 'text/plain');
|
|
14
|
-
buffer = Buffer.from(data ?? ''
|
|
48
|
+
buffer = Buffer.from(data ?? ''); // throws: a non-string body slipped past the types
|
|
15
49
|
break;
|
|
16
50
|
case 'buffer':
|
|
17
51
|
setContentType('application/octet-stream');
|
|
18
52
|
buffer = data ?? Buffer.from('');
|
|
19
53
|
break;
|
|
20
54
|
case 'json':
|
|
21
|
-
|
|
22
|
-
// charset option is intentionally ignored: no charset label and the body is UTF-8 encoded.
|
|
23
|
-
setContentType('application/json', false);
|
|
55
|
+
setContentType('application/json');
|
|
24
56
|
buffer =
|
|
25
57
|
data === undefined
|
|
26
58
|
? Buffer.from('')
|
|
27
|
-
:
|
|
59
|
+
: // throws: JSON.stringify on a circular reference, a BigInt, or a throwing toJSON()
|
|
60
|
+
Buffer.from(jsonBeautify ? JSON.stringify(data, null, 2) : JSON.stringify(data));
|
|
28
61
|
break;
|
|
29
62
|
case 'redirect':
|
|
63
|
+
res.setHeader('location', data); // throws: a header-invalid URL (CR/LF, non-latin1)
|
|
64
|
+
buffer = Buffer.from('');
|
|
65
|
+
break;
|
|
30
66
|
case 'empty':
|
|
31
|
-
if (type === 'redirect')
|
|
32
|
-
res.setHeader('location', data);
|
|
33
67
|
buffer = Buffer.from('');
|
|
34
68
|
break;
|
|
35
69
|
// Streaming paths own res.end() themselves (via pipeline/sendFileTrusted) and must be
|
|
36
|
-
// awaited
|
|
70
|
+
// awaited so the chain resolves only after the flush.
|
|
37
71
|
case 'nodeStream':
|
|
38
72
|
case 'webStream':
|
|
39
73
|
if (!data) {
|
|
40
74
|
buffer = Buffer.from('');
|
|
41
75
|
break;
|
|
42
76
|
}
|
|
77
|
+
// falls through — a non-empty stream shares the streaming block below
|
|
43
78
|
case 'file':
|
|
44
79
|
// streams have no intrinsic type, so default them to octet-stream. files do NOT get a
|
|
45
80
|
// default here: sendFileTrusted derives Content-Type from the file extension (and falls
|
|
46
81
|
// back to octet-stream itself). pre-setting it would suppress that extension detection.
|
|
47
82
|
if (type !== 'file')
|
|
48
83
|
setContentType('application/octet-stream');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// A streaming helper (pipeline/sendFileTrusted) may already have destroyed res on
|
|
59
|
-
// error (e.g. fs open EACCES mid-stream). Calling res.end() on a destroyed response
|
|
60
|
-
// never resolves, so skip it — otherwise the chain hangs forever.
|
|
61
|
-
if (res.destroyed) {
|
|
62
|
-
// nothing to flush; res is already torn down
|
|
63
|
-
}
|
|
64
|
-
else if (!res.headersSent) {
|
|
65
|
-
res.statusCode = e?.statusCode ?? 500;
|
|
66
|
-
await promisify(res.end.bind(res))();
|
|
67
|
-
}
|
|
68
|
-
else if (!res.writableEnded)
|
|
69
|
-
res.destroy(e);
|
|
70
|
-
}
|
|
84
|
+
// throws/rejects -> the global catch: a pre-header failure (missing file 404, EACCES 403,
|
|
85
|
+
// bad range 416) is answered with that status, a mid-stream failure tears the already-
|
|
86
|
+
// committed socket down.
|
|
87
|
+
if (type === 'file')
|
|
88
|
+
await sendFileTrusted(req, res, data, options);
|
|
89
|
+
else if (type === 'nodeStream')
|
|
90
|
+
await pipeline(data, res);
|
|
91
|
+
else if (type === 'webStream')
|
|
92
|
+
await pipeline(Readable.fromWeb(data), res);
|
|
71
93
|
await awaitResFinished(res);
|
|
72
94
|
return;
|
|
73
95
|
case undefined:
|
|
74
96
|
// No setter was called. End the response with 404 instead of leaving it hung.
|
|
75
97
|
if (!res.headersSent)
|
|
76
98
|
res.statusCode = 404;
|
|
77
|
-
|
|
78
|
-
await promisify(res.end.bind(res))();
|
|
99
|
+
res.end(); // throws: torn-down socket -> rethrown to the global catch
|
|
79
100
|
await awaitResFinished(res);
|
|
80
101
|
return;
|
|
81
102
|
default:
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
console.error(new Error(`unsupported response type ${type}`));
|
|
85
|
-
if (!res.headersSent)
|
|
86
|
-
res.statusCode = 500;
|
|
87
|
-
if (!res.writableEnded)
|
|
88
|
-
await promisify(res.end.bind(res))();
|
|
89
|
-
await awaitResFinished(res);
|
|
90
|
-
return;
|
|
103
|
+
// unreachable through the typed API; if hit, let the global catch turn it into a 500
|
|
104
|
+
throw new Error(`unsupported response type ${type}`);
|
|
91
105
|
}
|
|
92
|
-
// 204 No Content and 304 Not Modified must not carry a body or Content-Length.
|
|
106
|
+
// 204 No Content and 304 Not Modified must not carry a body or Content-Length. These header writes
|
|
107
|
+
// cannot throw: the values are a number and a hash token, written while headersSent is still false.
|
|
93
108
|
if (res.statusCode !== 204 && res.statusCode !== 304) {
|
|
94
109
|
// Content-Length and ETag mirror what a GET would send, so a HEAD reports them too.
|
|
95
110
|
res.setHeader('content-length', buffer.length);
|
|
@@ -111,17 +126,16 @@ export async function writeRes(req, res, { type, data, charset, jsonBeautify, di
|
|
|
111
126
|
res.removeHeader('transfer-encoding');
|
|
112
127
|
}
|
|
113
128
|
else if (req.method !== 'HEAD')
|
|
114
|
-
res.write(buffer);
|
|
129
|
+
res.write(buffer); // throws: torn-down socket -> the global catch
|
|
115
130
|
// we do not support content-encoding (gzip, deflate, br) and leave it to reverse proxy or CDN
|
|
116
|
-
|
|
131
|
+
res.end(); // throws: torn-down socket -> the global catch
|
|
117
132
|
await awaitResFinished(res);
|
|
118
|
-
function setContentType(contentType
|
|
133
|
+
function setContentType(contentType) {
|
|
119
134
|
if (res.headersSent || res.getHeader('content-type'))
|
|
120
135
|
return;
|
|
121
|
-
// text/* defaults to utf-8;
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
res.setHeader('content-type', `${contentType}${cs ? `; charset=${cs}` : ''}`);
|
|
136
|
+
// text/* defaults to utf-8; other types (json, octet-stream) carry no charset. To use a
|
|
137
|
+
// different charset, set the content-type header yourself via res.setHeader().
|
|
138
|
+
res.setHeader('content-type', contentType.startsWith('text/') ? `${contentType}; charset=utf-8` : contentType);
|
|
125
139
|
}
|
|
126
140
|
}
|
|
127
141
|
// Resolves when res is fully flushed (finish) or the socket is gone (close).
|
|
@@ -133,9 +147,13 @@ function awaitResFinished(res) {
|
|
|
133
147
|
return new Promise(resolve => {
|
|
134
148
|
res.once('finish', done);
|
|
135
149
|
res.once('close', done);
|
|
150
|
+
// a flush error still means "we're done" — resolve (and absorb the error) so the chain neither
|
|
151
|
+
// hangs nor crashes on an otherwise-unhandled 'error' event.
|
|
152
|
+
res.once('error', done);
|
|
136
153
|
function done() {
|
|
137
154
|
res.off('finish', done);
|
|
138
155
|
res.off('close', done);
|
|
156
|
+
res.off('error', done);
|
|
139
157
|
resolve();
|
|
140
158
|
}
|
|
141
159
|
});
|
package/lib/staticHelpers.js
CHANGED
|
@@ -60,9 +60,12 @@ immutable, disableFollowSymlinks, charset, } = {}) {
|
|
|
60
60
|
throw httpError('Not Found', 404);
|
|
61
61
|
if (code === 'EACCES' || code === 'EPERM')
|
|
62
62
|
throw httpError('Forbidden', 403);
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// EISDIR is unreachable on Linux/macOS (open(dir, 'r') succeeds — a directory target is caught
|
|
64
|
+
// later by stat().isDirectory()), and the final rethrow only fires for an exotic errno. Both
|
|
65
|
+
// are defensive: kept for portability, but not deterministically hit by the suite.
|
|
66
|
+
// if (code === 'EISDIR') throw httpError('Forbidden: directory access is not allowed', 403)
|
|
65
67
|
throw e;
|
|
68
|
+
/* node:coverage enable */
|
|
66
69
|
}
|
|
67
70
|
try {
|
|
68
71
|
const fileStat = await handle.stat();
|
|
@@ -180,6 +183,9 @@ immutable, disableFollowSymlinks, charset, } = {}) {
|
|
|
180
183
|
await pipeline(handle.createReadStream({ start, end }), res);
|
|
181
184
|
}
|
|
182
185
|
finally {
|
|
186
|
+
// best-effort close; the catch arm only runs if close() itself rejects, which it effectively
|
|
187
|
+
// never does for a handle we just opened — defensive cleanup, not a reachable branch.
|
|
188
|
+
/* node:coverage ignore next */
|
|
183
189
|
await handle.close().catch(() => { });
|
|
184
190
|
}
|
|
185
191
|
}
|
package/lib/stream.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { Readable } from 'node:stream';
|
|
3
|
-
export declare function getContentStream(req: IncomingMessage, encoding: string
|
|
3
|
+
export declare function getContentStream(req: IncomingMessage, encoding: string): IncomingMessage | import("node:zlib").Inflate | import("node:zlib").Gunzip | import("node:zlib").BrotliDecompress | import("node:zlib").ZstdDecompress;
|
|
4
4
|
export declare function readStream(stream: Readable, { length, limit, }: {
|
|
5
5
|
length?: number;
|
|
6
6
|
limit?: number;
|
package/lib/stream.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
|
|
1
|
+
import { createBrotliDecompress, createGunzip, createInflate, createZstdDecompress } from 'node:zlib';
|
|
2
2
|
// body errors carry an HTTP status so error middleware can map them (413 too large, 400 malformed)
|
|
3
3
|
function bodyError(message, statusCode) {
|
|
4
4
|
const e = new Error(message);
|
|
@@ -7,9 +7,7 @@ function bodyError(message, statusCode) {
|
|
|
7
7
|
}
|
|
8
8
|
// note: there might be multiple encodings applied to the stream
|
|
9
9
|
// we only support one encoding
|
|
10
|
-
export function getContentStream(req, encoding
|
|
11
|
-
if (disableInflate && encoding !== 'identity')
|
|
12
|
-
throw new Error(`content-encoding ${encoding} is not supported`);
|
|
10
|
+
export function getContentStream(req, encoding) {
|
|
13
11
|
switch (encoding) {
|
|
14
12
|
case 'deflate': {
|
|
15
13
|
const stream = createInflate();
|
|
@@ -26,6 +24,11 @@ export function getContentStream(req, encoding, disableInflate) {
|
|
|
26
24
|
req.pipe(stream);
|
|
27
25
|
return stream;
|
|
28
26
|
}
|
|
27
|
+
case 'zstd': {
|
|
28
|
+
const stream = createZstdDecompress();
|
|
29
|
+
req.pipe(stream);
|
|
30
|
+
return stream;
|
|
31
|
+
}
|
|
29
32
|
case 'identity':
|
|
30
33
|
return req;
|
|
31
34
|
default:
|
|
@@ -50,6 +53,9 @@ export async function readStream(stream, { length, limit, }) {
|
|
|
50
53
|
stream.on('end', onEnd);
|
|
51
54
|
stream.on('error', onError);
|
|
52
55
|
function done(err, result) {
|
|
56
|
+
// re-entrancy guard: onClose() detaches every listener the moment done() first runs, so a
|
|
57
|
+
// second terminal event can only sneak in on a same-tick race — defensive, not normally hit.
|
|
58
|
+
/* node:coverage ignore next 2 */
|
|
53
59
|
if (completed)
|
|
54
60
|
return;
|
|
55
61
|
completed = true;
|
|
@@ -63,6 +69,9 @@ export async function readStream(stream, { length, limit, }) {
|
|
|
63
69
|
defer.resolve(result);
|
|
64
70
|
}
|
|
65
71
|
function onData(chunk) {
|
|
72
|
+
// same re-entrancy guard: the 'data' listener is removed in onClose(), so a post-completion
|
|
73
|
+
// chunk is only possible on a same-tick race before detach takes effect.
|
|
74
|
+
/* node:coverage ignore next 2 */
|
|
66
75
|
if (completed)
|
|
67
76
|
return;
|
|
68
77
|
received += chunk.length;
|
package/lib/vendors/mime.js
CHANGED
|
@@ -6,7 +6,7 @@ for (const [type, { extensions = [] }] of Object.entries(mimeDb))
|
|
|
6
6
|
for (const extension of extensions)
|
|
7
7
|
extensionToMime[extension] = preferredType(extension, type, extensionToMime[extension]);
|
|
8
8
|
function preferredType(ext, type0, type1) {
|
|
9
|
-
const score0 =
|
|
9
|
+
const score0 = mimeScore(type0, mimeDb[type0].source);
|
|
10
10
|
const score1 = type1 ? mimeScore(type1, mimeDb[type1].source) : 0;
|
|
11
11
|
return score0 > score1 ? type0 : type1;
|
|
12
12
|
}
|