dx-server 0.14.0-alpha.3 → 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 +25 -15
- package/lib/dx.d.ts +13 -6
- package/lib/dx.js +46 -34
- package/lib/dxHelpers.d.ts +2 -3
- package/lib/dxHelpers.js +71 -50
- package/lib/staticHelpers.d.ts +2 -1
- package/lib/staticHelpers.js +10 -4
- package/lib/stream.d.ts +1 -1
- package/lib/stream.js +13 -4
- package/lib/vendors/mime.d.ts +1 -1
- package/lib/vendors/mime.js +7 -11
- 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
|
-
- **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.
|
|
11
|
-
- **Request compression is supported.** Request bodies with `Content-Encoding: gzip`, `deflate`, or `
|
|
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`, `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,18 +425,20 @@ Options:
|
|
|
419
425
|
|
|
420
426
|
#### Response Setters
|
|
421
427
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
- **`
|
|
428
|
-
- **`
|
|
429
|
-
- **`
|
|
430
|
-
- **`
|
|
431
|
-
- **`
|
|
432
|
-
- **`
|
|
433
|
-
- **`
|
|
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.
|
|
432
|
+
|
|
433
|
+
- **`setJson(data, {status?, disableEtag?})`** - JSON response (`application/json`; always UTF-8 per RFC 8259)
|
|
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`)
|
|
437
|
+
- **`setFile(path, {status?, ...SendFileOptions})`** - file (charset/ETag via `SendFileOptions`)
|
|
438
|
+
- **`setNodeStream(stream, {status?})`** - Node.js stream
|
|
439
|
+
- **`setWebStream(stream, {status?})`** - Web stream
|
|
440
|
+
- **`setRedirect(url, status)`** - redirect; `status` is `301 | 302` (required, positional)
|
|
441
|
+
- **`setEmpty({status?, disableEtag?})`** - empty response
|
|
434
442
|
|
|
435
443
|
#### Context Management
|
|
436
444
|
|
|
@@ -467,6 +475,8 @@ headers, use `getRes().setHeader(name, value)` before (or after) calling a sette
|
|
|
467
475
|
etag: 'weak', // 'weak' (default) | 'strong' | 'disabled'
|
|
468
476
|
disableLastModified: false,
|
|
469
477
|
disableFollowSymlinks: false, // set true to 403 files whose real path escapes root
|
|
478
|
+
charset: undefined, // override the Content-Type charset; default comes from the file extension
|
|
479
|
+
// (text/* -> utf-8). e.g. set 'utf-8' to force a charset on a type that has none.
|
|
470
480
|
})
|
|
471
481
|
```
|
|
472
482
|
|
package/lib/dx.d.ts
CHANGED
|
@@ -18,18 +18,25 @@ export declare function dxServer(req: IncomingMessage, res: ServerResponse, opti
|
|
|
18
18
|
}): Chainable;
|
|
19
19
|
export declare function getReq(): IncomingMessage;
|
|
20
20
|
export declare function getRes(): ServerResponse<IncomingMessage>;
|
|
21
|
-
export declare function setText(text: string, { status }?: {
|
|
21
|
+
export declare function setText(text: string, { status, disableEtag }?: {
|
|
22
22
|
status?: number;
|
|
23
|
+
disableEtag?: boolean;
|
|
23
24
|
}): void;
|
|
24
|
-
export declare function
|
|
25
|
+
export declare function setHtml(html: string, options?: {
|
|
25
26
|
status?: number;
|
|
27
|
+
disableEtag?: boolean;
|
|
26
28
|
}): void;
|
|
27
|
-
export declare function
|
|
29
|
+
export declare function setBuffer(buffer: Buffer, { status, disableEtag }?: {
|
|
28
30
|
status?: number;
|
|
31
|
+
disableEtag?: boolean;
|
|
29
32
|
}): void;
|
|
30
|
-
export declare function
|
|
31
|
-
export declare function setBuffer(buffer: Buffer, { status }?: {
|
|
33
|
+
export declare function setJson(json: any, { status, disableEtag }?: {
|
|
32
34
|
status?: number;
|
|
35
|
+
disableEtag?: boolean;
|
|
36
|
+
}): void;
|
|
37
|
+
export declare function setEmpty({ status, disableEtag }?: {
|
|
38
|
+
status?: number;
|
|
39
|
+
disableEtag?: boolean;
|
|
33
40
|
}): void;
|
|
34
41
|
export declare function setNodeStream(stream: Readable, { status }?: {
|
|
35
42
|
status?: number;
|
|
@@ -37,7 +44,7 @@ export declare function setNodeStream(stream: Readable, { status }?: {
|
|
|
37
44
|
export declare function setWebStream(stream: ReadableStream, { status }?: {
|
|
38
45
|
status?: number;
|
|
39
46
|
}): void;
|
|
40
|
-
export declare function
|
|
47
|
+
export declare function setFile(filePath: string, { status, ...options }?: SendFileOptions & {
|
|
41
48
|
status?: number;
|
|
42
49
|
}): void;
|
|
43
50
|
export declare function setRedirect(url: string, status: 301 | 302): void;
|
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,69 +57,78 @@ export function getReq() {
|
|
|
54
57
|
export function getRes() {
|
|
55
58
|
return requestStorage.getStore().res;
|
|
56
59
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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 } = {}) {
|
|
59
67
|
const dx = dxContext.value;
|
|
60
68
|
if (status)
|
|
61
|
-
|
|
69
|
+
getRes().statusCode = status;
|
|
70
|
+
if (disableEtag !== undefined)
|
|
71
|
+
dx.disableEtag = disableEtag;
|
|
62
72
|
dx.data = text;
|
|
63
73
|
dx.type = 'text';
|
|
64
74
|
}
|
|
65
|
-
export function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (status)
|
|
69
|
-
res.statusCode = status;
|
|
70
|
-
dx.data = undefined;
|
|
71
|
-
dx.type = 'empty';
|
|
75
|
+
export function setHtml(html, options = {}) {
|
|
76
|
+
setText(html, options);
|
|
77
|
+
dxContext.value.type = 'html';
|
|
72
78
|
}
|
|
73
|
-
export function
|
|
74
|
-
setText(html, opts);
|
|
79
|
+
export function setBuffer(buffer, { status, disableEtag } = {}) {
|
|
75
80
|
const dx = dxContext.value;
|
|
76
|
-
|
|
81
|
+
if (status)
|
|
82
|
+
getRes().statusCode = status;
|
|
83
|
+
if (disableEtag !== undefined)
|
|
84
|
+
dx.disableEtag = disableEtag;
|
|
85
|
+
dx.data = buffer;
|
|
86
|
+
dx.type = 'buffer';
|
|
77
87
|
}
|
|
78
|
-
export function
|
|
88
|
+
export function setJson(json, { status, disableEtag } = {}) {
|
|
79
89
|
const dx = dxContext.value;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
if (status)
|
|
91
|
+
getRes().statusCode = status;
|
|
92
|
+
if (disableEtag !== undefined)
|
|
93
|
+
dx.disableEtag = disableEtag;
|
|
94
|
+
dx.data = json;
|
|
95
|
+
dx.type = 'json';
|
|
83
96
|
}
|
|
84
|
-
export function
|
|
85
|
-
const res = getRes();
|
|
97
|
+
export function setEmpty({ status, disableEtag } = {}) {
|
|
86
98
|
const dx = dxContext.value;
|
|
87
99
|
if (status)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
getRes().statusCode = status;
|
|
101
|
+
if (disableEtag !== undefined)
|
|
102
|
+
dx.disableEtag = disableEtag;
|
|
103
|
+
dx.data = undefined;
|
|
104
|
+
dx.type = 'empty';
|
|
91
105
|
}
|
|
92
106
|
export function setNodeStream(stream, { status } = {}) {
|
|
93
|
-
const res = getRes();
|
|
94
107
|
const dx = dxContext.value;
|
|
95
108
|
if (status)
|
|
96
|
-
|
|
109
|
+
getRes().statusCode = status;
|
|
97
110
|
dx.data = stream;
|
|
98
111
|
dx.type = 'nodeStream';
|
|
99
112
|
}
|
|
100
113
|
export function setWebStream(stream, { status } = {}) {
|
|
101
|
-
const res = getRes();
|
|
102
114
|
const dx = dxContext.value;
|
|
103
115
|
if (status)
|
|
104
|
-
|
|
116
|
+
getRes().statusCode = status;
|
|
105
117
|
dx.data = stream;
|
|
106
118
|
dx.type = 'webStream';
|
|
107
119
|
}
|
|
108
|
-
export function
|
|
109
|
-
|
|
110
|
-
if (status)
|
|
111
|
-
res.statusCode = status;
|
|
120
|
+
export function setFile(filePath, { status, ...options } = {}) {
|
|
121
|
+
// charset/etag for files come from SendFileOptions (stay in `options`), handled by sendFileTrusted
|
|
112
122
|
const dx = dxContext.value;
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
if (status)
|
|
124
|
+
getRes().statusCode = status;
|
|
125
|
+
dx.data = filePath;
|
|
126
|
+
dx.type = 'file';
|
|
127
|
+
dx.options = options;
|
|
115
128
|
}
|
|
116
129
|
export function setRedirect(url, status) {
|
|
117
|
-
const res = getRes();
|
|
118
130
|
const dx = dxContext.value;
|
|
119
|
-
|
|
131
|
+
getRes().statusCode = status;
|
|
120
132
|
dx.data = url;
|
|
121
133
|
dx.type = 'redirect';
|
|
122
134
|
}
|
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,83 +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
|
-
buffer = data ?? Buffer.from(''
|
|
52
|
+
buffer = data ?? Buffer.from('');
|
|
19
53
|
break;
|
|
20
54
|
case 'json':
|
|
21
55
|
setContentType('application/json');
|
|
22
56
|
buffer =
|
|
23
57
|
data === undefined
|
|
24
|
-
? Buffer.from(''
|
|
25
|
-
:
|
|
58
|
+
? Buffer.from('')
|
|
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));
|
|
26
61
|
break;
|
|
27
62
|
case 'redirect':
|
|
63
|
+
res.setHeader('location', data); // throws: a header-invalid URL (CR/LF, non-latin1)
|
|
64
|
+
buffer = Buffer.from('');
|
|
65
|
+
break;
|
|
28
66
|
case 'empty':
|
|
29
|
-
|
|
30
|
-
res.setHeader('location', data);
|
|
31
|
-
buffer = Buffer.from('', charset);
|
|
67
|
+
buffer = Buffer.from('');
|
|
32
68
|
break;
|
|
33
69
|
// Streaming paths own res.end() themselves (via pipeline/sendFileTrusted) and must be
|
|
34
|
-
// awaited
|
|
70
|
+
// awaited so the chain resolves only after the flush.
|
|
35
71
|
case 'nodeStream':
|
|
36
72
|
case 'webStream':
|
|
37
73
|
if (!data) {
|
|
38
|
-
buffer = Buffer.from(''
|
|
74
|
+
buffer = Buffer.from('');
|
|
39
75
|
break;
|
|
40
76
|
}
|
|
77
|
+
// falls through — a non-empty stream shares the streaming block below
|
|
41
78
|
case 'file':
|
|
42
79
|
// streams have no intrinsic type, so default them to octet-stream. files do NOT get a
|
|
43
80
|
// default here: sendFileTrusted derives Content-Type from the file extension (and falls
|
|
44
81
|
// back to octet-stream itself). pre-setting it would suppress that extension detection.
|
|
45
82
|
if (type !== 'file')
|
|
46
83
|
setContentType('application/octet-stream');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// A streaming helper (pipeline/sendFileTrusted) may already have destroyed res on
|
|
57
|
-
// error (e.g. fs open EACCES mid-stream). Calling res.end() on a destroyed response
|
|
58
|
-
// never resolves, so skip it — otherwise the chain hangs forever.
|
|
59
|
-
if (res.destroyed) {
|
|
60
|
-
// nothing to flush; res is already torn down
|
|
61
|
-
}
|
|
62
|
-
else if (!res.headersSent) {
|
|
63
|
-
res.statusCode = e?.statusCode ?? 500;
|
|
64
|
-
await promisify(res.end.bind(res))();
|
|
65
|
-
}
|
|
66
|
-
else if (!res.writableEnded)
|
|
67
|
-
res.destroy(e);
|
|
68
|
-
}
|
|
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);
|
|
69
93
|
await awaitResFinished(res);
|
|
70
94
|
return;
|
|
71
95
|
case undefined:
|
|
72
96
|
// No setter was called. End the response with 404 instead of leaving it hung.
|
|
73
97
|
if (!res.headersSent)
|
|
74
98
|
res.statusCode = 404;
|
|
75
|
-
|
|
76
|
-
await promisify(res.end.bind(res))();
|
|
99
|
+
res.end(); // throws: torn-down socket -> rethrown to the global catch
|
|
77
100
|
await awaitResFinished(res);
|
|
78
101
|
return;
|
|
79
102
|
default:
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
console.error(new Error(`unsupported response type ${type}`));
|
|
83
|
-
if (!res.headersSent)
|
|
84
|
-
res.statusCode = 500;
|
|
85
|
-
if (!res.writableEnded)
|
|
86
|
-
await promisify(res.end.bind(res))();
|
|
87
|
-
await awaitResFinished(res);
|
|
88
|
-
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}`);
|
|
89
105
|
}
|
|
90
|
-
// 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.
|
|
91
108
|
if (res.statusCode !== 204 && res.statusCode !== 304) {
|
|
92
109
|
// Content-Length and ETag mirror what a GET would send, so a HEAD reports them too.
|
|
93
110
|
res.setHeader('content-length', buffer.length);
|
|
@@ -109,16 +126,16 @@ export async function writeRes(req, res, { type, data, charset, jsonBeautify, di
|
|
|
109
126
|
res.removeHeader('transfer-encoding');
|
|
110
127
|
}
|
|
111
128
|
else if (req.method !== 'HEAD')
|
|
112
|
-
res.write(buffer);
|
|
129
|
+
res.write(buffer); // throws: torn-down socket -> the global catch
|
|
113
130
|
// we do not support content-encoding (gzip, deflate, br) and leave it to reverse proxy or CDN
|
|
114
|
-
|
|
131
|
+
res.end(); // throws: torn-down socket -> the global catch
|
|
115
132
|
await awaitResFinished(res);
|
|
116
133
|
function setContentType(contentType) {
|
|
117
134
|
if (res.headersSent || res.getHeader('content-type'))
|
|
118
135
|
return;
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
res.setHeader('content-type', `${contentType}
|
|
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);
|
|
122
139
|
}
|
|
123
140
|
}
|
|
124
141
|
// Resolves when res is fully flushed (finish) or the socket is gone (close).
|
|
@@ -130,9 +147,13 @@ function awaitResFinished(res) {
|
|
|
130
147
|
return new Promise(resolve => {
|
|
131
148
|
res.once('finish', done);
|
|
132
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);
|
|
133
153
|
function done() {
|
|
134
154
|
res.off('finish', done);
|
|
135
155
|
res.off('close', done);
|
|
156
|
+
res.off('error', done);
|
|
136
157
|
resolve();
|
|
137
158
|
}
|
|
138
159
|
});
|
package/lib/staticHelpers.d.ts
CHANGED
|
@@ -11,10 +11,11 @@ export interface SendFileOptions {
|
|
|
11
11
|
disableCacheControl?: boolean;
|
|
12
12
|
maxAge?: number;
|
|
13
13
|
immutable?: boolean;
|
|
14
|
+
charset?: BufferEncoding;
|
|
14
15
|
disableFollowSymlinks?: boolean;
|
|
15
16
|
end?: number;
|
|
16
17
|
start?: number;
|
|
17
18
|
}
|
|
18
19
|
export declare function sendFileTrusted(req: IncomingMessage, res: ServerResponse, pathname: string, // plain path, not URI-encoded
|
|
19
20
|
{ root, allowDotfiles, start, end, disableAcceptRanges, disableLastModified, etag, disableCacheControl, maxAge, // 1 year
|
|
20
|
-
immutable, disableFollowSymlinks, }?: SendFileOptions): Promise<undefined>;
|
|
21
|
+
immutable, disableFollowSymlinks, charset, }?: SendFileOptions): Promise<undefined>;
|
package/lib/staticHelpers.js
CHANGED
|
@@ -15,7 +15,7 @@ const bytesRangeRegexp = /^ *bytes=/;
|
|
|
15
15
|
const upPathRegexp = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
16
16
|
export async function sendFileTrusted(req, res, pathname, // plain path, not URI-encoded
|
|
17
17
|
{ root, allowDotfiles, start = 0, end, disableAcceptRanges, disableLastModified, etag = 'weak', disableCacheControl, maxAge = 60 * 60 * 24 * 365 * 1000, // 1 year
|
|
18
|
-
immutable, disableFollowSymlinks, } = {}) {
|
|
18
|
+
immutable, disableFollowSymlinks, charset, } = {}) {
|
|
19
19
|
// null byte(s)
|
|
20
20
|
if (pathname.includes('\0'))
|
|
21
21
|
throw httpError('Forbidden', 403);
|
|
@@ -60,9 +60,12 @@ immutable, disableFollowSymlinks, } = {}) {
|
|
|
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();
|
|
@@ -89,7 +92,7 @@ immutable, disableFollowSymlinks, } = {}) {
|
|
|
89
92
|
//endregion set header fields
|
|
90
93
|
// content-type
|
|
91
94
|
if (!res.getHeader('Content-Type'))
|
|
92
|
-
res.setHeader('Content-Type', contentTypeForExtension(path.extname(pathname).slice(1)
|
|
95
|
+
res.setHeader('Content-Type', contentTypeForExtension(path.extname(pathname).slice(1), charset));
|
|
93
96
|
// conditional GET support
|
|
94
97
|
// isConditionalGET
|
|
95
98
|
if (req.headers['if-match'] ||
|
|
@@ -180,6 +183,9 @@ immutable, disableFollowSymlinks, } = {}) {
|
|
|
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.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function contentTypeForExtension(extension: string): string
|
|
1
|
+
export declare function contentTypeForExtension(extension: string, charset?: string): string;
|
package/lib/vendors/mime.js
CHANGED
|
@@ -6,20 +6,16 @@ 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
|
}
|
|
13
|
-
export function contentTypeForExtension(extension) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (charset)
|
|
20
|
-
return mimeType + '; charset=' + charset.toLowerCase();
|
|
21
|
-
}
|
|
22
|
-
return mimeType;
|
|
13
|
+
export function contentTypeForExtension(extension, charset) {
|
|
14
|
+
// unknown extension: octet-stream is binary, so add a charset only when one is explicitly given
|
|
15
|
+
const mimeType = extensionToMime[extension.toLowerCase()] ?? 'application/octet-stream';
|
|
16
|
+
// known text/* (and mime-db charset types) default to their charset; an explicit charset overrides it
|
|
17
|
+
charset ??= determineCharset(mimeType);
|
|
18
|
+
return `${mimeType}${charset ? '; charset=' + charset.toLowerCase() : ''}`;
|
|
23
19
|
}
|
|
24
20
|
const extractTypeRegexp = /^\s*([^;\s]*)(?:;|\s|$)/;
|
|
25
21
|
const textTypeRegexp = /^text\//i;
|