@naturalcycles/backend-lib 9.44.2 → 9.46.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.
@@ -35,7 +35,6 @@ export class BaseAdminService {
35
35
  if (!email)
36
36
  return;
37
37
  console.log(`getEmailPermissions (${dimGrey(email)}) returning undefined (please override the implementation)`);
38
- return;
39
38
  }
40
39
  /**
41
40
  * To be extended.
@@ -62,7 +61,6 @@ export class BaseAdminService {
62
61
  return; // skip logging, expected error
63
62
  }
64
63
  req.error(`getEmailByToken error:`, err);
65
- return;
66
64
  }
67
65
  }
68
66
  /**
@@ -1,10 +1,12 @@
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';
5
6
  import { notFoundMiddleware } from '../server/notFoundMiddleware.js';
6
7
  import { requestTimeoutMiddleware } from '../server/requestTimeoutMiddleware.js';
7
8
  import { simpleRequestLoggerMiddleware } from '../server/simpleRequestLoggerMiddleware.js';
9
+ import { isGAE } from '../util.js';
8
10
  const isTest = process.env['APP_ENV'] === 'test';
9
11
  const isDev = process.env['APP_ENV'] === 'dev';
10
12
  export async function createDefaultApp(cfg) {
@@ -29,6 +31,24 @@ export async function createDefaultApp(cfg) {
29
31
  if (isDev) {
30
32
  app.use(simpleRequestLoggerMiddleware());
31
33
  }
34
+ if (!isTest) {
35
+ // leaks, load lazily
36
+ const { default: helmet } = await import('helmet');
37
+ app.use(helmet({
38
+ contentSecurityPolicy: false, // to allow "admin 401 auto-redirect"
39
+ }));
40
+ }
41
+ app.use(cors({
42
+ origin: true,
43
+ credentials: true,
44
+ // methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', // default
45
+ maxAge: 86400,
46
+ ...cfg.corsOptions,
47
+ }));
48
+ if (!isGAE() && isTest) {
49
+ // compression is not used in AppEngine, because AppEngine provides it by default
50
+ app.use(compressionMiddleware());
51
+ }
32
52
  // app.use(safeJsonMiddleware()) // optional
33
53
  // accepts application/json
34
54
  app.use(express.json({
@@ -55,20 +75,6 @@ export async function createDefaultApp(cfg) {
55
75
  ...cfg.bodyParserRawOptions,
56
76
  }));
57
77
  app.use(cookieParser());
58
- if (!isTest) {
59
- // leaks, load lazily
60
- const { default: helmet } = await import('helmet');
61
- app.use(helmet({
62
- contentSecurityPolicy: false, // to allow "admin 401 auto-redirect"
63
- }));
64
- }
65
- app.use(cors({
66
- origin: true,
67
- credentials: true,
68
- // methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', // default
69
- maxAge: 86400,
70
- ...cfg.corsOptions,
71
- }));
72
78
  // app.use(clearBodyParserTimeout()) // removed by default
73
79
  // Static is now disabled by default due to performance
74
80
  // Without: 6500 rpsAvg
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,257 @@
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
+ const negotiator = new Negotiator(req);
161
+ let method = negotiator.encoding(SUPPORTED_ENCODING, PREFERRED_ENCODING);
162
+ // if no method is found, use the default encoding
163
+ if (!req.headers['accept-encoding'] && encodingSupported.has(enforceEncoding)) {
164
+ method = enforceEncoding;
165
+ }
166
+ // negotiation failed
167
+ if (!method || method === 'identity') {
168
+ nocompress('not acceptable');
169
+ return;
170
+ }
171
+ // compression stream
172
+ if (method === 'zstd') {
173
+ stream = zlib.createZstdCompress(optsZstd);
174
+ }
175
+ else if (method === 'br') {
176
+ stream = zlib.createBrotliCompress(optsBrotli);
177
+ }
178
+ else if (method === 'gzip') {
179
+ stream = zlib.createGzip(zlibOpts);
180
+ }
181
+ else {
182
+ stream = zlib.createDeflate(zlibOpts);
183
+ }
184
+ // add buffered listeners to stream
185
+ addListeners(stream, stream.on.bind(stream), listeners);
186
+ // header fields
187
+ res.setHeader('Content-Encoding', method);
188
+ res.removeHeader('Content-Length');
189
+ // compression
190
+ stream.on('data', function onStreamData(chunk) {
191
+ if (!_write.call(res, chunk, 'utf8', () => { })) {
192
+ stream?.pause();
193
+ }
194
+ });
195
+ stream.on('end', function onStreamEnd() {
196
+ _end.call(res, undefined, 'utf8', () => { });
197
+ });
198
+ _on.call(res, 'drain', function onResponseDrain() {
199
+ stream?.resume();
200
+ });
201
+ });
202
+ next();
203
+ };
204
+ }
205
+ /**
206
+ * Default filter function.
207
+ * Returns true if the Content-Type is compressible.
208
+ */
209
+ export function shouldCompress(_req, res) {
210
+ const type = res.getHeader('Content-Type');
211
+ if (type === undefined || !compressible(type)) {
212
+ return false;
213
+ }
214
+ return true;
215
+ }
216
+ function addListeners(stream, on, listeners) {
217
+ for (const [type, listener] of listeners) {
218
+ on.call(stream, type, listener);
219
+ }
220
+ }
221
+ function chunkLength(chunk, encoding) {
222
+ if (!chunk) {
223
+ return 0;
224
+ }
225
+ return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding);
226
+ }
227
+ function shouldTransform(_req, res) {
228
+ const cacheControl = res.getHeader('Cache-Control');
229
+ // Don't compress for Cache-Control: no-transform
230
+ // https://tools.ietf.org/html/rfc7234#section-5.2.2.4
231
+ return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl);
232
+ }
233
+ function toBuffer(chunk, encoding) {
234
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
235
+ }
236
+ function headersSent(res) {
237
+ return typeof res.headersSent !== 'boolean' ? Boolean(res._header) : res.headersSent;
238
+ }
239
+ function parseBytes(value) {
240
+ if (value === undefined)
241
+ return undefined;
242
+ if (typeof value === 'number')
243
+ return value;
244
+ const match = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb|tb|b)?$/i.exec(value);
245
+ if (!match)
246
+ return undefined;
247
+ const n = Number.parseFloat(match[1]);
248
+ const unit = (match[2] || 'b').toLowerCase();
249
+ const unitMap = {
250
+ b: 1,
251
+ kb: 1024,
252
+ mb: 1024 ** 2,
253
+ gb: 1024 ** 3,
254
+ tb: 1024 ** 4,
255
+ };
256
+ return Math.floor(n * unitMap[unit]);
257
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.44.2",
4
+ "version": "9.46.0",
5
5
  "peerDependencies": {
6
6
  "@sentry/node": "^10"
7
7
  },
