@naturalcycles/backend-lib 9.77.1 → 9.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compressible.d.ts +9 -0
- package/dist/compressible.js +57 -0
- package/dist/onFinished.d.ts +14 -2
- package/dist/onFinished.js +35 -2
- package/dist/onHeaders.d.ts +11 -0
- package/dist/onHeaders.js +56 -0
- package/dist/server/compressionMiddleware.js +3 -3
- package/dist/server/genericErrorMiddleware.js +34 -23
- package/dist/vary.d.ts +8 -0
- package/dist/vary.js +25 -0
- package/package.json +2 -10
- package/src/compressible.ts +59 -0
- package/src/onFinished.ts +43 -2
- package/src/onHeaders.ts +66 -0
- package/src/server/compressionMiddleware.ts +3 -3
- package/src/server/genericErrorMiddleware.ts +37 -24
- package/src/vary.ts +29 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the given Content-Type (parameters, e.g `; charset=utf-8`, are ignored) is compressible.
|
|
3
|
+
*
|
|
4
|
+
* Vendored (and modernized) from the `compressible` package.
|
|
5
|
+
* Instead of loading the whole `mime-db` (~2500 types), it's reduced to the equivalent:
|
|
6
|
+
* a regex + the list of compressible types that the regex doesn't match (as of mime-db@1.54.0).
|
|
7
|
+
* No type matched by the regex is marked as non-compressible in mime-db.
|
|
8
|
+
*/
|
|
9
|
+
export declare function compressible(type: string): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the given Content-Type (parameters, e.g `; charset=utf-8`, are ignored) is compressible.
|
|
3
|
+
*
|
|
4
|
+
* Vendored (and modernized) from the `compressible` package.
|
|
5
|
+
* Instead of loading the whole `mime-db` (~2500 types), it's reduced to the equivalent:
|
|
6
|
+
* a regex + the list of compressible types that the regex doesn't match (as of mime-db@1.54.0).
|
|
7
|
+
* No type matched by the regex is marked as non-compressible in mime-db.
|
|
8
|
+
*/
|
|
9
|
+
export function compressible(type) {
|
|
10
|
+
const mime = EXTRACT_TYPE_REGEX.exec(type)[1].toLowerCase();
|
|
11
|
+
return COMPRESSIBLE_TYPE_REGEX.test(mime) || COMPRESSIBLE_TYPES.has(mime);
|
|
12
|
+
}
|
|
13
|
+
const EXTRACT_TYPE_REGEX = /^\s*([^;\s]*)/;
|
|
14
|
+
const COMPRESSIBLE_TYPE_REGEX = /^text\/|\+(?:json|text|xml)$/;
|
|
15
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
16
|
+
'application/dart',
|
|
17
|
+
'application/ecmascript',
|
|
18
|
+
'application/javascript',
|
|
19
|
+
'application/json',
|
|
20
|
+
'application/octet-stream',
|
|
21
|
+
'application/postscript',
|
|
22
|
+
'application/raml+yaml',
|
|
23
|
+
'application/rtf',
|
|
24
|
+
'application/tar',
|
|
25
|
+
'application/toml',
|
|
26
|
+
'application/vnd.dart',
|
|
27
|
+
'application/vnd.ms-fontobject',
|
|
28
|
+
'application/vnd.ms-opentype',
|
|
29
|
+
'application/wasm',
|
|
30
|
+
'application/x-httpd-php',
|
|
31
|
+
'application/x-javascript',
|
|
32
|
+
'application/x-ns-proxy-autoconfig',
|
|
33
|
+
'application/x-sh',
|
|
34
|
+
'application/x-tar',
|
|
35
|
+
'application/x-virtualbox-hdd',
|
|
36
|
+
'application/x-virtualbox-ova',
|
|
37
|
+
'application/x-virtualbox-ovf',
|
|
38
|
+
'application/x-virtualbox-vbox',
|
|
39
|
+
'application/x-virtualbox-vdi',
|
|
40
|
+
'application/x-virtualbox-vhd',
|
|
41
|
+
'application/x-virtualbox-vmdk',
|
|
42
|
+
'application/x-www-form-urlencoded',
|
|
43
|
+
'application/xml',
|
|
44
|
+
'application/xml-dtd',
|
|
45
|
+
'font/otf',
|
|
46
|
+
'font/ttf',
|
|
47
|
+
'image/bmp',
|
|
48
|
+
'image/vnd.adobe.photoshop',
|
|
49
|
+
'image/vnd.microsoft.icon',
|
|
50
|
+
'image/vnd.ms-dds',
|
|
51
|
+
'image/x-icon',
|
|
52
|
+
'image/x-ms-bmp',
|
|
53
|
+
'message/rfc822',
|
|
54
|
+
'model/gltf-binary',
|
|
55
|
+
'x-shader/x-fragment',
|
|
56
|
+
'x-shader/x-vertex',
|
|
57
|
+
]);
|
package/dist/onFinished.d.ts
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Invokes the listener once the request/response has finished (completed, closed or errored).
|
|
4
|
+
* If it has already finished - the listener is invoked asynchronously (on the next tick).
|
|
5
|
+
*
|
|
6
|
+
* Listener runs in the async context (e.g AsyncLocalStorage) of the onFinished caller.
|
|
7
|
+
*
|
|
8
|
+
* Vendored (and modernized) from the `on-finished` package, built on top of `stream.finished`.
|
|
9
|
+
* Stream listeners are attached once per msg (subsequent calls are queued), as `stream.finished`
|
|
10
|
+
* is too expensive to call per listener (and would exceed the default MaxListeners).
|
|
11
|
+
*/
|
|
12
|
+
export declare function onFinished<T extends IncomingMessage | ServerResponse>(msg: T, listener: Listener<T>): T;
|
|
13
|
+
type Listener<T> = (err: Error | null, msg: T) => void;
|
|
14
|
+
export {};
|
package/dist/onFinished.js
CHANGED
|
@@ -1,2 +1,35 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
2
|
+
import { finished } from 'node:stream';
|
|
3
|
+
/**
|
|
4
|
+
* Invokes the listener once the request/response has finished (completed, closed or errored).
|
|
5
|
+
* If it has already finished - the listener is invoked asynchronously (on the next tick).
|
|
6
|
+
*
|
|
7
|
+
* Listener runs in the async context (e.g AsyncLocalStorage) of the onFinished caller.
|
|
8
|
+
*
|
|
9
|
+
* Vendored (and modernized) from the `on-finished` package, built on top of `stream.finished`.
|
|
10
|
+
* Stream listeners are attached once per msg (subsequent calls are queued), as `stream.finished`
|
|
11
|
+
* is too expensive to call per listener (and would exceed the default MaxListeners).
|
|
12
|
+
*/
|
|
13
|
+
export function onFinished(msg, listener) {
|
|
14
|
+
const m = msg;
|
|
15
|
+
const queue = m[kQueue];
|
|
16
|
+
if (queue) {
|
|
17
|
+
queue.push(bindAsyncContext(listener));
|
|
18
|
+
return msg;
|
|
19
|
+
}
|
|
20
|
+
const q = (m[kQueue] = [bindAsyncContext(listener)]);
|
|
21
|
+
finished(msg, err => {
|
|
22
|
+
m[kQueue] = undefined;
|
|
23
|
+
for (const fn of q)
|
|
24
|
+
fn(err ?? null, msg);
|
|
25
|
+
});
|
|
26
|
+
return msg;
|
|
27
|
+
}
|
|
28
|
+
const kQueue = Symbol('onFinished');
|
|
29
|
+
/**
|
|
30
|
+
* Faster alternative to `AsyncResource.bind` (which is ~0.8µs per call).
|
|
31
|
+
*/
|
|
32
|
+
function bindAsyncContext(fn) {
|
|
33
|
+
const resource = new AsyncResource('onFinished');
|
|
34
|
+
return (err, msg) => resource.runInAsyncScope(fn, null, err, msg);
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Invokes the listener once, right before the response writes its headers
|
|
4
|
+
* (via `res.writeHead`, which is also called implicitly by the first `write`/`end`).
|
|
5
|
+
*
|
|
6
|
+
* Headers passed to `writeHead` are applied to `res` before the listener runs,
|
|
7
|
+
* so the listener sees (and can modify) the final headers and statusCode.
|
|
8
|
+
*
|
|
9
|
+
* Vendored (and modernized) from the `on-headers` package.
|
|
10
|
+
*/
|
|
11
|
+
export declare function onHeaders(res: ServerResponse, listener: (this: ServerResponse) => void): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invokes the listener once, right before the response writes its headers
|
|
3
|
+
* (via `res.writeHead`, which is also called implicitly by the first `write`/`end`).
|
|
4
|
+
*
|
|
5
|
+
* Headers passed to `writeHead` are applied to `res` before the listener runs,
|
|
6
|
+
* so the listener sees (and can modify) the final headers and statusCode.
|
|
7
|
+
*
|
|
8
|
+
* Vendored (and modernized) from the `on-headers` package.
|
|
9
|
+
*/
|
|
10
|
+
export function onHeaders(res, listener) {
|
|
11
|
+
// Loosely typed, to call it with the (statusCode, reason?) signature, bypassing overloads
|
|
12
|
+
const prevWriteHead = res.writeHead;
|
|
13
|
+
let fired = false;
|
|
14
|
+
res.writeHead = function writeHead(statusCode, reasonOrHeaders, headers) {
|
|
15
|
+
if (fired)
|
|
16
|
+
return prevWriteHead.apply(this, arguments);
|
|
17
|
+
fired = true;
|
|
18
|
+
const reason = typeof reasonOrHeaders === 'string' ? reasonOrHeaders : undefined;
|
|
19
|
+
headers = reason === undefined ? reasonOrHeaders : headers;
|
|
20
|
+
this.statusCode = statusCode;
|
|
21
|
+
if (headers)
|
|
22
|
+
setHeaders(this, headers);
|
|
23
|
+
listener.call(this);
|
|
24
|
+
// Reason phrase is dropped if the listener changed the statusCode
|
|
25
|
+
return reason !== undefined && this.statusCode === statusCode
|
|
26
|
+
? prevWriteHead.call(this, statusCode, reason)
|
|
27
|
+
: prevWriteHead.call(this, this.statusCode);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function setHeaders(res, headers) {
|
|
31
|
+
if (!Array.isArray(headers)) {
|
|
32
|
+
for (const k of Object.keys(headers)) {
|
|
33
|
+
if (k)
|
|
34
|
+
res.setHeader(k, headers[k]);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(headers[0])) {
|
|
39
|
+
// 2D: [[k, v], [k, v]]
|
|
40
|
+
for (const [k, v] of headers) {
|
|
41
|
+
if (k)
|
|
42
|
+
res.setHeader(k, v);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// 1D (raw): [k, v, k, v], keys may repeat
|
|
47
|
+
if (headers.length % 2)
|
|
48
|
+
throw new TypeError('headers array is malformed');
|
|
49
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
50
|
+
res.removeHeader(headers[i]);
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
53
|
+
if (headers[i])
|
|
54
|
+
res.appendHeader(headers[i], headers[i + 1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* MIT Licensed
|
|
11
11
|
*/
|
|
12
12
|
import zlib from 'node:zlib';
|
|
13
|
-
import compressible from 'compressible';
|
|
14
13
|
// @ts-expect-error no types
|
|
15
14
|
import Negotiator from 'negotiator';
|
|
16
|
-
import
|
|
17
|
-
import
|
|
15
|
+
import { compressible } from '../compressible.js';
|
|
16
|
+
import { onHeaders } from '../onHeaders.js';
|
|
17
|
+
import { vary } from '../vary.js';
|
|
18
18
|
const SUPPORTED_ENCODING = ['zstd', 'br', 'gzip', 'deflate', 'identity'];
|
|
19
19
|
const PREFERRED_ENCODING = ['zstd', 'br', 'gzip'];
|
|
20
20
|
const encodingSupported = new Set(['gzip', 'deflate', 'identity', 'br', 'zstd']);
|
|
@@ -2,6 +2,17 @@ import { _anyToError, _errorLikeToErrorObject } from '@naturalcycles/js-lib/erro
|
|
|
2
2
|
import { _objectAssign } from '@naturalcycles/js-lib/types';
|
|
3
3
|
const { APP_ENV } = process.env;
|
|
4
4
|
const includeErrorStack = APP_ENV === 'dev';
|
|
5
|
+
// Headers a handler may have set for the response it never got to send;
|
|
6
|
+
// they would wrongly describe the error response (e.g `res.json` keeps a pre-set Content-Type)
|
|
7
|
+
const staleResponseHeaders = [
|
|
8
|
+
'Content-Type',
|
|
9
|
+
'Content-Disposition',
|
|
10
|
+
'Content-Encoding',
|
|
11
|
+
'Content-Language',
|
|
12
|
+
'Content-Range',
|
|
13
|
+
'ETag',
|
|
14
|
+
'Last-Modified',
|
|
15
|
+
];
|
|
5
16
|
// Hacky way to store the errorService, so it's available to `respondWithError` function
|
|
6
17
|
let errorService;
|
|
7
18
|
let formatError;
|
|
@@ -14,17 +25,6 @@ export function genericErrorMiddleware(cfg = {}) {
|
|
|
14
25
|
errorService ||= cfg.errorReportingService;
|
|
15
26
|
formatError = cfg.formatError;
|
|
16
27
|
return (err, req, res, _next) => {
|
|
17
|
-
// if (res.headersSent) {
|
|
18
|
-
// Here we don't even log this error
|
|
19
|
-
// It's known that it comes from sentry.requestHandler()
|
|
20
|
-
// requestHandler waits for all promises/timeouts to finish in the request, and then emits this error here,
|
|
21
|
-
// while `res` is the same as was returned to the User (so, both headers and the data was already returned by that time)
|
|
22
|
-
// req.log.warn(`genericErrorHandler, but headersSent=true`, err)
|
|
23
|
-
// Here we don't propagate the error further, cause there's only "default express error logger" behind it
|
|
24
|
-
// and nothing else. Previously it was logging the same error once again because of this. Avoid.
|
|
25
|
-
// return next(err)
|
|
26
|
-
// return next()
|
|
27
|
-
// }
|
|
28
28
|
respondWithError(req, res, err);
|
|
29
29
|
};
|
|
30
30
|
}
|
|
@@ -48,34 +48,45 @@ export function respondWithError(req, res, err) {
|
|
|
48
48
|
// todo: add endpoint to the log
|
|
49
49
|
// todo: add userId from the "Context" (or, just req.userId?) to the log
|
|
50
50
|
}
|
|
51
|
-
if (headersSent)
|
|
51
|
+
if (headersSent) {
|
|
52
|
+
// Too late for an error response. A finished response needs nothing beyond the reporting above.
|
|
53
|
+
// An unfinished one (error mid-stream) is cut off, so the client sees a failed transfer right away
|
|
54
|
+
// instead of waiting for a body that never completes. Same as Express' default error handler.
|
|
55
|
+
if (!res.writableEnded)
|
|
56
|
+
res.destroy();
|
|
52
57
|
return;
|
|
58
|
+
}
|
|
53
59
|
const httpError = _errorLikeToErrorObject(originalError);
|
|
54
60
|
if (!includeErrorStack)
|
|
55
61
|
delete httpError.stack;
|
|
56
62
|
// http-errors (express itself, its body parsers) carry the status they should be answered with
|
|
57
|
-
|
|
63
|
+
const httpErrorLike = isHttpError(originalError) ? originalError : undefined;
|
|
64
|
+
httpError.data.backendResponseStatusCode ||= httpErrorLike?.statusCode || 500; // default to 500
|
|
58
65
|
_objectAssign(httpError.data, {
|
|
59
66
|
errorId,
|
|
60
67
|
headersSent: headersSent || undefined,
|
|
61
68
|
});
|
|
62
69
|
formatError?.(httpError, req); // Mutates the error
|
|
70
|
+
for (const header of staleResponseHeaders)
|
|
71
|
+
res.removeHeader(header);
|
|
72
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
73
|
+
if (httpErrorLike?.headers)
|
|
74
|
+
res.set(httpErrorLike.headers);
|
|
63
75
|
res.status(httpError.data.backendResponseStatusCode).json({
|
|
64
76
|
error: httpError,
|
|
65
77
|
});
|
|
66
78
|
}
|
|
67
79
|
/**
|
|
68
80
|
* Errors created by the `http-errors` package (express itself, body-parser, raw-body, send)
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* (e.g a gateway's response
|
|
81
|
+
* describe the response they expect: `status` and `statusCode` (always equal),
|
|
82
|
+
* `expose` (whether the message is safe to show to the client) and optional response `headers`.
|
|
83
|
+
* The first three together are the marker: other libraries also put `statusCode` or `headers`
|
|
84
|
+
* on their errors (e.g a gateway's response), which say nothing about how we should respond.
|
|
73
85
|
*/
|
|
74
|
-
function
|
|
86
|
+
function isHttpError(err) {
|
|
75
87
|
const { status, statusCode, expose } = err;
|
|
76
|
-
if (typeof expose !== 'boolean' || typeof statusCode !== 'number' || status !== statusCode)
|
|
77
|
-
return;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return statusCode;
|
|
88
|
+
if (typeof expose !== 'boolean' || typeof statusCode !== 'number' || status !== statusCode) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599;
|
|
81
92
|
}
|
package/dist/vary.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Adds the field (header name) to the response's Vary header, unless it's already there
|
|
4
|
+
* (case-insensitive). Vary: * stays as is, and adding '*' replaces the whole header with '*'.
|
|
5
|
+
*
|
|
6
|
+
* Vendored (and modernized) from the `vary` package.
|
|
7
|
+
*/
|
|
8
|
+
export declare function vary(res: ServerResponse, field: string): void;
|
package/dist/vary.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the field (header name) to the response's Vary header, unless it's already there
|
|
3
|
+
* (case-insensitive). Vary: * stays as is, and adding '*' replaces the whole header with '*'.
|
|
4
|
+
*
|
|
5
|
+
* Vendored (and modernized) from the `vary` package.
|
|
6
|
+
*/
|
|
7
|
+
export function vary(res, field) {
|
|
8
|
+
const header = res.getHeader('Vary');
|
|
9
|
+
const value = Array.isArray(header) ? header.join(', ') : String(header ?? '');
|
|
10
|
+
if (!value) {
|
|
11
|
+
res.setHeader('Vary', field);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const fields = new Set(value
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(',')
|
|
17
|
+
.map(f => f.trim()));
|
|
18
|
+
if (field === '*' || fields.has('*')) {
|
|
19
|
+
if (value !== '*')
|
|
20
|
+
res.setHeader('Vary', '*');
|
|
21
|
+
}
|
|
22
|
+
else if (!fields.has(field.toLowerCase())) {
|
|
23
|
+
res.setHeader('Vary', `${value}, ${field}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/backend-lib",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.78.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@naturalcycles/db-lib": "^10",
|
|
7
7
|
"@naturalcycles/js-lib": "^15",
|
|
@@ -10,21 +10,13 @@
|
|
|
10
10
|
"@types/cookie-parser": "^1",
|
|
11
11
|
"@types/cors": "^2",
|
|
12
12
|
"@types/express": "^5",
|
|
13
|
-
"@types/compressible": "^2",
|
|
14
|
-
"@types/on-finished": "^2",
|
|
15
|
-
"@types/on-headers": "^1",
|
|
16
|
-
"@types/vary": "^1",
|
|
17
13
|
"busboy": "^1",
|
|
18
|
-
"compressible": "^2",
|
|
19
14
|
"cookie-parser": "^1",
|
|
20
15
|
"cors": "^2",
|
|
21
16
|
"express": "^5",
|
|
22
17
|
"helmet": "^8",
|
|
23
18
|
"negotiator": "^1",
|
|
24
|
-
"
|
|
25
|
-
"on-finished": "^2",
|
|
26
|
-
"tslib": "^2",
|
|
27
|
-
"vary": "^1"
|
|
19
|
+
"tslib": "^2"
|
|
28
20
|
},
|
|
29
21
|
"peerDependencies": {
|
|
30
22
|
"@sentry/node-core": "^10",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the given Content-Type (parameters, e.g `; charset=utf-8`, are ignored) is compressible.
|
|
3
|
+
*
|
|
4
|
+
* Vendored (and modernized) from the `compressible` package.
|
|
5
|
+
* Instead of loading the whole `mime-db` (~2500 types), it's reduced to the equivalent:
|
|
6
|
+
* a regex + the list of compressible types that the regex doesn't match (as of mime-db@1.54.0).
|
|
7
|
+
* No type matched by the regex is marked as non-compressible in mime-db.
|
|
8
|
+
*/
|
|
9
|
+
export function compressible(type: string): boolean {
|
|
10
|
+
const mime = EXTRACT_TYPE_REGEX.exec(type)![1]!.toLowerCase()
|
|
11
|
+
return COMPRESSIBLE_TYPE_REGEX.test(mime) || COMPRESSIBLE_TYPES.has(mime)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EXTRACT_TYPE_REGEX = /^\s*([^;\s]*)/
|
|
15
|
+
const COMPRESSIBLE_TYPE_REGEX = /^text\/|\+(?:json|text|xml)$/
|
|
16
|
+
|
|
17
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
18
|
+
'application/dart',
|
|
19
|
+
'application/ecmascript',
|
|
20
|
+
'application/javascript',
|
|
21
|
+
'application/json',
|
|
22
|
+
'application/octet-stream',
|
|
23
|
+
'application/postscript',
|
|
24
|
+
'application/raml+yaml',
|
|
25
|
+
'application/rtf',
|
|
26
|
+
'application/tar',
|
|
27
|
+
'application/toml',
|
|
28
|
+
'application/vnd.dart',
|
|
29
|
+
'application/vnd.ms-fontobject',
|
|
30
|
+
'application/vnd.ms-opentype',
|
|
31
|
+
'application/wasm',
|
|
32
|
+
'application/x-httpd-php',
|
|
33
|
+
'application/x-javascript',
|
|
34
|
+
'application/x-ns-proxy-autoconfig',
|
|
35
|
+
'application/x-sh',
|
|
36
|
+
'application/x-tar',
|
|
37
|
+
'application/x-virtualbox-hdd',
|
|
38
|
+
'application/x-virtualbox-ova',
|
|
39
|
+
'application/x-virtualbox-ovf',
|
|
40
|
+
'application/x-virtualbox-vbox',
|
|
41
|
+
'application/x-virtualbox-vdi',
|
|
42
|
+
'application/x-virtualbox-vhd',
|
|
43
|
+
'application/x-virtualbox-vmdk',
|
|
44
|
+
'application/x-www-form-urlencoded',
|
|
45
|
+
'application/xml',
|
|
46
|
+
'application/xml-dtd',
|
|
47
|
+
'font/otf',
|
|
48
|
+
'font/ttf',
|
|
49
|
+
'image/bmp',
|
|
50
|
+
'image/vnd.adobe.photoshop',
|
|
51
|
+
'image/vnd.microsoft.icon',
|
|
52
|
+
'image/vnd.ms-dds',
|
|
53
|
+
'image/x-icon',
|
|
54
|
+
'image/x-ms-bmp',
|
|
55
|
+
'message/rfc822',
|
|
56
|
+
'model/gltf-binary',
|
|
57
|
+
'x-shader/x-fragment',
|
|
58
|
+
'x-shader/x-vertex',
|
|
59
|
+
])
|
package/src/onFinished.ts
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import { finished } from 'node:stream'
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Invokes the listener once the request/response has finished (completed, closed or errored).
|
|
7
|
+
* If it has already finished - the listener is invoked asynchronously (on the next tick).
|
|
8
|
+
*
|
|
9
|
+
* Listener runs in the async context (e.g AsyncLocalStorage) of the onFinished caller.
|
|
10
|
+
*
|
|
11
|
+
* Vendored (and modernized) from the `on-finished` package, built on top of `stream.finished`.
|
|
12
|
+
* Stream listeners are attached once per msg (subsequent calls are queued), as `stream.finished`
|
|
13
|
+
* is too expensive to call per listener (and would exceed the default MaxListeners).
|
|
14
|
+
*/
|
|
15
|
+
export function onFinished<T extends IncomingMessage | ServerResponse>(
|
|
16
|
+
msg: T,
|
|
17
|
+
listener: Listener<T>,
|
|
18
|
+
): T {
|
|
19
|
+
const m = msg as T & { [kQueue]?: Listener<T>[] }
|
|
20
|
+
const queue = m[kQueue]
|
|
21
|
+
if (queue) {
|
|
22
|
+
queue.push(bindAsyncContext(listener))
|
|
23
|
+
return msg
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const q = (m[kQueue] = [bindAsyncContext(listener)])
|
|
27
|
+
finished(msg, err => {
|
|
28
|
+
m[kQueue] = undefined
|
|
29
|
+
for (const fn of q) fn(err ?? null, msg)
|
|
30
|
+
})
|
|
31
|
+
return msg
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Listener<T> = (err: Error | null, msg: T) => void
|
|
35
|
+
|
|
36
|
+
const kQueue = Symbol('onFinished')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Faster alternative to `AsyncResource.bind` (which is ~0.8µs per call).
|
|
40
|
+
*/
|
|
41
|
+
function bindAsyncContext<T>(fn: Listener<T>): Listener<T> {
|
|
42
|
+
const resource = new AsyncResource('onFinished')
|
|
43
|
+
return (err, msg) => resource.runInAsyncScope(fn, null, err, msg)
|
|
44
|
+
}
|
package/src/onHeaders.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Invokes the listener once, right before the response writes its headers
|
|
5
|
+
* (via `res.writeHead`, which is also called implicitly by the first `write`/`end`).
|
|
6
|
+
*
|
|
7
|
+
* Headers passed to `writeHead` are applied to `res` before the listener runs,
|
|
8
|
+
* so the listener sees (and can modify) the final headers and statusCode.
|
|
9
|
+
*
|
|
10
|
+
* Vendored (and modernized) from the `on-headers` package.
|
|
11
|
+
*/
|
|
12
|
+
export function onHeaders(res: ServerResponse, listener: (this: ServerResponse) => void): void {
|
|
13
|
+
// Loosely typed, to call it with the (statusCode, reason?) signature, bypassing overloads
|
|
14
|
+
const prevWriteHead = res.writeHead as (statusCode: number, reason?: string) => ServerResponse
|
|
15
|
+
let fired = false
|
|
16
|
+
|
|
17
|
+
res.writeHead = function writeHead(
|
|
18
|
+
this: ServerResponse,
|
|
19
|
+
statusCode: number,
|
|
20
|
+
reasonOrHeaders?: string | Headers,
|
|
21
|
+
headers?: Headers,
|
|
22
|
+
) {
|
|
23
|
+
if (fired) return prevWriteHead.apply(this, arguments as any)
|
|
24
|
+
fired = true
|
|
25
|
+
|
|
26
|
+
const reason = typeof reasonOrHeaders === 'string' ? reasonOrHeaders : undefined
|
|
27
|
+
headers = reason === undefined ? (reasonOrHeaders as Headers | undefined) : headers
|
|
28
|
+
|
|
29
|
+
this.statusCode = statusCode
|
|
30
|
+
if (headers) setHeaders(this, headers)
|
|
31
|
+
listener.call(this)
|
|
32
|
+
|
|
33
|
+
// Reason phrase is dropped if the listener changed the statusCode
|
|
34
|
+
return reason !== undefined && this.statusCode === statusCode
|
|
35
|
+
? prevWriteHead.call(this, statusCode, reason)
|
|
36
|
+
: prevWriteHead.call(this, this.statusCode)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Headers = OutgoingHttpHeaders | OutgoingHttpHeader[]
|
|
41
|
+
|
|
42
|
+
function setHeaders(res: ServerResponse, headers: Headers): void {
|
|
43
|
+
if (!Array.isArray(headers)) {
|
|
44
|
+
for (const k of Object.keys(headers)) {
|
|
45
|
+
if (k) res.setHeader(k, headers[k]!)
|
|
46
|
+
}
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(headers[0])) {
|
|
51
|
+
// 2D: [[k, v], [k, v]]
|
|
52
|
+
for (const [k, v] of headers as unknown as [string, OutgoingHttpHeader][]) {
|
|
53
|
+
if (k) res.setHeader(k, v)
|
|
54
|
+
}
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1D (raw): [k, v, k, v], keys may repeat
|
|
59
|
+
if (headers.length % 2) throw new TypeError('headers array is malformed')
|
|
60
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
61
|
+
res.removeHeader(headers[i] as string)
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
64
|
+
if (headers[i]) res.appendHeader(headers[i] as string, headers[i + 1] as string | string[])
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import zlib from 'node:zlib'
|
|
14
|
-
import compressible from 'compressible'
|
|
15
14
|
// @ts-expect-error no types
|
|
16
15
|
import Negotiator from 'negotiator'
|
|
17
|
-
import
|
|
18
|
-
import
|
|
16
|
+
import { compressible } from '../compressible.js'
|
|
17
|
+
import { onHeaders } from '../onHeaders.js'
|
|
18
|
+
import { vary } from '../vary.js'
|
|
19
19
|
import type { BackendRequest, BackendRequestHandler, BackendResponse } from './server.model.js'
|
|
20
20
|
|
|
21
21
|
export interface CompressionOptions {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BackendErrorResponseObject, ErrorObject } from '@naturalcycles/js-lib/error'
|
|
2
2
|
import { _anyToError, _errorLikeToErrorObject } from '@naturalcycles/js-lib/error'
|
|
3
|
-
import type {
|
|
3
|
+
import type { StringMap } from '@naturalcycles/js-lib/types'
|
|
4
4
|
import { _objectAssign } from '@naturalcycles/js-lib/types'
|
|
5
5
|
import type { BackendErrorRequestHandler, BackendRequest, BackendResponse } from './server.model.js'
|
|
6
6
|
|
|
@@ -30,6 +30,18 @@ export interface ErrorReportingService {
|
|
|
30
30
|
const { APP_ENV } = process.env
|
|
31
31
|
const includeErrorStack = APP_ENV === 'dev'
|
|
32
32
|
|
|
33
|
+
// Headers a handler may have set for the response it never got to send;
|
|
34
|
+
// they would wrongly describe the error response (e.g `res.json` keeps a pre-set Content-Type)
|
|
35
|
+
const staleResponseHeaders = [
|
|
36
|
+
'Content-Type',
|
|
37
|
+
'Content-Disposition',
|
|
38
|
+
'Content-Encoding',
|
|
39
|
+
'Content-Language',
|
|
40
|
+
'Content-Range',
|
|
41
|
+
'ETag',
|
|
42
|
+
'Last-Modified',
|
|
43
|
+
]
|
|
44
|
+
|
|
33
45
|
// Hacky way to store the errorService, so it's available to `respondWithError` function
|
|
34
46
|
let errorService: ErrorReportingService | undefined
|
|
35
47
|
let formatError: GenericErrorMiddlewareCfg['formatError']
|
|
@@ -46,18 +58,6 @@ export function genericErrorMiddleware(
|
|
|
46
58
|
formatError = cfg.formatError
|
|
47
59
|
|
|
48
60
|
return (err, req, res, _next) => {
|
|
49
|
-
// if (res.headersSent) {
|
|
50
|
-
// Here we don't even log this error
|
|
51
|
-
// It's known that it comes from sentry.requestHandler()
|
|
52
|
-
// requestHandler waits for all promises/timeouts to finish in the request, and then emits this error here,
|
|
53
|
-
// while `res` is the same as was returned to the User (so, both headers and the data was already returned by that time)
|
|
54
|
-
// req.log.warn(`genericErrorHandler, but headersSent=true`, err)
|
|
55
|
-
// Here we don't propagate the error further, cause there's only "default express error logger" behind it
|
|
56
|
-
// and nothing else. Previously it was logging the same error once again because of this. Avoid.
|
|
57
|
-
// return next(err)
|
|
58
|
-
// return next()
|
|
59
|
-
// }
|
|
60
|
-
|
|
61
61
|
respondWithError(req, res, err)
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -85,13 +85,20 @@ export function respondWithError(req: BackendRequest, res: BackendResponse, err:
|
|
|
85
85
|
// todo: add userId from the "Context" (or, just req.userId?) to the log
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
if (headersSent)
|
|
88
|
+
if (headersSent) {
|
|
89
|
+
// Too late for an error response. A finished response needs nothing beyond the reporting above.
|
|
90
|
+
// An unfinished one (error mid-stream) is cut off, so the client sees a failed transfer right away
|
|
91
|
+
// instead of waiting for a body that never completes. Same as Express' default error handler.
|
|
92
|
+
if (!res.writableEnded) res.destroy()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
89
95
|
|
|
90
96
|
const httpError = _errorLikeToErrorObject(originalError)
|
|
91
97
|
if (!includeErrorStack) delete httpError.stack
|
|
92
98
|
|
|
93
99
|
// http-errors (express itself, its body parsers) carry the status they should be answered with
|
|
94
|
-
|
|
100
|
+
const httpErrorLike = isHttpError(originalError) ? originalError : undefined
|
|
101
|
+
httpError.data.backendResponseStatusCode ||= httpErrorLike?.statusCode || 500 // default to 500
|
|
95
102
|
_objectAssign(httpError.data, {
|
|
96
103
|
errorId,
|
|
97
104
|
headersSent: headersSent || undefined,
|
|
@@ -99,27 +106,33 @@ export function respondWithError(req: BackendRequest, res: BackendResponse, err:
|
|
|
99
106
|
|
|
100
107
|
formatError?.(httpError, req) // Mutates the error
|
|
101
108
|
|
|
109
|
+
for (const header of staleResponseHeaders) res.removeHeader(header)
|
|
110
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
111
|
+
if (httpErrorLike?.headers) res.set(httpErrorLike.headers)
|
|
112
|
+
|
|
102
113
|
res.status(httpError.data.backendResponseStatusCode).json({
|
|
103
114
|
error: httpError,
|
|
104
115
|
} satisfies BackendErrorResponseObject)
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
interface HttpErrorLike {
|
|
118
|
+
interface HttpErrorLike extends Error {
|
|
108
119
|
status: number
|
|
109
120
|
statusCode: number
|
|
110
121
|
expose: boolean
|
|
122
|
+
headers?: StringMap<string>
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
/**
|
|
114
126
|
* Errors created by the `http-errors` package (express itself, body-parser, raw-body, send)
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* (e.g a gateway's response
|
|
127
|
+
* describe the response they expect: `status` and `statusCode` (always equal),
|
|
128
|
+
* `expose` (whether the message is safe to show to the client) and optional response `headers`.
|
|
129
|
+
* The first three together are the marker: other libraries also put `statusCode` or `headers`
|
|
130
|
+
* on their errors (e.g a gateway's response), which say nothing about how we should respond.
|
|
119
131
|
*/
|
|
120
|
-
function
|
|
132
|
+
function isHttpError(err: Error): err is HttpErrorLike {
|
|
121
133
|
const { status, statusCode, expose } = err as Partial<HttpErrorLike>
|
|
122
|
-
if (typeof expose !== 'boolean' || typeof statusCode !== 'number' || status !== statusCode)
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
if (typeof expose !== 'boolean' || typeof statusCode !== 'number' || status !== statusCode) {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
return Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599
|
|
125
138
|
}
|
package/src/vary.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adds the field (header name) to the response's Vary header, unless it's already there
|
|
5
|
+
* (case-insensitive). Vary: * stays as is, and adding '*' replaces the whole header with '*'.
|
|
6
|
+
*
|
|
7
|
+
* Vendored (and modernized) from the `vary` package.
|
|
8
|
+
*/
|
|
9
|
+
export function vary(res: ServerResponse, field: string): void {
|
|
10
|
+
const header = res.getHeader('Vary')
|
|
11
|
+
const value = Array.isArray(header) ? header.join(', ') : String(header ?? '')
|
|
12
|
+
if (!value) {
|
|
13
|
+
res.setHeader('Vary', field)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const fields = new Set(
|
|
18
|
+
value
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.split(',')
|
|
21
|
+
.map(f => f.trim()),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if (field === '*' || fields.has('*')) {
|
|
25
|
+
if (value !== '*') res.setHeader('Vary', '*')
|
|
26
|
+
} else if (!fields.has(field.toLowerCase())) {
|
|
27
|
+
res.setHeader('Vary', `${value}, ${field}`)
|
|
28
|
+
}
|
|
29
|
+
}
|