@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.
- package/dist/admin/base.admin.service.js +0 -2
- package/dist/express/createDefaultApp.js +20 -14
- 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 +257 -0
- package/package.json +9 -2
- package/src/admin/base.admin.service.ts +0 -2
- package/src/express/createDefaultApp.ts +27 -20
- package/src/index.ts +1 -0
- package/src/server/compressionMiddleware.ts +361 -0
|
@@ -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.
|
|
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
|
+
}
|