@naturalcycles/backend-lib 9.77.2 → 9.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compressible.d.ts +9 -0
- package/dist/compressible.js +57 -0
- package/dist/onFinished.d.ts +14 -2
- package/dist/onFinished.js +35 -2
- package/dist/onHeaders.d.ts +11 -0
- package/dist/onHeaders.js +56 -0
- package/dist/server/compressionMiddleware.d.ts +14 -1
- package/dist/server/compressionMiddleware.js +84 -30
- package/dist/vary.d.ts +8 -0
- package/dist/vary.js +25 -0
- package/package.json +2 -10
- package/src/compressible.ts +59 -0
- package/src/onFinished.ts +43 -2
- package/src/onHeaders.ts +66 -0
- package/src/server/compressionMiddleware.ts +110 -31
- package/src/vary.ts +29 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the given Content-Type (parameters, e.g `; charset=utf-8`, are ignored) is compressible.
|
|
3
|
+
*
|
|
4
|
+
* Vendored (and modernized) from the `compressible` package.
|
|
5
|
+
* Instead of loading the whole `mime-db` (~2500 types), it's reduced to the equivalent:
|
|
6
|
+
* a regex + the list of compressible types that the regex doesn't match (as of mime-db@1.54.0).
|
|
7
|
+
* No type matched by the regex is marked as non-compressible in mime-db.
|
|
8
|
+
*/
|
|
9
|
+
export declare function compressible(type: string): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the given Content-Type (parameters, e.g `; charset=utf-8`, are ignored) is compressible.
|
|
3
|
+
*
|
|
4
|
+
* Vendored (and modernized) from the `compressible` package.
|
|
5
|
+
* Instead of loading the whole `mime-db` (~2500 types), it's reduced to the equivalent:
|
|
6
|
+
* a regex + the list of compressible types that the regex doesn't match (as of mime-db@1.54.0).
|
|
7
|
+
* No type matched by the regex is marked as non-compressible in mime-db.
|
|
8
|
+
*/
|
|
9
|
+
export function compressible(type) {
|
|
10
|
+
const mime = EXTRACT_TYPE_REGEX.exec(type)[1].toLowerCase();
|
|
11
|
+
return COMPRESSIBLE_TYPE_REGEX.test(mime) || COMPRESSIBLE_TYPES.has(mime);
|
|
12
|
+
}
|
|
13
|
+
const EXTRACT_TYPE_REGEX = /^\s*([^;\s]*)/;
|
|
14
|
+
const COMPRESSIBLE_TYPE_REGEX = /^text\/|\+(?:json|text|xml)$/;
|
|
15
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
16
|
+
'application/dart',
|
|
17
|
+
'application/ecmascript',
|
|
18
|
+
'application/javascript',
|
|
19
|
+
'application/json',
|
|
20
|
+
'application/octet-stream',
|
|
21
|
+
'application/postscript',
|
|
22
|
+
'application/raml+yaml',
|
|
23
|
+
'application/rtf',
|
|
24
|
+
'application/tar',
|
|
25
|
+
'application/toml',
|
|
26
|
+
'application/vnd.dart',
|
|
27
|
+
'application/vnd.ms-fontobject',
|
|
28
|
+
'application/vnd.ms-opentype',
|
|
29
|
+
'application/wasm',
|
|
30
|
+
'application/x-httpd-php',
|
|
31
|
+
'application/x-javascript',
|
|
32
|
+
'application/x-ns-proxy-autoconfig',
|
|
33
|
+
'application/x-sh',
|
|
34
|
+
'application/x-tar',
|
|
35
|
+
'application/x-virtualbox-hdd',
|
|
36
|
+
'application/x-virtualbox-ova',
|
|
37
|
+
'application/x-virtualbox-ovf',
|
|
38
|
+
'application/x-virtualbox-vbox',
|
|
39
|
+
'application/x-virtualbox-vdi',
|
|
40
|
+
'application/x-virtualbox-vhd',
|
|
41
|
+
'application/x-virtualbox-vmdk',
|
|
42
|
+
'application/x-www-form-urlencoded',
|
|
43
|
+
'application/xml',
|
|
44
|
+
'application/xml-dtd',
|
|
45
|
+
'font/otf',
|
|
46
|
+
'font/ttf',
|
|
47
|
+
'image/bmp',
|
|
48
|
+
'image/vnd.adobe.photoshop',
|
|
49
|
+
'image/vnd.microsoft.icon',
|
|
50
|
+
'image/vnd.ms-dds',
|
|
51
|
+
'image/x-icon',
|
|
52
|
+
'image/x-ms-bmp',
|
|
53
|
+
'message/rfc822',
|
|
54
|
+
'model/gltf-binary',
|
|
55
|
+
'x-shader/x-fragment',
|
|
56
|
+
'x-shader/x-vertex',
|
|
57
|
+
]);
|
package/dist/onFinished.d.ts
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Invokes the listener once the request/response has finished (completed, closed or errored).
|
|
4
|
+
* If it has already finished - the listener is invoked asynchronously (on the next tick).
|
|
5
|
+
*
|
|
6
|
+
* Listener runs in the async context (e.g AsyncLocalStorage) of the onFinished caller.
|
|
7
|
+
*
|
|
8
|
+
* Vendored (and modernized) from the `on-finished` package, built on top of `stream.finished`.
|
|
9
|
+
* Stream listeners are attached once per msg (subsequent calls are queued), as `stream.finished`
|
|
10
|
+
* is too expensive to call per listener (and would exceed the default MaxListeners).
|
|
11
|
+
*/
|
|
12
|
+
export declare function onFinished<T extends IncomingMessage | ServerResponse>(msg: T, listener: Listener<T>): T;
|
|
13
|
+
type Listener<T> = (err: Error | null, msg: T) => void;
|
|
14
|
+
export {};
|
package/dist/onFinished.js
CHANGED
|
@@ -1,2 +1,35 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
2
|
+
import { finished } from 'node:stream';
|
|
3
|
+
/**
|
|
4
|
+
* Invokes the listener once the request/response has finished (completed, closed or errored).
|
|
5
|
+
* If it has already finished - the listener is invoked asynchronously (on the next tick).
|
|
6
|
+
*
|
|
7
|
+
* Listener runs in the async context (e.g AsyncLocalStorage) of the onFinished caller.
|
|
8
|
+
*
|
|
9
|
+
* Vendored (and modernized) from the `on-finished` package, built on top of `stream.finished`.
|
|
10
|
+
* Stream listeners are attached once per msg (subsequent calls are queued), as `stream.finished`
|
|
11
|
+
* is too expensive to call per listener (and would exceed the default MaxListeners).
|
|
12
|
+
*/
|
|
13
|
+
export function onFinished(msg, listener) {
|
|
14
|
+
const m = msg;
|
|
15
|
+
const queue = m[kQueue];
|
|
16
|
+
if (queue) {
|
|
17
|
+
queue.push(bindAsyncContext(listener));
|
|
18
|
+
return msg;
|
|
19
|
+
}
|
|
20
|
+
const q = (m[kQueue] = [bindAsyncContext(listener)]);
|
|
21
|
+
finished(msg, err => {
|
|
22
|
+
m[kQueue] = undefined;
|
|
23
|
+
for (const fn of q)
|
|
24
|
+
fn(err ?? null, msg);
|
|
25
|
+
});
|
|
26
|
+
return msg;
|
|
27
|
+
}
|
|
28
|
+
const kQueue = Symbol('onFinished');
|
|
29
|
+
/**
|
|
30
|
+
* Faster alternative to `AsyncResource.bind` (which is ~0.8µs per call).
|
|
31
|
+
*/
|
|
32
|
+
function bindAsyncContext(fn) {
|
|
33
|
+
const resource = new AsyncResource('onFinished');
|
|
34
|
+
return (err, msg) => resource.runInAsyncScope(fn, null, err, msg);
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Invokes the listener once, right before the response writes its headers
|
|
4
|
+
* (via `res.writeHead`, which is also called implicitly by the first `write`/`end`).
|
|
5
|
+
*
|
|
6
|
+
* Headers passed to `writeHead` are applied to `res` before the listener runs,
|
|
7
|
+
* so the listener sees (and can modify) the final headers and statusCode.
|
|
8
|
+
*
|
|
9
|
+
* Vendored (and modernized) from the `on-headers` package.
|
|
10
|
+
*/
|
|
11
|
+
export declare function onHeaders(res: ServerResponse, listener: (this: ServerResponse) => void): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invokes the listener once, right before the response writes its headers
|
|
3
|
+
* (via `res.writeHead`, which is also called implicitly by the first `write`/`end`).
|
|
4
|
+
*
|
|
5
|
+
* Headers passed to `writeHead` are applied to `res` before the listener runs,
|
|
6
|
+
* so the listener sees (and can modify) the final headers and statusCode.
|
|
7
|
+
*
|
|
8
|
+
* Vendored (and modernized) from the `on-headers` package.
|
|
9
|
+
*/
|
|
10
|
+
export function onHeaders(res, listener) {
|
|
11
|
+
// Loosely typed, to call it with the (statusCode, reason?) signature, bypassing overloads
|
|
12
|
+
const prevWriteHead = res.writeHead;
|
|
13
|
+
let fired = false;
|
|
14
|
+
res.writeHead = function writeHead(statusCode, reasonOrHeaders, headers) {
|
|
15
|
+
if (fired)
|
|
16
|
+
return prevWriteHead.apply(this, arguments);
|
|
17
|
+
fired = true;
|
|
18
|
+
const reason = typeof reasonOrHeaders === 'string' ? reasonOrHeaders : undefined;
|
|
19
|
+
headers = reason === undefined ? reasonOrHeaders : headers;
|
|
20
|
+
this.statusCode = statusCode;
|
|
21
|
+
if (headers)
|
|
22
|
+
setHeaders(this, headers);
|
|
23
|
+
listener.call(this);
|
|
24
|
+
// Reason phrase is dropped if the listener changed the statusCode
|
|
25
|
+
return reason !== undefined && this.statusCode === statusCode
|
|
26
|
+
? prevWriteHead.call(this, statusCode, reason)
|
|
27
|
+
: prevWriteHead.call(this, this.statusCode);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function setHeaders(res, headers) {
|
|
31
|
+
if (!Array.isArray(headers)) {
|
|
32
|
+
for (const k of Object.keys(headers)) {
|
|
33
|
+
if (k)
|
|
34
|
+
res.setHeader(k, headers[k]);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(headers[0])) {
|
|
39
|
+
// 2D: [[k, v], [k, v]]
|
|
40
|
+
for (const [k, v] of headers) {
|
|
41
|
+
if (k)
|
|
42
|
+
res.setHeader(k, v);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// 1D (raw): [k, v, k, v], keys may repeat
|
|
47
|
+
if (headers.length % 2)
|
|
48
|
+
throw new TypeError('headers array is malformed');
|
|
49
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
50
|
+
res.removeHeader(headers[i]);
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
53
|
+
if (headers[i])
|
|
54
|
+
res.appendHeader(headers[i], headers[i + 1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -10,6 +10,18 @@ export interface CompressionOptions {
|
|
|
10
10
|
* @default 1024
|
|
11
11
|
*/
|
|
12
12
|
threshold?: number | string;
|
|
13
|
+
/**
|
|
14
|
+
* Bodies sent in one `res.end(chunk)` call (which is what `res.json` and `res.send` do) up to this
|
|
15
|
+
* size are compressed synchronously on the event loop, instead of through a zlib stream.
|
|
16
|
+
* For typical JSON responses the per-response stream setup and threadpool round-trip cost more
|
|
17
|
+
* than the compression itself, and the sync path also keeps an exact `Content-Length`
|
|
18
|
+
* (streamed responses are chunked).
|
|
19
|
+
* Bodies above this size, and bodies written in several `res.write` calls, are streamed.
|
|
20
|
+
* Set to `0` to always stream.
|
|
21
|
+
*
|
|
22
|
+
* @default 128kb
|
|
23
|
+
*/
|
|
24
|
+
syncMaxBytes?: number | string;
|
|
13
25
|
/**
|
|
14
26
|
* Encoding to use when Accept-Encoding header is not present.
|
|
15
27
|
* @default 'identity'
|
|
@@ -39,7 +51,8 @@ export interface CompressionOptions {
|
|
|
39
51
|
/**
|
|
40
52
|
* Compression middleware with support for gzip, deflate, brotli, and zstd.
|
|
41
53
|
*
|
|
42
|
-
* This is a fork of the `compression` npm package with added zstd support
|
|
54
|
+
* This is a fork of the `compression` npm package with added zstd support,
|
|
55
|
+
* and a synchronous fast path for single-chunk bodies (see `syncMaxBytes`).
|
|
43
56
|
*
|
|
44
57
|
* Encoding preference order: zstd > br > gzip > deflate
|
|
45
58
|
*/
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* MIT Licensed
|
|
11
11
|
*/
|
|
12
12
|
import zlib from 'node:zlib';
|
|
13
|
-
import compressible from 'compressible';
|
|
14
13
|
// @ts-expect-error no types
|
|
15
14
|
import Negotiator from 'negotiator';
|
|
16
|
-
import
|
|
17
|
-
import
|
|
15
|
+
import { compressible } from '../compressible.js';
|
|
16
|
+
import { onHeaders } from '../onHeaders.js';
|
|
17
|
+
import { vary } from '../vary.js';
|
|
18
18
|
const SUPPORTED_ENCODING = ['zstd', 'br', 'gzip', 'deflate', 'identity'];
|
|
19
19
|
const PREFERRED_ENCODING = ['zstd', 'br', 'gzip'];
|
|
20
20
|
const encodingSupported = new Set(['gzip', 'deflate', 'identity', 'br', 'zstd']);
|
|
@@ -22,7 +22,8 @@ const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i;
|
|
|
22
22
|
/**
|
|
23
23
|
* Compression middleware with support for gzip, deflate, brotli, and zstd.
|
|
24
24
|
*
|
|
25
|
-
* This is a fork of the `compression` npm package with added zstd support
|
|
25
|
+
* This is a fork of the `compression` npm package with added zstd support,
|
|
26
|
+
* and a synchronous fast path for single-chunk bodies (see `syncMaxBytes`).
|
|
26
27
|
*
|
|
27
28
|
* Encoding preference order: zstd > br > gzip > deflate
|
|
28
29
|
*/
|
|
@@ -37,10 +38,14 @@ export function compressionMiddleware(options) {
|
|
|
37
38
|
},
|
|
38
39
|
};
|
|
39
40
|
// Zstd options
|
|
41
|
+
// Level 1 instead of zstd's own default of 3: for typical JSON responses the CPU cost and
|
|
42
|
+
// compression ratio are the same (measured 2026-10 on 22 KB and 115 KB payloads), but a level 3
|
|
43
|
+
// stream holds ~850 KB of native memory per in-flight response vs ~150 KB at level 1.
|
|
44
|
+
// That per-response allocation churn is what MALLOC_ARENA_MAX=2 works around on Cloud Run.
|
|
40
45
|
const optsZstd = {
|
|
41
46
|
...opts.zstd,
|
|
42
47
|
params: {
|
|
43
|
-
[zlib.constants.ZSTD_c_compressionLevel]:
|
|
48
|
+
[zlib.constants.ZSTD_c_compressionLevel]: 1,
|
|
44
49
|
...opts.zstd?.params,
|
|
45
50
|
},
|
|
46
51
|
};
|
|
@@ -56,11 +61,17 @@ export function compressionMiddleware(options) {
|
|
|
56
61
|
zlibOpts.windowBits = opts.windowBits;
|
|
57
62
|
if (opts.chunkSize !== undefined)
|
|
58
63
|
zlibOpts.chunkSize = opts.chunkSize;
|
|
64
|
+
const codecOpts = { zstd: optsZstd, brotli: optsBrotli, zlib: zlibOpts };
|
|
59
65
|
const filter = opts.filter || shouldCompress;
|
|
60
66
|
const threshold = parseBytes(opts.threshold) ?? 1024;
|
|
67
|
+
const syncMaxBytes = parseBytes(opts.syncMaxBytes) ?? 128 * 1024;
|
|
61
68
|
const enforceEncoding = opts.enforceEncoding || 'identity';
|
|
62
69
|
return function compression(req, res, next) {
|
|
63
70
|
let ended = false;
|
|
71
|
+
// Set by the first `res.write`: the body comes in several chunks, so only the stream path applies
|
|
72
|
+
let wrote = false;
|
|
73
|
+
// Set once the compress-or-not decision has been made for this response (by either path)
|
|
74
|
+
let decided = false;
|
|
64
75
|
let length;
|
|
65
76
|
let listeners = [];
|
|
66
77
|
let stream;
|
|
@@ -78,6 +89,7 @@ export function compressionMiddleware(options) {
|
|
|
78
89
|
if (ended) {
|
|
79
90
|
return false;
|
|
80
91
|
}
|
|
92
|
+
wrote = true;
|
|
81
93
|
if (!headersSent(res)) {
|
|
82
94
|
res.writeHead(res.statusCode);
|
|
83
95
|
}
|
|
@@ -96,6 +108,26 @@ export function compressionMiddleware(options) {
|
|
|
96
108
|
if (!res.getHeader('Content-Length')) {
|
|
97
109
|
length = chunkLength(chunk, encodingOrCallback);
|
|
98
110
|
}
|
|
111
|
+
// Single-chunk body (no prior `res.write`): the whole body is right here, so compress it
|
|
112
|
+
// synchronously instead of setting up a zlib stream for one chunk.
|
|
113
|
+
if (chunk && !wrote && !decided) {
|
|
114
|
+
const encoding = typeof encodingOrCallback === 'function' ? undefined : encodingOrCallback;
|
|
115
|
+
const body = toBuffer(chunk, encoding);
|
|
116
|
+
if (body.length <= syncMaxBytes) {
|
|
117
|
+
const method = decide(body.length);
|
|
118
|
+
if (method) {
|
|
119
|
+
const compressed = compressSync(method, body, codecOpts);
|
|
120
|
+
res.setHeader('Content-Encoding', method);
|
|
121
|
+
res.setHeader('Content-Length', compressed.length);
|
|
122
|
+
ended = true;
|
|
123
|
+
const cb = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
|
|
124
|
+
// encoding is irrelevant for a Buffer chunk; it is passed to satisfy the `end` overload
|
|
125
|
+
return _end.call(res, compressed, 'utf8', cb);
|
|
126
|
+
}
|
|
127
|
+
// Not compressing: falls through to the plain `end`.
|
|
128
|
+
// The `onHeaders` listener below sees `decided` and does nothing.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
99
131
|
res.writeHead(res.statusCode);
|
|
100
132
|
}
|
|
101
133
|
if (!stream) {
|
|
@@ -127,46 +159,36 @@ export function compressionMiddleware(options) {
|
|
|
127
159
|
addListeners(res, _on, listeners);
|
|
128
160
|
listeners = null;
|
|
129
161
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
_on.call(res, 'close', function onResponseClose() {
|
|
138
|
-
closed = true;
|
|
139
|
-
stream?.destroy();
|
|
140
|
-
});
|
|
141
|
-
onHeaders(res, function onResponseHeaders() {
|
|
162
|
+
/**
|
|
163
|
+
* The compress-or-not decision, shared by the sync path (`res.end` holding the whole body)
|
|
164
|
+
* and the stream path (`onHeaders`).
|
|
165
|
+
* Returns the encoding to use, or undefined when the response is to be sent as-is.
|
|
166
|
+
*/
|
|
167
|
+
function decide(bodyLength) {
|
|
168
|
+
decided = true;
|
|
142
169
|
// determine if request is filtered
|
|
143
170
|
if (!filter(req, res)) {
|
|
144
|
-
nocompress('filtered');
|
|
145
|
-
return;
|
|
171
|
+
return nocompress('filtered');
|
|
146
172
|
}
|
|
147
173
|
// determine if the entity should be transformed
|
|
148
174
|
if (!shouldTransform(req, res)) {
|
|
149
|
-
nocompress('no transform');
|
|
150
|
-
return;
|
|
175
|
+
return nocompress('no transform');
|
|
151
176
|
}
|
|
152
177
|
// vary
|
|
153
178
|
vary(res, 'Accept-Encoding');
|
|
154
179
|
// content-length below threshold
|
|
155
180
|
if (Number(res.getHeader('Content-Length')) < threshold ||
|
|
156
|
-
(
|
|
157
|
-
nocompress('size below threshold');
|
|
158
|
-
return;
|
|
181
|
+
(bodyLength !== undefined && bodyLength < threshold)) {
|
|
182
|
+
return nocompress('size below threshold');
|
|
159
183
|
}
|
|
160
184
|
const encoding = res.getHeader('Content-Encoding') || 'identity';
|
|
161
185
|
// already encoded
|
|
162
186
|
if (encoding !== 'identity') {
|
|
163
|
-
nocompress('already encoded');
|
|
164
|
-
return;
|
|
187
|
+
return nocompress('already encoded');
|
|
165
188
|
}
|
|
166
189
|
// head
|
|
167
190
|
if (req.method === 'HEAD') {
|
|
168
|
-
nocompress('HEAD request');
|
|
169
|
-
return;
|
|
191
|
+
return nocompress('HEAD request');
|
|
170
192
|
}
|
|
171
193
|
// compression method
|
|
172
194
|
// Get all client-accepted encodings, then pick the first one from our preferred order
|
|
@@ -180,9 +202,28 @@ export function compressionMiddleware(options) {
|
|
|
180
202
|
}
|
|
181
203
|
// negotiation failed
|
|
182
204
|
if (!method || method === 'identity') {
|
|
183
|
-
nocompress('not acceptable');
|
|
184
|
-
return;
|
|
205
|
+
return nocompress('not acceptable');
|
|
185
206
|
}
|
|
207
|
+
return method;
|
|
208
|
+
}
|
|
209
|
+
// Release the compression stream when the response closes, freeing native
|
|
210
|
+
// zlib memory even if the client disconnected before the response finished.
|
|
211
|
+
// Registered before onHeaders so a close that happens before the stream
|
|
212
|
+
// exists is not missed (the `closed` flag below handles a stream created
|
|
213
|
+
// afterwards). Fixes CVE-2026-87776 / GHSA-vc2v-76pw-4v95.
|
|
214
|
+
// We use `stream.destroy()` (not the `destroy` npm package upstream added)
|
|
215
|
+
// because our Node target frees the zlib handle on destroy().
|
|
216
|
+
_on.call(res, 'close', function onResponseClose() {
|
|
217
|
+
closed = true;
|
|
218
|
+
stream?.destroy();
|
|
219
|
+
});
|
|
220
|
+
onHeaders(res, function onResponseHeaders() {
|
|
221
|
+
// The sync path has already handled this response (compressed it, or declined to)
|
|
222
|
+
if (decided)
|
|
223
|
+
return;
|
|
224
|
+
const method = decide(length);
|
|
225
|
+
if (!method)
|
|
226
|
+
return;
|
|
186
227
|
// compression stream
|
|
187
228
|
if (method === 'zstd') {
|
|
188
229
|
stream = zlib.createZstdCompress(optsZstd);
|
|
@@ -236,6 +277,19 @@ export function shouldCompress(_req, res) {
|
|
|
236
277
|
}
|
|
237
278
|
return true;
|
|
238
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Compresses a whole body in one call, on the event loop.
|
|
282
|
+
* Same codec options as the streams, so the output is identical to the streamed one.
|
|
283
|
+
*/
|
|
284
|
+
function compressSync(method, body, opts) {
|
|
285
|
+
if (method === 'zstd')
|
|
286
|
+
return zlib.zstdCompressSync(body, opts.zstd);
|
|
287
|
+
if (method === 'br')
|
|
288
|
+
return zlib.brotliCompressSync(body, opts.brotli);
|
|
289
|
+
if (method === 'gzip')
|
|
290
|
+
return zlib.gzipSync(body, opts.zlib);
|
|
291
|
+
return zlib.deflateSync(body, opts.zlib);
|
|
292
|
+
}
|
|
239
293
|
function addListeners(stream, on, listeners) {
|
|
240
294
|
for (const [type, listener] of listeners) {
|
|
241
295
|
on.call(stream, type, listener);
|
package/dist/vary.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Adds the field (header name) to the response's Vary header, unless it's already there
|
|
4
|
+
* (case-insensitive). Vary: * stays as is, and adding '*' replaces the whole header with '*'.
|
|
5
|
+
*
|
|
6
|
+
* Vendored (and modernized) from the `vary` package.
|
|
7
|
+
*/
|
|
8
|
+
export declare function vary(res: ServerResponse, field: string): void;
|
package/dist/vary.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the field (header name) to the response's Vary header, unless it's already there
|
|
3
|
+
* (case-insensitive). Vary: * stays as is, and adding '*' replaces the whole header with '*'.
|
|
4
|
+
*
|
|
5
|
+
* Vendored (and modernized) from the `vary` package.
|
|
6
|
+
*/
|
|
7
|
+
export function vary(res, field) {
|
|
8
|
+
const header = res.getHeader('Vary');
|
|
9
|
+
const value = Array.isArray(header) ? header.join(', ') : String(header ?? '');
|
|
10
|
+
if (!value) {
|
|
11
|
+
res.setHeader('Vary', field);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const fields = new Set(value
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(',')
|
|
17
|
+
.map(f => f.trim()));
|
|
18
|
+
if (field === '*' || fields.has('*')) {
|
|
19
|
+
if (value !== '*')
|
|
20
|
+
res.setHeader('Vary', '*');
|
|
21
|
+
}
|
|
22
|
+
else if (!fields.has(field.toLowerCase())) {
|
|
23
|
+
res.setHeader('Vary', `${value}, ${field}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/backend-lib",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.79.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@naturalcycles/db-lib": "^10",
|
|
7
7
|
"@naturalcycles/js-lib": "^15",
|
|
@@ -10,21 +10,13 @@
|
|
|
10
10
|
"@types/cookie-parser": "^1",
|
|
11
11
|
"@types/cors": "^2",
|
|
12
12
|
"@types/express": "^5",
|
|
13
|
-
"@types/compressible": "^2",
|
|
14
|
-
"@types/on-finished": "^2",
|
|
15
|
-
"@types/on-headers": "^1",
|
|
16
|
-
"@types/vary": "^1",
|
|
17
13
|
"busboy": "^1",
|
|
18
|
-
"compressible": "^2",
|
|
19
14
|
"cookie-parser": "^1",
|
|
20
15
|
"cors": "^2",
|
|
21
16
|
"express": "^5",
|
|
22
17
|
"helmet": "^8",
|
|
23
18
|
"negotiator": "^1",
|
|
24
|
-
"
|
|
25
|
-
"on-finished": "^2",
|
|
26
|
-
"tslib": "^2",
|
|
27
|
-
"vary": "^1"
|
|
19
|
+
"tslib": "^2"
|
|
28
20
|
},
|
|
29
21
|
"peerDependencies": {
|
|
30
22
|
"@sentry/node-core": "^10",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the given Content-Type (parameters, e.g `; charset=utf-8`, are ignored) is compressible.
|
|
3
|
+
*
|
|
4
|
+
* Vendored (and modernized) from the `compressible` package.
|
|
5
|
+
* Instead of loading the whole `mime-db` (~2500 types), it's reduced to the equivalent:
|
|
6
|
+
* a regex + the list of compressible types that the regex doesn't match (as of mime-db@1.54.0).
|
|
7
|
+
* No type matched by the regex is marked as non-compressible in mime-db.
|
|
8
|
+
*/
|
|
9
|
+
export function compressible(type: string): boolean {
|
|
10
|
+
const mime = EXTRACT_TYPE_REGEX.exec(type)![1]!.toLowerCase()
|
|
11
|
+
return COMPRESSIBLE_TYPE_REGEX.test(mime) || COMPRESSIBLE_TYPES.has(mime)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EXTRACT_TYPE_REGEX = /^\s*([^;\s]*)/
|
|
15
|
+
const COMPRESSIBLE_TYPE_REGEX = /^text\/|\+(?:json|text|xml)$/
|
|
16
|
+
|
|
17
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
18
|
+
'application/dart',
|
|
19
|
+
'application/ecmascript',
|
|
20
|
+
'application/javascript',
|
|
21
|
+
'application/json',
|
|
22
|
+
'application/octet-stream',
|
|
23
|
+
'application/postscript',
|
|
24
|
+
'application/raml+yaml',
|
|
25
|
+
'application/rtf',
|
|
26
|
+
'application/tar',
|
|
27
|
+
'application/toml',
|
|
28
|
+
'application/vnd.dart',
|
|
29
|
+
'application/vnd.ms-fontobject',
|
|
30
|
+
'application/vnd.ms-opentype',
|
|
31
|
+
'application/wasm',
|
|
32
|
+
'application/x-httpd-php',
|
|
33
|
+
'application/x-javascript',
|
|
34
|
+
'application/x-ns-proxy-autoconfig',
|
|
35
|
+
'application/x-sh',
|
|
36
|
+
'application/x-tar',
|
|
37
|
+
'application/x-virtualbox-hdd',
|
|
38
|
+
'application/x-virtualbox-ova',
|
|
39
|
+
'application/x-virtualbox-ovf',
|
|
40
|
+
'application/x-virtualbox-vbox',
|
|
41
|
+
'application/x-virtualbox-vdi',
|
|
42
|
+
'application/x-virtualbox-vhd',
|
|
43
|
+
'application/x-virtualbox-vmdk',
|
|
44
|
+
'application/x-www-form-urlencoded',
|
|
45
|
+
'application/xml',
|
|
46
|
+
'application/xml-dtd',
|
|
47
|
+
'font/otf',
|
|
48
|
+
'font/ttf',
|
|
49
|
+
'image/bmp',
|
|
50
|
+
'image/vnd.adobe.photoshop',
|
|
51
|
+
'image/vnd.microsoft.icon',
|
|
52
|
+
'image/vnd.ms-dds',
|
|
53
|
+
'image/x-icon',
|
|
54
|
+
'image/x-ms-bmp',
|
|
55
|
+
'message/rfc822',
|
|
56
|
+
'model/gltf-binary',
|
|
57
|
+
'x-shader/x-fragment',
|
|
58
|
+
'x-shader/x-vertex',
|
|
59
|
+
])
|
package/src/onFinished.ts
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import { finished } from 'node:stream'
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Invokes the listener once the request/response has finished (completed, closed or errored).
|
|
7
|
+
* If it has already finished - the listener is invoked asynchronously (on the next tick).
|
|
8
|
+
*
|
|
9
|
+
* Listener runs in the async context (e.g AsyncLocalStorage) of the onFinished caller.
|
|
10
|
+
*
|
|
11
|
+
* Vendored (and modernized) from the `on-finished` package, built on top of `stream.finished`.
|
|
12
|
+
* Stream listeners are attached once per msg (subsequent calls are queued), as `stream.finished`
|
|
13
|
+
* is too expensive to call per listener (and would exceed the default MaxListeners).
|
|
14
|
+
*/
|
|
15
|
+
export function onFinished<T extends IncomingMessage | ServerResponse>(
|
|
16
|
+
msg: T,
|
|
17
|
+
listener: Listener<T>,
|
|
18
|
+
): T {
|
|
19
|
+
const m = msg as T & { [kQueue]?: Listener<T>[] }
|
|
20
|
+
const queue = m[kQueue]
|
|
21
|
+
if (queue) {
|
|
22
|
+
queue.push(bindAsyncContext(listener))
|
|
23
|
+
return msg
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const q = (m[kQueue] = [bindAsyncContext(listener)])
|
|
27
|
+
finished(msg, err => {
|
|
28
|
+
m[kQueue] = undefined
|
|
29
|
+
for (const fn of q) fn(err ?? null, msg)
|
|
30
|
+
})
|
|
31
|
+
return msg
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Listener<T> = (err: Error | null, msg: T) => void
|
|
35
|
+
|
|
36
|
+
const kQueue = Symbol('onFinished')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Faster alternative to `AsyncResource.bind` (which is ~0.8µs per call).
|
|
40
|
+
*/
|
|
41
|
+
function bindAsyncContext<T>(fn: Listener<T>): Listener<T> {
|
|
42
|
+
const resource = new AsyncResource('onFinished')
|
|
43
|
+
return (err, msg) => resource.runInAsyncScope(fn, null, err, msg)
|
|
44
|
+
}
|
package/src/onHeaders.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Invokes the listener once, right before the response writes its headers
|
|
5
|
+
* (via `res.writeHead`, which is also called implicitly by the first `write`/`end`).
|
|
6
|
+
*
|
|
7
|
+
* Headers passed to `writeHead` are applied to `res` before the listener runs,
|
|
8
|
+
* so the listener sees (and can modify) the final headers and statusCode.
|
|
9
|
+
*
|
|
10
|
+
* Vendored (and modernized) from the `on-headers` package.
|
|
11
|
+
*/
|
|
12
|
+
export function onHeaders(res: ServerResponse, listener: (this: ServerResponse) => void): void {
|
|
13
|
+
// Loosely typed, to call it with the (statusCode, reason?) signature, bypassing overloads
|
|
14
|
+
const prevWriteHead = res.writeHead as (statusCode: number, reason?: string) => ServerResponse
|
|
15
|
+
let fired = false
|
|
16
|
+
|
|
17
|
+
res.writeHead = function writeHead(
|
|
18
|
+
this: ServerResponse,
|
|
19
|
+
statusCode: number,
|
|
20
|
+
reasonOrHeaders?: string | Headers,
|
|
21
|
+
headers?: Headers,
|
|
22
|
+
) {
|
|
23
|
+
if (fired) return prevWriteHead.apply(this, arguments as any)
|
|
24
|
+
fired = true
|
|
25
|
+
|
|
26
|
+
const reason = typeof reasonOrHeaders === 'string' ? reasonOrHeaders : undefined
|
|
27
|
+
headers = reason === undefined ? (reasonOrHeaders as Headers | undefined) : headers
|
|
28
|
+
|
|
29
|
+
this.statusCode = statusCode
|
|
30
|
+
if (headers) setHeaders(this, headers)
|
|
31
|
+
listener.call(this)
|
|
32
|
+
|
|
33
|
+
// Reason phrase is dropped if the listener changed the statusCode
|
|
34
|
+
return reason !== undefined && this.statusCode === statusCode
|
|
35
|
+
? prevWriteHead.call(this, statusCode, reason)
|
|
36
|
+
: prevWriteHead.call(this, this.statusCode)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Headers = OutgoingHttpHeaders | OutgoingHttpHeader[]
|
|
41
|
+
|
|
42
|
+
function setHeaders(res: ServerResponse, headers: Headers): void {
|
|
43
|
+
if (!Array.isArray(headers)) {
|
|
44
|
+
for (const k of Object.keys(headers)) {
|
|
45
|
+
if (k) res.setHeader(k, headers[k]!)
|
|
46
|
+
}
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(headers[0])) {
|
|
51
|
+
// 2D: [[k, v], [k, v]]
|
|
52
|
+
for (const [k, v] of headers as unknown as [string, OutgoingHttpHeader][]) {
|
|
53
|
+
if (k) res.setHeader(k, v)
|
|
54
|
+
}
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1D (raw): [k, v, k, v], keys may repeat
|
|
59
|
+
if (headers.length % 2) throw new TypeError('headers array is malformed')
|
|
60
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
61
|
+
res.removeHeader(headers[i] as string)
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
64
|
+
if (headers[i]) res.appendHeader(headers[i] as string, headers[i + 1] as string | string[])
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import zlib from 'node:zlib'
|
|
14
|
-
import compressible from 'compressible'
|
|
15
14
|
// @ts-expect-error no types
|
|
16
15
|
import Negotiator from 'negotiator'
|
|
17
|
-
import
|
|
18
|
-
import
|
|
16
|
+
import { compressible } from '../compressible.js'
|
|
17
|
+
import { onHeaders } from '../onHeaders.js'
|
|
18
|
+
import { vary } from '../vary.js'
|
|
19
19
|
import type { BackendRequest, BackendRequestHandler, BackendResponse } from './server.model.js'
|
|
20
20
|
|
|
21
21
|
export interface CompressionOptions {
|
|
@@ -31,6 +31,19 @@ export interface CompressionOptions {
|
|
|
31
31
|
*/
|
|
32
32
|
threshold?: number | string
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Bodies sent in one `res.end(chunk)` call (which is what `res.json` and `res.send` do) up to this
|
|
36
|
+
* size are compressed synchronously on the event loop, instead of through a zlib stream.
|
|
37
|
+
* For typical JSON responses the per-response stream setup and threadpool round-trip cost more
|
|
38
|
+
* than the compression itself, and the sync path also keeps an exact `Content-Length`
|
|
39
|
+
* (streamed responses are chunked).
|
|
40
|
+
* Bodies above this size, and bodies written in several `res.write` calls, are streamed.
|
|
41
|
+
* Set to `0` to always stream.
|
|
42
|
+
*
|
|
43
|
+
* @default 128kb
|
|
44
|
+
*/
|
|
45
|
+
syncMaxBytes?: number | string
|
|
46
|
+
|
|
34
47
|
/**
|
|
35
48
|
* Encoding to use when Accept-Encoding header is not present.
|
|
36
49
|
* @default 'identity'
|
|
@@ -71,7 +84,8 @@ const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i
|
|
|
71
84
|
/**
|
|
72
85
|
* Compression middleware with support for gzip, deflate, brotli, and zstd.
|
|
73
86
|
*
|
|
74
|
-
* This is a fork of the `compression` npm package with added zstd support
|
|
87
|
+
* This is a fork of the `compression` npm package with added zstd support,
|
|
88
|
+
* and a synchronous fast path for single-chunk bodies (see `syncMaxBytes`).
|
|
75
89
|
*
|
|
76
90
|
* Encoding preference order: zstd > br > gzip > deflate
|
|
77
91
|
*/
|
|
@@ -88,10 +102,14 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
// Zstd options
|
|
105
|
+
// Level 1 instead of zstd's own default of 3: for typical JSON responses the CPU cost and
|
|
106
|
+
// compression ratio are the same (measured 2026-10 on 22 KB and 115 KB payloads), but a level 3
|
|
107
|
+
// stream holds ~850 KB of native memory per in-flight response vs ~150 KB at level 1.
|
|
108
|
+
// That per-response allocation churn is what MALLOC_ARENA_MAX=2 works around on Cloud Run.
|
|
91
109
|
const optsZstd: zlib.ZstdOptions = {
|
|
92
110
|
...opts.zstd,
|
|
93
111
|
params: {
|
|
94
|
-
[zlib.constants.ZSTD_c_compressionLevel]:
|
|
112
|
+
[zlib.constants.ZSTD_c_compressionLevel]: 1,
|
|
95
113
|
...opts.zstd?.params,
|
|
96
114
|
},
|
|
97
115
|
}
|
|
@@ -104,12 +122,19 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
104
122
|
if (opts.windowBits !== undefined) zlibOpts.windowBits = opts.windowBits
|
|
105
123
|
if (opts.chunkSize !== undefined) zlibOpts.chunkSize = opts.chunkSize
|
|
106
124
|
|
|
125
|
+
const codecOpts: CodecOptions = { zstd: optsZstd, brotli: optsBrotli, zlib: zlibOpts }
|
|
126
|
+
|
|
107
127
|
const filter = opts.filter || shouldCompress
|
|
108
128
|
const threshold = parseBytes(opts.threshold) ?? 1024
|
|
129
|
+
const syncMaxBytes = parseBytes(opts.syncMaxBytes) ?? 128 * 1024
|
|
109
130
|
const enforceEncoding = opts.enforceEncoding || 'identity'
|
|
110
131
|
|
|
111
132
|
return function compression(req, res, next) {
|
|
112
133
|
let ended = false
|
|
134
|
+
// Set by the first `res.write`: the body comes in several chunks, so only the stream path applies
|
|
135
|
+
let wrote = false
|
|
136
|
+
// Set once the compress-or-not decision has been made for this response (by either path)
|
|
137
|
+
let decided = false
|
|
113
138
|
let length: number | undefined
|
|
114
139
|
let listeners: [string, (...args: any[]) => void][] | null = []
|
|
115
140
|
let stream: zlib.Gzip | zlib.Deflate | zlib.BrotliCompress | zlib.ZstdCompress | undefined
|
|
@@ -136,6 +161,8 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
136
161
|
return false
|
|
137
162
|
}
|
|
138
163
|
|
|
164
|
+
wrote = true
|
|
165
|
+
|
|
139
166
|
if (!headersSent(res)) {
|
|
140
167
|
res.writeHead(res.statusCode)
|
|
141
168
|
}
|
|
@@ -161,6 +188,30 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
161
188
|
if (!res.getHeader('Content-Length')) {
|
|
162
189
|
length = chunkLength(chunk, encodingOrCallback as BufferEncoding)
|
|
163
190
|
}
|
|
191
|
+
|
|
192
|
+
// Single-chunk body (no prior `res.write`): the whole body is right here, so compress it
|
|
193
|
+
// synchronously instead of setting up a zlib stream for one chunk.
|
|
194
|
+
if (chunk && !wrote && !decided) {
|
|
195
|
+
const encoding = typeof encodingOrCallback === 'function' ? undefined : encodingOrCallback
|
|
196
|
+
const body = toBuffer(chunk, encoding)
|
|
197
|
+
|
|
198
|
+
if (body.length <= syncMaxBytes) {
|
|
199
|
+
const method = decide(body.length)
|
|
200
|
+
|
|
201
|
+
if (method) {
|
|
202
|
+
const compressed = compressSync(method, body, codecOpts)
|
|
203
|
+
res.setHeader('Content-Encoding', method)
|
|
204
|
+
res.setHeader('Content-Length', compressed.length)
|
|
205
|
+
ended = true
|
|
206
|
+
const cb = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback
|
|
207
|
+
// encoding is irrelevant for a Buffer chunk; it is passed to satisfy the `end` overload
|
|
208
|
+
return _end.call(res, compressed, 'utf8', cb)
|
|
209
|
+
}
|
|
210
|
+
// Not compressing: falls through to the plain `end`.
|
|
211
|
+
// The `onHeaders` listener below sees `decided` and does nothing.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
164
215
|
res.writeHead(res.statusCode)
|
|
165
216
|
}
|
|
166
217
|
|
|
@@ -195,34 +246,27 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
195
246
|
return res
|
|
196
247
|
}
|
|
197
248
|
|
|
198
|
-
function nocompress(_msg: string):
|
|
249
|
+
function nocompress(_msg: string): undefined {
|
|
199
250
|
addListeners(res, _on, listeners!)
|
|
200
251
|
listeners = null
|
|
201
252
|
}
|
|
202
253
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
_on.call(res, 'close', function onResponseClose() {
|
|
211
|
-
closed = true
|
|
212
|
-
stream?.destroy()
|
|
213
|
-
})
|
|
254
|
+
/**
|
|
255
|
+
* The compress-or-not decision, shared by the sync path (`res.end` holding the whole body)
|
|
256
|
+
* and the stream path (`onHeaders`).
|
|
257
|
+
* Returns the encoding to use, or undefined when the response is to be sent as-is.
|
|
258
|
+
*/
|
|
259
|
+
function decide(bodyLength: number | undefined): string | undefined {
|
|
260
|
+
decided = true
|
|
214
261
|
|
|
215
|
-
onHeaders(res, function onResponseHeaders() {
|
|
216
262
|
// determine if request is filtered
|
|
217
263
|
if (!filter(req, res)) {
|
|
218
|
-
nocompress('filtered')
|
|
219
|
-
return
|
|
264
|
+
return nocompress('filtered')
|
|
220
265
|
}
|
|
221
266
|
|
|
222
267
|
// determine if the entity should be transformed
|
|
223
268
|
if (!shouldTransform(req, res)) {
|
|
224
|
-
nocompress('no transform')
|
|
225
|
-
return
|
|
269
|
+
return nocompress('no transform')
|
|
226
270
|
}
|
|
227
271
|
|
|
228
272
|
// vary
|
|
@@ -231,24 +275,21 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
231
275
|
// content-length below threshold
|
|
232
276
|
if (
|
|
233
277
|
Number(res.getHeader('Content-Length')) < threshold ||
|
|
234
|
-
(
|
|
278
|
+
(bodyLength !== undefined && bodyLength < threshold)
|
|
235
279
|
) {
|
|
236
|
-
nocompress('size below threshold')
|
|
237
|
-
return
|
|
280
|
+
return nocompress('size below threshold')
|
|
238
281
|
}
|
|
239
282
|
|
|
240
283
|
const encoding = (res.getHeader('Content-Encoding') as string) || 'identity'
|
|
241
284
|
|
|
242
285
|
// already encoded
|
|
243
286
|
if (encoding !== 'identity') {
|
|
244
|
-
nocompress('already encoded')
|
|
245
|
-
return
|
|
287
|
+
return nocompress('already encoded')
|
|
246
288
|
}
|
|
247
289
|
|
|
248
290
|
// head
|
|
249
291
|
if (req.method === 'HEAD') {
|
|
250
|
-
nocompress('HEAD request')
|
|
251
|
-
return
|
|
292
|
+
return nocompress('HEAD request')
|
|
252
293
|
}
|
|
253
294
|
|
|
254
295
|
// compression method
|
|
@@ -266,10 +307,31 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
|
|
|
266
307
|
|
|
267
308
|
// negotiation failed
|
|
268
309
|
if (!method || method === 'identity') {
|
|
269
|
-
nocompress('not acceptable')
|
|
270
|
-
return
|
|
310
|
+
return nocompress('not acceptable')
|
|
271
311
|
}
|
|
272
312
|
|
|
313
|
+
return method
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Release the compression stream when the response closes, freeing native
|
|
317
|
+
// zlib memory even if the client disconnected before the response finished.
|
|
318
|
+
// Registered before onHeaders so a close that happens before the stream
|
|
319
|
+
// exists is not missed (the `closed` flag below handles a stream created
|
|
320
|
+
// afterwards). Fixes CVE-2026-87776 / GHSA-vc2v-76pw-4v95.
|
|
321
|
+
// We use `stream.destroy()` (not the `destroy` npm package upstream added)
|
|
322
|
+
// because our Node target frees the zlib handle on destroy().
|
|
323
|
+
_on.call(res, 'close', function onResponseClose() {
|
|
324
|
+
closed = true
|
|
325
|
+
stream?.destroy()
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
onHeaders(res, function onResponseHeaders() {
|
|
329
|
+
// The sync path has already handled this response (compressed it, or declined to)
|
|
330
|
+
if (decided) return
|
|
331
|
+
|
|
332
|
+
const method = decide(length)
|
|
333
|
+
if (!method) return
|
|
334
|
+
|
|
273
335
|
// compression stream
|
|
274
336
|
if (method === 'zstd') {
|
|
275
337
|
stream = zlib.createZstdCompress(optsZstd)
|
|
@@ -331,6 +393,23 @@ export function shouldCompress(_req: any, res: any): boolean {
|
|
|
331
393
|
return true
|
|
332
394
|
}
|
|
333
395
|
|
|
396
|
+
interface CodecOptions {
|
|
397
|
+
zstd: zlib.ZstdOptions
|
|
398
|
+
brotli: zlib.BrotliOptions
|
|
399
|
+
zlib: zlib.ZlibOptions
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Compresses a whole body in one call, on the event loop.
|
|
404
|
+
* Same codec options as the streams, so the output is identical to the streamed one.
|
|
405
|
+
*/
|
|
406
|
+
function compressSync(method: string, body: Buffer, opts: CodecOptions): Buffer {
|
|
407
|
+
if (method === 'zstd') return zlib.zstdCompressSync(body, opts.zstd)
|
|
408
|
+
if (method === 'br') return zlib.brotliCompressSync(body, opts.brotli)
|
|
409
|
+
if (method === 'gzip') return zlib.gzipSync(body, opts.zlib)
|
|
410
|
+
return zlib.deflateSync(body, opts.zlib)
|
|
411
|
+
}
|
|
412
|
+
|
|
334
413
|
function addListeners(
|
|
335
414
|
stream: any,
|
|
336
415
|
on: (type: string, listener: (...args: any[]) => void) => void,
|
package/src/vary.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adds the field (header name) to the response's Vary header, unless it's already there
|
|
5
|
+
* (case-insensitive). Vary: * stays as is, and adding '*' replaces the whole header with '*'.
|
|
6
|
+
*
|
|
7
|
+
* Vendored (and modernized) from the `vary` package.
|
|
8
|
+
*/
|
|
9
|
+
export function vary(res: ServerResponse, field: string): void {
|
|
10
|
+
const header = res.getHeader('Vary')
|
|
11
|
+
const value = Array.isArray(header) ? header.join(', ') : String(header ?? '')
|
|
12
|
+
if (!value) {
|
|
13
|
+
res.setHeader('Vary', field)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const fields = new Set(
|
|
18
|
+
value
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.split(',')
|
|
21
|
+
.map(f => f.trim()),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if (field === '*' || fields.has('*')) {
|
|
25
|
+
if (value !== '*') res.setHeader('Vary', '*')
|
|
26
|
+
} else if (!fields.has(field.toLowerCase())) {
|
|
27
|
+
res.setHeader('Vary', `${value}, ${field}`)
|
|
28
|
+
}
|
|
29
|
+
}
|