@@ -13,7 +13,11 @@
13
13
  "@types/cookie-parser": "^1",
14
14
  "@types/cors": "^2",
15
15
  "@types/express": "^5",
16
+ "@types/compressible": "^2",
16
17
  "@types/on-finished": "^2",
18
+ "@types/on-headers": "^1",
19
+ "@types/vary": "^1",
20
+ "compressible": "^2",
17
21
  "cookie-parser": "^1",
18
22
  "cors": "^2",
19
23
  "dotenv": "^17",
@@ -21,9 +25,12 @@
21
25
  "express": "^5",
22
26
  "firebase-admin": "^13",
23
27
  "helmet": "^8",
28
+ "negotiator": "^1",
29
+ "on-headers": "^1",
24
30
  "on-finished": "^2",
25
31
  "simple-git": "^3",
26
- "tslib": "^2"
32
+ "tslib": "^2",
33
+ "vary": "^1"
27
34
  },
28
35
  "devDependencies": {
29
36
  "@sentry/node": "^10",
@@ -65,7 +65,6 @@ export class BaseAdminService {
65
65
  email,
66
66
  )}) returning undefined (please override the implementation)`,
67
67
  )
68
- return
69
68
  }
70
69
 
71
70
  /**
@@ -107,7 +106,6 @@ export class BaseAdminService {
107
106
  }
108
107
 
109
108
  req.error(`getEmailByToken error:`, err)
110
- return
111
109
  }
112
110
  }
113
111
 
@@ -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,
@@ -16,6 +17,7 @@ import type {
16
17
  BackendRequestHandler,
17
18
  } from '../server/server.model.js'
18
19
  import { simpleRequestLoggerMiddleware } from '../server/simpleRequestLoggerMiddleware.js'
20
+ import { isGAE } from '../util.js'
19
21
 
20
22
  const isTest = process.env['APP_ENV'] === 'test'
21
23
  const isDev = process.env['APP_ENV'] === 'dev'
@@ -51,6 +53,31 @@ export async function createDefaultApp(cfg: DefaultAppCfg): Promise<BackendAppli
51
53
  app.use(simpleRequestLoggerMiddleware())
52
54
  }
53
55
 
56
+ if (!isTest) {
57
+ // leaks, load lazily
58
+ const { default: helmet } = await import('helmet')
59
+ app.use(
60
+ helmet({
61
+ contentSecurityPolicy: false, // to allow "admin 401 auto-redirect"
62
+ }),
63
+ )
64
+ }
65
+
66
+ app.use(
67
+ cors({
68
+ origin: true,
69
+ credentials: true,
70
+ // methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', // default
71
+ maxAge: 86400,
72
+ ...cfg.corsOptions,
73
+ }),
74
+ )
75
+
76
+ if (!isGAE() && isTest) {
77
+ // compression is not used in AppEngine, because AppEngine provides it by default
78
+ app.use(compressionMiddleware())
79
+ }
80
+
54
81
  // app.use(safeJsonMiddleware()) // optional
55
82
 
56
83
  // accepts application/json
@@ -88,26 +115,6 @@ export async function createDefaultApp(cfg: DefaultAppCfg): Promise<BackendAppli
88
115
 
89
116
  app.use(cookieParser())
90
117
 
91
- if (!isTest) {
92
- // leaks, load lazily
93
- const { default: helmet } = await import('helmet')
94
- app.use(
95
- helmet({
96
- contentSecurityPolicy: false, // to allow "admin 401 auto-redirect"
97
- }),
98
- )
99
- }
100
-
101
- app.use(
102
- cors({
103
- origin: true,
104
- credentials: true,
105
- // methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', // default
106
- maxAge: 86400,
107
- ...cfg.corsOptions,
108
- }),
109
- )
110
-
111
118
  // app.use(clearBodyParserTimeout()) // removed by default
112
119
 
113
120
  // Static is now disabled by default due to performance
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,361 @@
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
+ const negotiator = new Negotiator(req)
243
+ let method: string | undefined = negotiator.encoding(SUPPORTED_ENCODING, PREFERRED_ENCODING)
244
+
245
+ // if no method is found, use the default encoding
246
+ if (!req.headers['accept-encoding'] && encodingSupported.has(enforceEncoding)) {
247
+ method = enforceEncoding
248
+ }
249
+
250
+ // negotiation failed
251
+ if (!method || method === 'identity') {
252
+ nocompress('not acceptable')
253
+ return
254
+ }
255
+
256
+ // compression stream
257
+ if (method === 'zstd') {
258
+ stream = zlib.createZstdCompress(optsZstd)
259
+ } else if (method === 'br') {
260
+ stream = zlib.createBrotliCompress(optsBrotli)
261
+ } else if (method === 'gzip') {
262
+ stream = zlib.createGzip(zlibOpts)
263
+ } else {
264
+ stream = zlib.createDeflate(zlibOpts)
265
+ }
266
+
267
+ // add buffered listeners to stream
268
+ addListeners(stream, stream.on.bind(stream), listeners!)
269
+
270
+ // header fields
271
+ res.setHeader('Content-Encoding', method)
272
+ res.removeHeader('Content-Length')
273
+
274
+ // compression
275
+ stream.on('data', function onStreamData(chunk: Buffer) {
276
+ if (!_write.call(res, chunk, 'utf8', () => {})) {
277
+ stream?.pause()
278
+ }
279
+ })
280
+
281
+ stream.on('end', function onStreamEnd() {
282
+ _end.call(res, undefined, 'utf8', () => {})
283
+ })
284
+
285
+ _on.call(res, 'drain', function onResponseDrain() {
286
+ stream?.resume()
287
+ })
288
+ })
289
+
290
+ next()
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Default filter function.
296
+ * Returns true if the Content-Type is compressible.
297
+ */
298
+ export function shouldCompress(_req: any, res: any): boolean {
299
+ const type = res.getHeader('Content-Type') as string | undefined
300
+
301
+ if (type === undefined || !compressible(type)) {
302
+ return false
303
+ }
304
+
305
+ return true
306
+ }
307
+
308
+ function addListeners(
309
+ stream: any,
310
+ on: (type: string, listener: (...args: any[]) => void) => void,
311
+ listeners: [string, (...args: any[]) => void][],
312
+ ): void {
313
+ for (const [type, listener] of listeners) {
314
+ on.call(stream, type, listener)
315
+ }
316
+ }
317
+
318
+ function chunkLength(chunk: any, encoding?: BufferEncoding): number {
319
+ if (!chunk) {
320
+ return 0
321
+ }
322
+
323
+ return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding)
324
+ }
325
+
326
+ function shouldTransform(_req: any, res: any): boolean {
327
+ const cacheControl = res.getHeader('Cache-Control') as string | undefined
328
+
329
+ // Don't compress for Cache-Control: no-transform
330
+ // https://tools.ietf.org/html/rfc7234#section-5.2.2.4
331
+ return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl)
332
+ }
333
+
334
+ function toBuffer(chunk: any, encoding?: BufferEncoding): Buffer {
335
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
336
+ }
337
+
338
+ function headersSent(res: any): boolean {
339
+ return typeof res.headersSent !== 'boolean' ? Boolean(res._header) : res.headersSent
340
+ }
341
+
342
+ function parseBytes(value: string | number | undefined): number | undefined {
343
+ if (value === undefined) return undefined
344
+ if (typeof value === 'number') return value
345
+
346
+ const match = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb|tb|b)?$/i.exec(value)
347
+ if (!match) return undefined
348
+
349
+ const n = Number.parseFloat(match[1]!)
350
+ const unit = (match[2] || 'b').toLowerCase()
351
+
352
+ const unitMap: Record<string, number> = {
353
+ b: 1,
354
+ kb: 1024,
355
+ mb: 1024 ** 2,
356
+ gb: 1024 ** 3,
357
+ tb: 1024 ** 4,
358
+ }
359
+
360
+ return Math.floor(n * unitMap[unit]!)
361
+ }