@naturalcycles/backend-lib 9.45.0 → 9.46.1
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/express/createDefaultApp.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/compressionMiddleware.d.ts +51 -0
- package/dist/server/compressionMiddleware.js +260 -0
- package/package.json +10 -5
- package/src/express/createDefaultApp.ts +2 -2
- package/src/index.ts +1 -0
- package/src/server/compressionMiddleware.ts +365 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asyncLocalStorageMiddleware } from '../server/asyncLocalStorageMiddleware.js';
|
|
2
|
+
import { compressionMiddleware } from '../server/compressionMiddleware.js';
|
|
2
3
|
import { genericErrorMiddleware, } from '../server/genericErrorMiddleware.js';
|
|
3
4
|
import { logMiddleware } from '../server/logMiddleware.js';
|
|
4
5
|
import { methodOverrideMiddleware } from '../server/methodOverrideMiddleware.js';
|
|
@@ -46,8 +47,7 @@ export async function createDefaultApp(cfg) {
|
|
|
46
47
|
}));
|
|
47
48
|
if (!isGAE() && isTest) {
|
|
48
49
|
// compression is not used in AppEngine, because AppEngine provides it by default
|
|
49
|
-
|
|
50
|
-
app.use(compression());
|
|
50
|
+
app.use(compressionMiddleware());
|
|
51
51
|
}
|
|
52
52
|
// app.use(safeJsonMiddleware()) // optional
|
|
53
53
|
// accepts application/json
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './sentry/sentry.shared.service.js';
|
|
|
3
3
|
export * from './server/asyncLocalStorageMiddleware.js';
|
|
4
4
|
export * from './server/basicAuthMiddleware.js';
|
|
5
5
|
export * from './server/bodyParserTimeoutMiddleware.js';
|
|
6
|
+
export * from './server/compressionMiddleware.js';
|
|
6
7
|
export * from './server/eventLoop.util.js';
|
|
7
8
|
export * from './server/genericErrorMiddleware.js';
|
|
8
9
|
export * from './server/logMiddleware.js';
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export * from './sentry/sentry.shared.service.js';
|
|
|
3
3
|
export * from './server/asyncLocalStorageMiddleware.js';
|
|
4
4
|
export * from './server/basicAuthMiddleware.js';
|
|
5
5
|
export * from './server/bodyParserTimeoutMiddleware.js';
|
|
6
|
+
export * from './server/compressionMiddleware.js';
|
|
6
7
|
export * from './server/eventLoop.util.js';
|
|
7
8
|
export * from './server/genericErrorMiddleware.js';
|
|
8
9
|
export * from './server/logMiddleware.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BackendRequest, BackendRequestHandler, BackendResponse } from './server.model.js';
|
|
2
|
+
export interface CompressionOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Custom filter function to determine if response should be compressed.
|
|
5
|
+
* Default checks if Content-Type is compressible.
|
|
6
|
+
*/
|
|
7
|
+
filter?: (req: BackendRequest, res: BackendResponse) => boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Minimum response size in bytes to compress.
|
|
10
|
+
* @default 1024
|
|
11
|
+
*/
|
|
12
|
+
threshold?: number | string;
|
|
13
|
+
/**
|
|
14
|
+
* Encoding to use when Accept-Encoding header is not present.
|
|
15
|
+
* @default 'identity'
|
|
16
|
+
*/
|
|
17
|
+
enforceEncoding?: 'gzip' | 'deflate' | 'br' | 'zstd' | 'identity';
|
|
18
|
+
/**
|
|
19
|
+
* zlib options for gzip/deflate.
|
|
20
|
+
*/
|
|
21
|
+
level?: number;
|
|
22
|
+
memLevel?: number;
|
|
23
|
+
strategy?: number;
|
|
24
|
+
windowBits?: number;
|
|
25
|
+
chunkSize?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Brotli-specific options.
|
|
28
|
+
*/
|
|
29
|
+
brotli?: {
|
|
30
|
+
params?: Record<number, number>;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Zstd-specific options.
|
|
34
|
+
*/
|
|
35
|
+
zstd?: {
|
|
36
|
+
params?: Record<number, number>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compression middleware with support for gzip, deflate, brotli, and zstd.
|
|
41
|
+
*
|
|
42
|
+
* This is a fork of the `compression` npm package with added zstd support.
|
|
43
|
+
*
|
|
44
|
+
* Encoding preference order: zstd > br > gzip > deflate
|
|
45
|
+
*/
|
|
46
|
+
export declare function compressionMiddleware(options?: CompressionOptions): BackendRequestHandler;
|
|
47
|
+
/**
|
|
48
|
+
* Default filter function.
|
|
49
|
+
* Returns true if the Content-Type is compressible.
|
|
50
|
+
*/
|
|
51
|
+
export declare function shouldCompress(_req: any, res: any): boolean;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/* !
|
|
2
|
+
* Compression middleware forked from `compression` npm package.
|
|
3
|
+
* With added zstd support based on https://github.com/expressjs/compression/pull/250
|
|
4
|
+
*
|
|
5
|
+
* Original copyright:
|
|
6
|
+
* Copyright(c) 2010 Sencha Inc.
|
|
7
|
+
* Copyright(c) 2011 TJ Holowaychuk
|
|
8
|
+
* Copyright(c) 2014 Jonathan Ong
|
|
9
|
+
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
10
|
+
* MIT Licensed
|
|
11
|
+
*/
|
|
12
|
+
import zlib from 'node:zlib';
|
|
13
|
+
import compressible from 'compressible';
|
|
14
|
+
// @ts-expect-error no types
|
|
15
|
+
import Negotiator from 'negotiator';
|
|
16
|
+
import onHeaders from 'on-headers';
|
|
17
|
+
import vary from 'vary';
|
|
18
|
+
const SUPPORTED_ENCODING = ['zstd', 'br', 'gzip', 'deflate', 'identity'];
|
|
19
|
+
const PREFERRED_ENCODING = ['zstd', 'br', 'gzip'];
|
|
20
|
+
const encodingSupported = new Set(['gzip', 'deflate', 'identity', 'br', 'zstd']);
|
|
21
|
+
const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i;
|
|
22
|
+
/**
|
|
23
|
+
* Compression middleware with support for gzip, deflate, brotli, and zstd.
|
|
24
|
+
*
|
|
25
|
+
* This is a fork of the `compression` npm package with added zstd support.
|
|
26
|
+
*
|
|
27
|
+
* Encoding preference order: zstd > br > gzip > deflate
|
|
28
|
+
*/
|
|
29
|
+
export function compressionMiddleware(options) {
|
|
30
|
+
const opts = options || {};
|
|
31
|
+
// Brotli options
|
|
32
|
+
const optsBrotli = {
|
|
33
|
+
...opts.brotli,
|
|
34
|
+
params: {
|
|
35
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
36
|
+
...opts.brotli?.params,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
// Zstd options
|
|
40
|
+
const optsZstd = {
|
|
41
|
+
...opts.zstd,
|
|
42
|
+
params: {
|
|
43
|
+
[zlib.constants.ZSTD_c_compressionLevel]: 3,
|
|
44
|
+
...opts.zstd?.params,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
// General zlib options for gzip/deflate
|
|
48
|
+
const zlibOpts = {};
|
|
49
|
+
if (opts.level !== undefined)
|
|
50
|
+
zlibOpts.level = opts.level;
|
|
51
|
+
if (opts.memLevel !== undefined)
|
|
52
|
+
zlibOpts.memLevel = opts.memLevel;
|
|
53
|
+
if (opts.strategy !== undefined)
|
|
54
|
+
zlibOpts.strategy = opts.strategy;
|
|
55
|
+
if (opts.windowBits !== undefined)
|
|
56
|
+
zlibOpts.windowBits = opts.windowBits;
|
|
57
|
+
if (opts.chunkSize !== undefined)
|
|
58
|
+
zlibOpts.chunkSize = opts.chunkSize;
|
|
59
|
+
const filter = opts.filter || shouldCompress;
|
|
60
|
+
const threshold = parseBytes(opts.threshold) ?? 1024;
|
|
61
|
+
const enforceEncoding = opts.enforceEncoding || 'identity';
|
|
62
|
+
return function compression(req, res, next) {
|
|
63
|
+
let ended = false;
|
|
64
|
+
let length;
|
|
65
|
+
let listeners = [];
|
|
66
|
+
let stream;
|
|
67
|
+
const _end = res.end;
|
|
68
|
+
const _on = res.on;
|
|
69
|
+
const _write = res.write;
|
|
70
|
+
res.flush = function flush() {
|
|
71
|
+
if (stream) {
|
|
72
|
+
stream.flush();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
// proxy write
|
|
76
|
+
res.write = function write(chunk, encodingOrCallback, callback) {
|
|
77
|
+
if (ended) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!headersSent(res)) {
|
|
81
|
+
res.writeHead(res.statusCode);
|
|
82
|
+
}
|
|
83
|
+
if (stream) {
|
|
84
|
+
return stream.write(toBuffer(chunk, encodingOrCallback));
|
|
85
|
+
}
|
|
86
|
+
return _write.call(res, chunk, encodingOrCallback, callback);
|
|
87
|
+
};
|
|
88
|
+
// proxy end
|
|
89
|
+
res.end = function end(chunk, encodingOrCallback, callback) {
|
|
90
|
+
if (ended) {
|
|
91
|
+
return res;
|
|
92
|
+
}
|
|
93
|
+
if (!headersSent(res)) {
|
|
94
|
+
// estimate the length
|
|
95
|
+
if (!res.getHeader('Content-Length')) {
|
|
96
|
+
length = chunkLength(chunk, encodingOrCallback);
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(res.statusCode);
|
|
99
|
+
}
|
|
100
|
+
if (!stream) {
|
|
101
|
+
return _end.call(res, chunk, encodingOrCallback, callback);
|
|
102
|
+
}
|
|
103
|
+
// mark ended
|
|
104
|
+
ended = true;
|
|
105
|
+
// write Buffer
|
|
106
|
+
if (chunk) {
|
|
107
|
+
stream.end(toBuffer(chunk, encodingOrCallback));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
stream.end();
|
|
111
|
+
}
|
|
112
|
+
return res;
|
|
113
|
+
};
|
|
114
|
+
res.on = function on(type, listener) {
|
|
115
|
+
if (!listeners || type !== 'drain') {
|
|
116
|
+
return _on.call(res, type, listener);
|
|
117
|
+
}
|
|
118
|
+
if (stream) {
|
|
119
|
+
return stream.on(type, listener);
|
|
120
|
+
}
|
|
121
|
+
// buffer listeners for future stream
|
|
122
|
+
listeners.push([type, listener]);
|
|
123
|
+
return res;
|
|
124
|
+
};
|
|
125
|
+
function nocompress(_msg) {
|
|
126
|
+
addListeners(res, _on, listeners);
|
|
127
|
+
listeners = null;
|
|
128
|
+
}
|
|
129
|
+
onHeaders(res, function onResponseHeaders() {
|
|
130
|
+
// determine if request is filtered
|
|
131
|
+
if (!filter(req, res)) {
|
|
132
|
+
nocompress('filtered');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// determine if the entity should be transformed
|
|
136
|
+
if (!shouldTransform(req, res)) {
|
|
137
|
+
nocompress('no transform');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// vary
|
|
141
|
+
vary(res, 'Accept-Encoding');
|
|
142
|
+
// content-length below threshold
|
|
143
|
+
if (Number(res.getHeader('Content-Length')) < threshold ||
|
|
144
|
+
(length !== undefined && length < threshold)) {
|
|
145
|
+
nocompress('size below threshold');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const encoding = res.getHeader('Content-Encoding') || 'identity';
|
|
149
|
+
// already encoded
|
|
150
|
+
if (encoding !== 'identity') {
|
|
151
|
+
nocompress('already encoded');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// head
|
|
155
|
+
if (req.method === 'HEAD') {
|
|
156
|
+
nocompress('HEAD request');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// compression method
|
|
160
|
+
// Get all client-accepted encodings, then pick the first one from our preferred order
|
|
161
|
+
const negotiator = new Negotiator(req);
|
|
162
|
+
const clientEncodings = negotiator.encodings(SUPPORTED_ENCODING);
|
|
163
|
+
// Prefer server's order, but fall back to client's first choice if no preferred match
|
|
164
|
+
let method = PREFERRED_ENCODING.find(enc => clientEncodings.includes(enc)) || clientEncodings[0];
|
|
165
|
+
// if no method is found, use the default encoding
|
|
166
|
+
if (!req.headers['accept-encoding'] && encodingSupported.has(enforceEncoding)) {
|
|
167
|
+
method = enforceEncoding;
|
|
168
|
+
}
|
|
169
|
+
// negotiation failed
|
|
170
|
+
if (!method || method === 'identity') {
|
|
171
|
+
nocompress('not acceptable');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// compression stream
|
|
175
|
+
if (method === 'zstd') {
|
|
176
|
+
stream = zlib.createZstdCompress(optsZstd);
|
|
177
|
+
}
|
|
178
|
+
else if (method === 'br') {
|
|
179
|
+
stream = zlib.createBrotliCompress(optsBrotli);
|
|
180
|
+
}
|
|
181
|
+
else if (method === 'gzip') {
|
|
182
|
+
stream = zlib.createGzip(zlibOpts);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
stream = zlib.createDeflate(zlibOpts);
|
|
186
|
+
}
|
|
187
|
+
// add buffered listeners to stream
|
|
188
|
+
addListeners(stream, stream.on.bind(stream), listeners);
|
|
189
|
+
// header fields
|
|
190
|
+
res.setHeader('Content-Encoding', method);
|
|
191
|
+
res.removeHeader('Content-Length');
|
|
192
|
+
// compression
|
|
193
|
+
stream.on('data', function onStreamData(chunk) {
|
|
194
|
+
if (!_write.call(res, chunk, 'utf8', () => { })) {
|
|
195
|
+
stream?.pause();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
stream.on('end', function onStreamEnd() {
|
|
199
|
+
_end.call(res, undefined, 'utf8', () => { });
|
|
200
|
+
});
|
|
201
|
+
_on.call(res, 'drain', function onResponseDrain() {
|
|
202
|
+
stream?.resume();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
next();
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Default filter function.
|
|
210
|
+
* Returns true if the Content-Type is compressible.
|
|
211
|
+
*/
|
|
212
|
+
export function shouldCompress(_req, res) {
|
|
213
|
+
const type = res.getHeader('Content-Type');
|
|
214
|
+
if (type === undefined || !compressible(type)) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
function addListeners(stream, on, listeners) {
|
|
220
|
+
for (const [type, listener] of listeners) {
|
|
221
|
+
on.call(stream, type, listener);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function chunkLength(chunk, encoding) {
|
|
225
|
+
if (!chunk) {
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding);
|
|
229
|
+
}
|
|
230
|
+
function shouldTransform(_req, res) {
|
|
231
|
+
const cacheControl = res.getHeader('Cache-Control');
|
|
232
|
+
// Don't compress for Cache-Control: no-transform
|
|
233
|
+
// https://tools.ietf.org/html/rfc7234#section-5.2.2.4
|
|
234
|
+
return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl);
|
|
235
|
+
}
|
|
236
|
+
function toBuffer(chunk, encoding) {
|
|
237
|
+
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
238
|
+
}
|
|
239
|
+
function headersSent(res) {
|
|
240
|
+
return typeof res.headersSent !== 'boolean' ? Boolean(res._header) : res.headersSent;
|
|
241
|
+
}
|
|
242
|
+
function parseBytes(value) {
|
|
243
|
+
if (value === undefined)
|
|
244
|
+
return undefined;
|
|
245
|
+
if (typeof value === 'number')
|
|
246
|
+
return value;
|
|
247
|
+
const match = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb|tb|b)?$/i.exec(value);
|
|
248
|
+
if (!match)
|
|
249
|
+
return undefined;
|
|
250
|
+
const n = Number.parseFloat(match[1]);
|
|
251
|
+
const unit = (match[2] || 'b').toLowerCase();
|
|
252
|
+
const unitMap = {
|
|
253
|
+
b: 1,
|
|
254
|
+
kb: 1024,
|
|
255
|
+
mb: 1024 ** 2,
|
|
256
|
+
gb: 1024 ** 3,
|
|
257
|
+
tb: 1024 ** 4,
|
|
258
|
+
};
|
|
259
|
+
return Math.floor(n * unitMap[unit]);
|
|
260
|
+
}
|
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.46.1",
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"@sentry/node": "^10"
|
|
7
7
|
},
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
"@naturalcycles/js-lib": "^15",
|
|
11
11
|
"@naturalcycles/nodejs-lib": "^15",
|
|
12
12
|
"@types/body-parser": "^1",
|
|
13
|
-
"@types/compression": "^1",
|
|
14
13
|
"@types/cookie-parser": "^1",
|
|
15
14
|
"@types/cors": "^2",
|
|
16
15
|
"@types/express": "^5",
|
|
16
|
+
"@types/compressible": "^2",
|
|
17
17
|
"@types/on-finished": "^2",
|
|
18
|
-
"
|
|
18
|
+
"@types/on-headers": "^1",
|
|
19
|
+
"@types/vary": "^1",
|
|
20
|
+
"compressible": "^2",
|
|
19
21
|
"cookie-parser": "^1",
|
|
20
22
|
"cors": "^2",
|
|
21
23
|
"dotenv": "^17",
|
|
@@ -23,15 +25,18 @@
|
|
|
23
25
|
"express": "^5",
|
|
24
26
|
"firebase-admin": "^13",
|
|
25
27
|
"helmet": "^8",
|
|
28
|
+
"negotiator": "^1",
|
|
29
|
+
"on-headers": "^1",
|
|
26
30
|
"on-finished": "^2",
|
|
27
31
|
"simple-git": "^3",
|
|
28
|
-
"tslib": "^2"
|
|
32
|
+
"tslib": "^2",
|
|
33
|
+
"vary": "^1"
|
|
29
34
|
},
|
|
30
35
|
"devDependencies": {
|
|
31
36
|
"@sentry/node": "^10",
|
|
32
37
|
"@types/ejs": "^3",
|
|
33
38
|
"fastify": "^5",
|
|
34
|
-
"@naturalcycles/dev-lib": "
|
|
39
|
+
"@naturalcycles/dev-lib": "18.4.2"
|
|
35
40
|
},
|
|
36
41
|
"exports": {
|
|
37
42
|
".": "./dist/index.js",
|
|
@@ -2,6 +2,7 @@ import type { Options, OptionsJson, OptionsUrlencoded } from 'body-parser'
|
|
|
2
2
|
import type { CorsOptions } from 'cors'
|
|
3
3
|
import type { SentrySharedService } from '../sentry/sentry.shared.service.js'
|
|
4
4
|
import { asyncLocalStorageMiddleware } from '../server/asyncLocalStorageMiddleware.js'
|
|
5
|
+
import { compressionMiddleware } from '../server/compressionMiddleware.js'
|
|
5
6
|
import {
|
|
6
7
|
genericErrorMiddleware,
|
|
7
8
|
type GenericErrorMiddlewareCfg,
|
|
@@ -74,8 +75,7 @@ export async function createDefaultApp(cfg: DefaultAppCfg): Promise<BackendAppli
|
|
|
74
75
|
|
|
75
76
|
if (!isGAE() && isTest) {
|
|
76
77
|
// compression is not used in AppEngine, because AppEngine provides it by default
|
|
77
|
-
|
|
78
|
-
app.use(compression())
|
|
78
|
+
app.use(compressionMiddleware())
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// app.use(safeJsonMiddleware()) // optional
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './sentry/sentry.shared.service.js'
|
|
|
3
3
|
export * from './server/asyncLocalStorageMiddleware.js'
|
|
4
4
|
export * from './server/basicAuthMiddleware.js'
|
|
5
5
|
export * from './server/bodyParserTimeoutMiddleware.js'
|
|
6
|
+
export * from './server/compressionMiddleware.js'
|
|
6
7
|
export * from './server/eventLoop.util.js'
|
|
7
8
|
export * from './server/genericErrorMiddleware.js'
|
|
8
9
|
export * from './server/logMiddleware.js'
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/* !
|
|
2
|
+
* Compression middleware forked from `compression` npm package.
|
|
3
|
+
* With added zstd support based on https://github.com/expressjs/compression/pull/250
|
|
4
|
+
*
|
|
5
|
+
* Original copyright:
|
|
6
|
+
* Copyright(c) 2010 Sencha Inc.
|
|
7
|
+
* Copyright(c) 2011 TJ Holowaychuk
|
|
8
|
+
* Copyright(c) 2014 Jonathan Ong
|
|
9
|
+
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
10
|
+
* MIT Licensed
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import zlib from 'node:zlib'
|
|
14
|
+
import compressible from 'compressible'
|
|
15
|
+
// @ts-expect-error no types
|
|
16
|
+
import Negotiator from 'negotiator'
|
|
17
|
+
import onHeaders from 'on-headers'
|
|
18
|
+
import vary from 'vary'
|
|
19
|
+
import type { BackendRequest, BackendRequestHandler, BackendResponse } from './server.model.js'
|
|
20
|
+
|
|
21
|
+
export interface CompressionOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Custom filter function to determine if response should be compressed.
|
|
24
|
+
* Default checks if Content-Type is compressible.
|
|
25
|
+
*/
|
|
26
|
+
filter?: (req: BackendRequest, res: BackendResponse) => boolean
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimum response size in bytes to compress.
|
|
30
|
+
* @default 1024
|
|
31
|
+
*/
|
|
32
|
+
threshold?: number | string
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Encoding to use when Accept-Encoding header is not present.
|
|
36
|
+
* @default 'identity'
|
|
37
|
+
*/
|
|
38
|
+
enforceEncoding?: 'gzip' | 'deflate' | 'br' | 'zstd' | 'identity'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* zlib options for gzip/deflate.
|
|
42
|
+
*/
|
|
43
|
+
level?: number
|
|
44
|
+
memLevel?: number
|
|
45
|
+
strategy?: number
|
|
46
|
+
windowBits?: number
|
|
47
|
+
chunkSize?: number
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Brotli-specific options.
|
|
51
|
+
*/
|
|
52
|
+
brotli?: {
|
|
53
|
+
params?: Record<number, number>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Zstd-specific options.
|
|
58
|
+
*/
|
|
59
|
+
zstd?: {
|
|
60
|
+
params?: Record<number, number>
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SUPPORTED_ENCODING = ['zstd', 'br', 'gzip', 'deflate', 'identity']
|
|
65
|
+
const PREFERRED_ENCODING = ['zstd', 'br', 'gzip']
|
|
66
|
+
|
|
67
|
+
const encodingSupported = new Set(['gzip', 'deflate', 'identity', 'br', 'zstd'])
|
|
68
|
+
|
|
69
|
+
const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compression middleware with support for gzip, deflate, brotli, and zstd.
|
|
73
|
+
*
|
|
74
|
+
* This is a fork of the `compression` npm package with added zstd support.
|
|
75
|
+
*
|
|
76
|
+
* Encoding preference order: zstd > br > gzip > deflate
|
|
77
|
+
*/
|
|
78
|
+
export function compressionMiddleware(options?: CompressionOptions): BackendRequestHandler {
|
|
79
|
+
const opts = options || {}
|
|
80
|
+
|
|
81
|
+
// Brotli options
|
|
82
|
+
const optsBrotli: zlib.BrotliOptions = {
|
|
83
|
+
...opts.brotli,
|
|
84
|
+
params: {
|
|
85
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
86
|
+
...opts.brotli?.params,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Zstd options
|
|
91
|
+
const optsZstd: zlib.ZstdOptions = {
|
|
92
|
+
...opts.zstd,
|
|
93
|
+
params: {
|
|
94
|
+
[zlib.constants.ZSTD_c_compressionLevel]: 3,
|
|
95
|
+
...opts.zstd?.params,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// General zlib options for gzip/deflate
|
|
100
|
+
const zlibOpts: zlib.ZlibOptions = {}
|
|
101
|
+
if (opts.level !== undefined) zlibOpts.level = opts.level
|
|
102
|
+
if (opts.memLevel !== undefined) zlibOpts.memLevel = opts.memLevel
|
|
103
|
+
if (opts.strategy !== undefined) zlibOpts.strategy = opts.strategy
|
|
104
|
+
if (opts.windowBits !== undefined) zlibOpts.windowBits = opts.windowBits
|
|
105
|
+
if (opts.chunkSize !== undefined) zlibOpts.chunkSize = opts.chunkSize
|
|
106
|
+
|
|
107
|
+
const filter = opts.filter || shouldCompress
|
|
108
|
+
const threshold = parseBytes(opts.threshold) ?? 1024
|
|
109
|
+
const enforceEncoding = opts.enforceEncoding || 'identity'
|
|
110
|
+
|
|
111
|
+
return function compression(req, res, next) {
|
|
112
|
+
let ended = false
|
|
113
|
+
let length: number | undefined
|
|
114
|
+
let listeners: [string, (...args: any[]) => void][] | null = []
|
|
115
|
+
let stream: zlib.Gzip | zlib.Deflate | zlib.BrotliCompress | zlib.ZstdCompress | undefined
|
|
116
|
+
|
|
117
|
+
const _end = res.end
|
|
118
|
+
const _on = res.on
|
|
119
|
+
const _write = res.write
|
|
120
|
+
|
|
121
|
+
// flush
|
|
122
|
+
;(res as any).flush = function flush() {
|
|
123
|
+
if (stream) {
|
|
124
|
+
stream.flush()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// proxy write
|
|
129
|
+
res.write = function write(
|
|
130
|
+
chunk: any,
|
|
131
|
+
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
|
132
|
+
callback?: (error?: Error | null) => void,
|
|
133
|
+
): boolean {
|
|
134
|
+
if (ended) {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!headersSent(res)) {
|
|
139
|
+
res.writeHead(res.statusCode)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (stream) {
|
|
143
|
+
return stream.write(toBuffer(chunk, encodingOrCallback as BufferEncoding))
|
|
144
|
+
}
|
|
145
|
+
return _write.call(res, chunk, encodingOrCallback as BufferEncoding, callback as any)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// proxy end
|
|
149
|
+
res.end = function end(
|
|
150
|
+
chunk?: any,
|
|
151
|
+
encodingOrCallback?: BufferEncoding | (() => void),
|
|
152
|
+
callback?: () => void,
|
|
153
|
+
): any {
|
|
154
|
+
if (ended) {
|
|
155
|
+
return res
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!headersSent(res)) {
|
|
159
|
+
// estimate the length
|
|
160
|
+
if (!res.getHeader('Content-Length')) {
|
|
161
|
+
length = chunkLength(chunk, encodingOrCallback as BufferEncoding)
|
|
162
|
+
}
|
|
163
|
+
res.writeHead(res.statusCode)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!stream) {
|
|
167
|
+
return _end.call(res, chunk, encodingOrCallback as BufferEncoding, callback)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// mark ended
|
|
171
|
+
ended = true
|
|
172
|
+
|
|
173
|
+
// write Buffer
|
|
174
|
+
if (chunk) {
|
|
175
|
+
stream.end(toBuffer(chunk, encodingOrCallback as BufferEncoding))
|
|
176
|
+
} else {
|
|
177
|
+
stream.end()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return res
|
|
181
|
+
}
|
|
182
|
+
;(res as any).on = function on(type: string, listener: (...args: any[]) => void) {
|
|
183
|
+
if (!listeners || type !== 'drain') {
|
|
184
|
+
return _on.call(res, type, listener)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (stream) {
|
|
188
|
+
return stream.on(type, listener)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// buffer listeners for future stream
|
|
192
|
+
listeners.push([type, listener])
|
|
193
|
+
|
|
194
|
+
return res
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function nocompress(_msg: string): void {
|
|
198
|
+
addListeners(res, _on, listeners!)
|
|
199
|
+
listeners = null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
onHeaders(res, function onResponseHeaders() {
|
|
203
|
+
// determine if request is filtered
|
|
204
|
+
if (!filter(req, res)) {
|
|
205
|
+
nocompress('filtered')
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// determine if the entity should be transformed
|
|
210
|
+
if (!shouldTransform(req, res)) {
|
|
211
|
+
nocompress('no transform')
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// vary
|
|
216
|
+
vary(res, 'Accept-Encoding')
|
|
217
|
+
|
|
218
|
+
// content-length below threshold
|
|
219
|
+
if (
|
|
220
|
+
Number(res.getHeader('Content-Length')) < threshold ||
|
|
221
|
+
(length !== undefined && length < threshold)
|
|
222
|
+
) {
|
|
223
|
+
nocompress('size below threshold')
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const encoding = (res.getHeader('Content-Encoding') as string) || 'identity'
|
|
228
|
+
|
|
229
|
+
// already encoded
|
|
230
|
+
if (encoding !== 'identity') {
|
|
231
|
+
nocompress('already encoded')
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// head
|
|
236
|
+
if (req.method === 'HEAD') {
|
|
237
|
+
nocompress('HEAD request')
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// compression method
|
|
242
|
+
// Get all client-accepted encodings, then pick the first one from our preferred order
|
|
243
|
+
const negotiator = new Negotiator(req)
|
|
244
|
+
const clientEncodings = negotiator.encodings(SUPPORTED_ENCODING) as string[]
|
|
245
|
+
// Prefer server's order, but fall back to client's first choice if no preferred match
|
|
246
|
+
let method: string | undefined =
|
|
247
|
+
PREFERRED_ENCODING.find(enc => clientEncodings.includes(enc)) || clientEncodings[0]
|
|
248
|
+
|
|
249
|
+
// if no method is found, use the default encoding
|
|
250
|
+
if (!req.headers['accept-encoding'] && encodingSupported.has(enforceEncoding)) {
|
|
251
|
+
method = enforceEncoding
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// negotiation failed
|
|
255
|
+
if (!method || method === 'identity') {
|
|
256
|
+
nocompress('not acceptable')
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// compression stream
|
|
261
|
+
if (method === 'zstd') {
|
|
262
|
+
stream = zlib.createZstdCompress(optsZstd)
|
|
263
|
+
} else if (method === 'br') {
|
|
264
|
+
stream = zlib.createBrotliCompress(optsBrotli)
|
|
265
|
+
} else if (method === 'gzip') {
|
|
266
|
+
stream = zlib.createGzip(zlibOpts)
|
|
267
|
+
} else {
|
|
268
|
+
stream = zlib.createDeflate(zlibOpts)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// add buffered listeners to stream
|
|
272
|
+
addListeners(stream, stream.on.bind(stream), listeners!)
|
|
273
|
+
|
|
274
|
+
// header fields
|
|
275
|
+
res.setHeader('Content-Encoding', method)
|
|
276
|
+
res.removeHeader('Content-Length')
|
|
277
|
+
|
|
278
|
+
// compression
|
|
279
|
+
stream.on('data', function onStreamData(chunk: Buffer) {
|
|
280
|
+
if (!_write.call(res, chunk, 'utf8', () => {})) {
|
|
281
|
+
stream?.pause()
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
stream.on('end', function onStreamEnd() {
|
|
286
|
+
_end.call(res, undefined, 'utf8', () => {})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
_on.call(res, 'drain', function onResponseDrain() {
|
|
290
|
+
stream?.resume()
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
next()
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Default filter function.
|
|
300
|
+
* Returns true if the Content-Type is compressible.
|
|
301
|
+
*/
|
|
302
|
+
export function shouldCompress(_req: any, res: any): boolean {
|
|
303
|
+
const type = res.getHeader('Content-Type') as string | undefined
|
|
304
|
+
|
|
305
|
+
if (type === undefined || !compressible(type)) {
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return true
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function addListeners(
|
|
313
|
+
stream: any,
|
|
314
|
+
on: (type: string, listener: (...args: any[]) => void) => void,
|
|
315
|
+
listeners: [string, (...args: any[]) => void][],
|
|
316
|
+
): void {
|
|
317
|
+
for (const [type, listener] of listeners) {
|
|
318
|
+
on.call(stream, type, listener)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function chunkLength(chunk: any, encoding?: BufferEncoding): number {
|
|
323
|
+
if (!chunk) {
|
|
324
|
+
return 0
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function shouldTransform(_req: any, res: any): boolean {
|
|
331
|
+
const cacheControl = res.getHeader('Cache-Control') as string | undefined
|
|
332
|
+
|
|
333
|
+
// Don't compress for Cache-Control: no-transform
|
|
334
|
+
// https://tools.ietf.org/html/rfc7234#section-5.2.2.4
|
|
335
|
+
return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function toBuffer(chunk: any, encoding?: BufferEncoding): Buffer {
|
|
339
|
+
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function headersSent(res: any): boolean {
|
|
343
|
+
return typeof res.headersSent !== 'boolean' ? Boolean(res._header) : res.headersSent
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseBytes(value: string | number | undefined): number | undefined {
|
|
347
|
+
if (value === undefined) return undefined
|
|
348
|
+
if (typeof value === 'number') return value
|
|
349
|
+
|
|
350
|
+
const match = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb|tb|b)?$/i.exec(value)
|
|
351
|
+
if (!match) return undefined
|
|
352
|
+
|
|
353
|
+
const n = Number.parseFloat(match[1]!)
|
|
354
|
+
const unit = (match[2] || 'b').toLowerCase()
|
|
355
|
+
|
|
356
|
+
const unitMap: Record<string, number> = {
|
|
357
|
+
b: 1,
|
|
358
|
+
kb: 1024,
|
|
359
|
+
mb: 1024 ** 2,
|
|
360
|
+
gb: 1024 ** 3,
|
|
361
|
+
tb: 1024 ** 4,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return Math.floor(n * unitMap[unit]!)
|
|
365
|
+
}
|