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 CHANGED
@@ -5,12 +5,18 @@ A modern, unopinionated, and performant Node.js server framework built on AsyncL
5
5
  [![npm version](https://img.shields.io/npm/v/dx-server.svg)](https://www.npmjs.com/package/dx-server)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- ## Important caveats
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 `br` are decompressed automatically.
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
- Setters only take a `{status?}` option (except `setRedirect`/`setFile`). To set response
423
- headers, use `getRes().setHeader(name, value)` before (or after) calling a setter.
424
-
425
- - **`setJson(data, {status?})`** - Send JSON response (`application/json; charset=utf-8`)
426
- - **`setHtml(html, {status?})`** - Send HTML response (`text/html; charset=utf-8`)
427
- - **`setText(text, {status?})`** - Send plain text (`text/plain; charset=utf-8`)
428
- - **`setBuffer(buffer, {status?})`** - Send buffer (`application/octet-stream`)
429
- - **`setFile(path, options?)`** - Send file (see `SendFileOptions`)
430
- - **`setNodeStream(stream, {status?})`** - Send Node.js stream
431
- - **`setWebStream(stream, {status?})`** - Send Web stream
432
- - **`setRedirect(url, status)`** - Redirect response; `status` is `301 | 302` (required, positional)
433
- - **`setEmpty({status?})`** - Send empty response
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 setEmpty({ status }?: {
25
+ export declare function setHtml(html: string, options?: {
25
26
  status?: number;
27
+ disableEtag?: boolean;
26
28
  }): void;
27
- export declare function setHtml(html: string, opts?: {
29
+ export declare function setBuffer(buffer: Buffer, { status, disableEtag }?: {
28
30
  status?: number;
31
+ disableEtag?: boolean;
29
32
  }): void;
30
- export declare function setFile(filePath: string, options?: SendFileOptions): void;
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 setJson(json: any, { status }?: {
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
- export function setText(text, { status } = {}) {
58
- const res = getRes();
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
- res.statusCode = status;
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 setEmpty({ status } = {}) {
66
- const res = getRes();
67
- const dx = dxContext.value;
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 setHtml(html, opts = {}) {
74
- setText(html, opts);
79
+ export function setBuffer(buffer, { status, disableEtag } = {}) {
75
80
  const dx = dxContext.value;
76
- dx.type = 'html';
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 setFile(filePath, options) {
88
+ export function setJson(json, { status, disableEtag } = {}) {
79
89
  const dx = dxContext.value;
80
- dx.data = filePath;
81
- dx.type = 'file';
82
- dx.options = options;
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 setBuffer(buffer, { status } = {}) {
85
- const res = getRes();
97
+ export function setEmpty({ status, disableEtag } = {}) {
86
98
  const dx = dxContext.value;
87
99
  if (status)
88
- res.statusCode = status;
89
- dx.data = buffer;
90
- dx.type = 'buffer';
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
- res.statusCode = status;
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
- res.statusCode = status;
116
+ getRes().statusCode = status;
105
117
  dx.data = stream;
106
118
  dx.type = 'webStream';
107
119
  }
108
- export function setJson(json, { status } = {}) {
109
- const res = getRes();
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
- dx.data = json;
114
- dx.type = 'json';
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
- res.statusCode = status;
131
+ getRes().statusCode = status;
120
132
  dx.data = url;
121
133
  dx.type = 'redirect';
122
134
  }
@@ -1,9 +1,8 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
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, { type, data, charset, jsonBeautify, disableEtag, options }: DxContext): Promise<void>;
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
- export async function writeRes(req, res, { type, data, charset, jsonBeautify, disableEtag, options }) {
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 ?? '', charset);
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('', charset);
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('', charset)
25
- : Buffer.from(jsonBeautify ? JSON.stringify(data, null, 2) : JSON.stringify(data), charset);
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
- if (type === 'redirect')
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 to fulfil the "chain resolves after flush" invariant.
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('', charset);
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
- try {
48
- if (type === 'file')
49
- await sendFileTrusted(req, res, data, options);
50
- else if (type === 'nodeStream')
51
- await pipeline(data, res);
52
- else if (type === 'webStream')
53
- await pipeline(Readable.fromWeb(data), res);
54
- }
55
- catch (e) {
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
- if (!res.writableEnded)
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
- // Unknown type: programming error. Surface it via console.error but still finish
81
- // res so the invariant holds (chain resolves only after flush).
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
- await promisify(res.end.bind(res))();
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
- // only text/* carries a charset; binary (octet-stream) and JSON (always UTF-8 per RFC 8259) do not
120
- const cs = charset ?? (contentType.startsWith('text/') ? 'utf-8' : undefined);
121
- 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);
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
  });
@@ -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>;
@@ -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
- if (code === 'EISDIR')
64
- throw httpError('Forbidden: directory access is not allowed', 403);
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)) || 'application/octet-stream');
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, disableInflate?: boolean): IncomingMessage | import("node:zlib").Inflate | import("node:zlib").Gunzip | import("node:zlib").BrotliDecompress;
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, disableInflate) {
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;
@@ -1 +1 @@
1
- export declare function contentTypeForExtension(extension: string): string | undefined;
1
+ export declare function contentTypeForExtension(extension: string, charset?: string): string;
@@ -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 = type0 ? mimeScore(type0, mimeDb[type0].source) : 0;
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
- const mimeType = extensionToMime[extension.toLowerCase()];
15
- if (!mimeType)
16
- return;
17
- if (!mimeType.includes('charset')) {
18
- const charset = determineCharset(mimeType);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dx-server",
3
- "version": "0.14.0-alpha.3",
3
+ "version": "0.14.0-alpha.5",
4
4
  "main": "./lib/index.js",
5
5
  "repository": {
6
6
  "type": "git",