@naturalcycles/backend-lib 9.76.0 → 9.77.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/genericErrorMiddleware.js +17 -1
- package/dist/server/logMiddleware.js +6 -5
- package/dist/server/request.util.js +1 -1
- package/dist/server/trace.util.d.ts +16 -0
- package/dist/server/trace.util.js +35 -0
- package/dist/upload/getUploadedFiles.js +3 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/server/genericErrorMiddleware.ts +23 -1
- package/src/server/logMiddleware.ts +6 -5
- package/src/server/request.util.ts +1 -1
- package/src/server/trace.util.ts +54 -0
- package/src/upload/getUploadedFiles.ts +3 -1
package/dist/index.d.ts
CHANGED
|
@@ -18,4 +18,5 @@ export type * from './server/server.model.js';
|
|
|
18
18
|
export * from './server/serverStatsMiddleware.js';
|
|
19
19
|
export * from './server/serverStatusMiddleware.js';
|
|
20
20
|
export * from './server/simpleRequestLoggerMiddleware.js';
|
|
21
|
+
export * from './server/trace.util.js';
|
|
21
22
|
export * from './util.js';
|
package/dist/index.js
CHANGED
|
@@ -17,4 +17,5 @@ export * from './server/safeJsonMiddleware.js';
|
|
|
17
17
|
export * from './server/serverStatsMiddleware.js';
|
|
18
18
|
export * from './server/serverStatusMiddleware.js';
|
|
19
19
|
export * from './server/simpleRequestLoggerMiddleware.js';
|
|
20
|
+
export * from './server/trace.util.js';
|
|
20
21
|
export * from './util.js';
|
|
@@ -53,7 +53,8 @@ export function respondWithError(req, res, err) {
|
|
|
53
53
|
const httpError = _errorLikeToErrorObject(originalError);
|
|
54
54
|
if (!includeErrorStack)
|
|
55
55
|
delete httpError.stack;
|
|
56
|
-
|
|
56
|
+
// http-errors (express itself, its body parsers) carry the status they should be answered with
|
|
57
|
+
httpError.data.backendResponseStatusCode ||= getHttpErrorStatusCode(originalError) || 500; // default to 500
|
|
57
58
|
_objectAssign(httpError.data, {
|
|
58
59
|
errorId,
|
|
59
60
|
headersSent: headersSent || undefined,
|
|
@@ -63,3 +64,18 @@ export function respondWithError(req, res, err) {
|
|
|
63
64
|
error: httpError,
|
|
64
65
|
});
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Errors created by the `http-errors` package (express itself, body-parser, raw-body, send)
|
|
69
|
+
* carry the HTTP status they are meant to be answered with: `status` and `statusCode` (always equal),
|
|
70
|
+
* plus `expose` (whether the message is safe to show to the client).
|
|
71
|
+
* All three together are the marker: other libraries also put a `statusCode` on their errors
|
|
72
|
+
* (e.g a gateway's response status), which says nothing about how we should respond.
|
|
73
|
+
*/
|
|
74
|
+
function getHttpErrorStatusCode(err) {
|
|
75
|
+
const { status, statusCode, expose } = err;
|
|
76
|
+
if (typeof expose !== 'boolean' || typeof statusCode !== 'number' || status !== statusCode)
|
|
77
|
+
return;
|
|
78
|
+
if (!Number.isInteger(statusCode) || statusCode < 400 || statusCode > 599)
|
|
79
|
+
return;
|
|
80
|
+
return statusCode;
|
|
81
|
+
}
|
|
@@ -5,6 +5,7 @@ import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringi
|
|
|
5
5
|
import { _objectAssign } from '@naturalcycles/js-lib/types';
|
|
6
6
|
import { logEntryToString } from '@naturalcycles/nodejs-lib';
|
|
7
7
|
import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
|
|
8
|
+
import { getRequestTraceId } from './trace.util.js';
|
|
8
9
|
const { GOOGLE_CLOUD_PROJECT, GAE_INSTANCE, K_SERVICE, APP_ENV } = process.env;
|
|
9
10
|
const isGAE = !!GAE_INSTANCE;
|
|
10
11
|
const isCloudRun = !!K_SERVICE;
|
|
@@ -75,11 +76,11 @@ export function logMiddleware() {
|
|
|
75
76
|
// gcloud run deploy my-service \
|
|
76
77
|
// --update-env-vars=GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project)
|
|
77
78
|
if (GOOGLE_CLOUD_PROJECT) {
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
req.requestId =
|
|
79
|
+
const traceId = getRequestTraceId(req);
|
|
80
|
+
if (traceId) {
|
|
81
|
+
meta['logging.googleapis.com/trace'] =
|
|
82
|
+
`projects/${GOOGLE_CLOUD_PROJECT}/traces/${traceId}`;
|
|
83
|
+
req.requestId = traceId;
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
if (isGAE) {
|
|
@@ -43,6 +43,6 @@ function buildRequestEndpoint(req, stripPrefix, sanitize) {
|
|
|
43
43
|
// the exact bytes are not worth preserving here, `req.url` still has them.
|
|
44
44
|
const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu;
|
|
45
45
|
function sanitizePath(path) {
|
|
46
|
-
return path.
|
|
46
|
+
return path.replaceAll(UNSAFE_PATH_CHAR, '_');
|
|
47
47
|
}
|
|
48
48
|
const identity = (path) => path;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BackendRequest } from './server.model.js';
|
|
2
|
+
/**
|
|
3
|
+
* The trace a request belongs to, as carried by the W3C `traceparent` header.
|
|
4
|
+
*/
|
|
5
|
+
export interface TraceContext {
|
|
6
|
+
traceId: string;
|
|
7
|
+
spanId: string;
|
|
8
|
+
sampled: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The trace id of the request, from `traceparent`. Cached per request, so it is cheap to call on
|
|
12
|
+
* every log line.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getRequestTraceId(req: BackendRequest): string | undefined;
|
|
15
|
+
export declare function getRequestTraceContext(req: BackendRequest): TraceContext | undefined;
|
|
16
|
+
export declare function parseTraceparent(header: string | undefined): TraceContext | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// https://www.w3.org/TR/trace-context/#traceparent-header
|
|
2
|
+
// Version `ff` is invalid. Higher versions may append `-` fields, which are ignored (forward
|
|
3
|
+
// compatibility), while version `00` must not have any.
|
|
4
|
+
const TRACEPARENT_REGEX = /^(?!ff)([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/;
|
|
5
|
+
const INVALID_TRACE_ID = '0'.repeat(32);
|
|
6
|
+
const INVALID_SPAN_ID = '0'.repeat(16);
|
|
7
|
+
const traceIdByRequest = new WeakMap();
|
|
8
|
+
/**
|
|
9
|
+
* The trace id of the request, from `traceparent`. Cached per request, so it is cheap to call on
|
|
10
|
+
* every log line.
|
|
11
|
+
*/
|
|
12
|
+
export function getRequestTraceId(req) {
|
|
13
|
+
let traceId = traceIdByRequest.get(req);
|
|
14
|
+
if (traceId === undefined) {
|
|
15
|
+
traceId = getRequestTraceContext(req)?.traceId ?? null;
|
|
16
|
+
traceIdByRequest.set(req, traceId);
|
|
17
|
+
}
|
|
18
|
+
return traceId ?? undefined;
|
|
19
|
+
}
|
|
20
|
+
export function getRequestTraceContext(req) {
|
|
21
|
+
// Reads from `req.headers` and not `req.header()`, so a bare request object (as in tests) works too
|
|
22
|
+
const header = req.headers?.['traceparent'];
|
|
23
|
+
return parseTraceparent(typeof header === 'string' ? header : undefined);
|
|
24
|
+
}
|
|
25
|
+
export function parseTraceparent(header) {
|
|
26
|
+
const match = header ? TRACEPARENT_REGEX.exec(header) : null;
|
|
27
|
+
if (!match)
|
|
28
|
+
return;
|
|
29
|
+
const [, version, traceId, spanId, flags, extension] = match;
|
|
30
|
+
if (version === '00' && extension !== undefined)
|
|
31
|
+
return;
|
|
32
|
+
if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID)
|
|
33
|
+
return;
|
|
34
|
+
return { traceId, spanId, sampled: Number.parseInt(flags, 16) % 2 === 1 };
|
|
35
|
+
}
|
|
@@ -75,7 +75,9 @@ async function parseMultipart(req, maxFileSize) {
|
|
|
75
75
|
stream.on('close', () => {
|
|
76
76
|
const data = Buffer.concat(chunks);
|
|
77
77
|
files[name] = {
|
|
78
|
-
|
|
78
|
+
// busboy reports `filename` as undefined for a bare `application/octet-stream` part with
|
|
79
|
+
// no filename; normalize to `""`, same as a blank file input (`filename=""`) yields.
|
|
80
|
+
name: info.filename ?? '',
|
|
79
81
|
mimeType: info.mimeType,
|
|
80
82
|
encoding: info.encoding,
|
|
81
83
|
data,
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -18,4 +18,5 @@ export type * from './server/server.model.js'
|
|
|
18
18
|
export * from './server/serverStatsMiddleware.js'
|
|
19
19
|
export * from './server/serverStatusMiddleware.js'
|
|
20
20
|
export * from './server/simpleRequestLoggerMiddleware.js'
|
|
21
|
+
export * from './server/trace.util.js'
|
|
21
22
|
export * from './util.js'
|
|
@@ -1,5 +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 { HttpStatusCode } from '@naturalcycles/js-lib/http'
|
|
3
4
|
import { _objectAssign } from '@naturalcycles/js-lib/types'
|
|
4
5
|
import type { BackendErrorRequestHandler, BackendRequest, BackendResponse } from './server.model.js'
|
|
5
6
|
|
|
@@ -89,7 +90,8 @@ export function respondWithError(req: BackendRequest, res: BackendResponse, err:
|
|
|
89
90
|
const httpError = _errorLikeToErrorObject(originalError)
|
|
90
91
|
if (!includeErrorStack) delete httpError.stack
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
// http-errors (express itself, its body parsers) carry the status they should be answered with
|
|
94
|
+
httpError.data.backendResponseStatusCode ||= getHttpErrorStatusCode(originalError) || 500 // default to 500
|
|
93
95
|
_objectAssign(httpError.data, {
|
|
94
96
|
errorId,
|
|
95
97
|
headersSent: headersSent || undefined,
|
|
@@ -101,3 +103,23 @@ export function respondWithError(req: BackendRequest, res: BackendResponse, err:
|
|
|
101
103
|
error: httpError,
|
|
102
104
|
} satisfies BackendErrorResponseObject)
|
|
103
105
|
}
|
|
106
|
+
|
|
107
|
+
interface HttpErrorLike {
|
|
108
|
+
status: number
|
|
109
|
+
statusCode: number
|
|
110
|
+
expose: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Errors created by the `http-errors` package (express itself, body-parser, raw-body, send)
|
|
115
|
+
* carry the HTTP status they are meant to be answered with: `status` and `statusCode` (always equal),
|
|
116
|
+
* plus `expose` (whether the message is safe to show to the client).
|
|
117
|
+
* All three together are the marker: other libraries also put a `statusCode` on their errors
|
|
118
|
+
* (e.g a gateway's response status), which says nothing about how we should respond.
|
|
119
|
+
*/
|
|
120
|
+
function getHttpErrorStatusCode(err: Error): HttpStatusCode | undefined {
|
|
121
|
+
const { status, statusCode, expose } = err as Partial<HttpErrorLike>
|
|
122
|
+
if (typeof expose !== 'boolean' || typeof statusCode !== 'number' || status !== statusCode) return
|
|
123
|
+
if (!Number.isInteger(statusCode) || statusCode < 400 || statusCode > 599) return
|
|
124
|
+
return statusCode
|
|
125
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { AnyObject } from '@naturalcycles/js-lib/types'
|
|
|
8
8
|
import { logEntryToString } from '@naturalcycles/nodejs-lib'
|
|
9
9
|
import { dimGrey } from '@naturalcycles/nodejs-lib/colors'
|
|
10
10
|
import type { BackendRequestHandler } from './server.model.js'
|
|
11
|
+
import { getRequestTraceId } from './trace.util.js'
|
|
11
12
|
|
|
12
13
|
const { GOOGLE_CLOUD_PROJECT, GAE_INSTANCE, K_SERVICE, APP_ENV } = process.env
|
|
13
14
|
const isGAE = !!GAE_INSTANCE
|
|
@@ -88,11 +89,11 @@ export function logMiddleware(): BackendRequestHandler {
|
|
|
88
89
|
// gcloud run deploy my-service \
|
|
89
90
|
// --update-env-vars=GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project)
|
|
90
91
|
if (GOOGLE_CLOUD_PROJECT) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
req.requestId =
|
|
92
|
+
const traceId = getRequestTraceId(req)
|
|
93
|
+
if (traceId) {
|
|
94
|
+
meta['logging.googleapis.com/trace'] =
|
|
95
|
+
`projects/${GOOGLE_CLOUD_PROJECT}/traces/${traceId}`
|
|
96
|
+
req.requestId = traceId
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
if (isGAE) {
|
|
@@ -55,7 +55,7 @@ function buildRequestEndpoint(
|
|
|
55
55
|
const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu
|
|
56
56
|
|
|
57
57
|
function sanitizePath(path: string): string {
|
|
58
|
-
return path.
|
|
58
|
+
return path.replaceAll(UNSAFE_PATH_CHAR, '_')
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const identity = (path: string): string => path
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BackendRequest } from './server.model.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The trace a request belongs to, as carried by the W3C `traceparent` header.
|
|
5
|
+
*/
|
|
6
|
+
export interface TraceContext {
|
|
7
|
+
traceId: string
|
|
8
|
+
spanId: string
|
|
9
|
+
sampled: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// https://www.w3.org/TR/trace-context/#traceparent-header
|
|
13
|
+
// Version `ff` is invalid. Higher versions may append `-` fields, which are ignored (forward
|
|
14
|
+
// compatibility), while version `00` must not have any.
|
|
15
|
+
const TRACEPARENT_REGEX = /^(?!ff)([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/
|
|
16
|
+
const INVALID_TRACE_ID = '0'.repeat(32)
|
|
17
|
+
const INVALID_SPAN_ID = '0'.repeat(16)
|
|
18
|
+
|
|
19
|
+
const traceIdByRequest = new WeakMap<BackendRequest, string | null>()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The trace id of the request, from `traceparent`. Cached per request, so it is cheap to call on
|
|
23
|
+
* every log line.
|
|
24
|
+
*/
|
|
25
|
+
export function getRequestTraceId(req: BackendRequest): string | undefined {
|
|
26
|
+
let traceId = traceIdByRequest.get(req)
|
|
27
|
+
if (traceId === undefined) {
|
|
28
|
+
traceId = getRequestTraceContext(req)?.traceId ?? null
|
|
29
|
+
traceIdByRequest.set(req, traceId)
|
|
30
|
+
}
|
|
31
|
+
return traceId ?? undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getRequestTraceContext(req: BackendRequest): TraceContext | undefined {
|
|
35
|
+
// Reads from `req.headers` and not `req.header()`, so a bare request object (as in tests) works too
|
|
36
|
+
const header = req.headers?.['traceparent']
|
|
37
|
+
return parseTraceparent(typeof header === 'string' ? header : undefined)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseTraceparent(header: string | undefined): TraceContext | undefined {
|
|
41
|
+
const match = header ? TRACEPARENT_REGEX.exec(header) : null
|
|
42
|
+
if (!match) return
|
|
43
|
+
const [, version, traceId, spanId, flags, extension] = match as unknown as [
|
|
44
|
+
string,
|
|
45
|
+
string,
|
|
46
|
+
string,
|
|
47
|
+
string,
|
|
48
|
+
string,
|
|
49
|
+
string?,
|
|
50
|
+
]
|
|
51
|
+
if (version === '00' && extension !== undefined) return
|
|
52
|
+
if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID) return
|
|
53
|
+
return { traceId, spanId, sampled: Number.parseInt(flags, 16) % 2 === 1 }
|
|
54
|
+
}
|
|
@@ -102,7 +102,9 @@ async function parseMultipart(
|
|
|
102
102
|
stream.on('close', () => {
|
|
103
103
|
const data = Buffer.concat(chunks)
|
|
104
104
|
files[name] = {
|
|
105
|
-
|
|
105
|
+
// busboy reports `filename` as undefined for a bare `application/octet-stream` part with
|
|
106
|
+
// no filename; normalize to `""`, same as a blank file input (`filename=""`) yields.
|
|
107
|
+
name: info.filename ?? '',
|
|
106
108
|
mimeType: info.mimeType,
|
|
107
109
|
encoding: info.encoding,
|
|
108
110
|
data,
|