@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 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
- httpError.data.backendResponseStatusCode ||= 500; // default to 500
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 traceHeader = req.header('x-cloud-trace-context');
79
- if (traceHeader) {
80
- const [trace] = traceHeader.split('/');
81
- meta['logging.googleapis.com/trace'] = `projects/${GOOGLE_CLOUD_PROJECT}/traces/${trace}`;
82
- req.requestId = trace;
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.replace(UNSAFE_PATH_CHAR, '_');
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
- name: info.filename,
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.76.0",
4
+ "version": "9.77.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
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
- httpError.data.backendResponseStatusCode ||= 500 // default to 500
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 traceHeader = req.header('x-cloud-trace-context')
92
- if (traceHeader) {
93
- const [trace] = traceHeader.split('/')
94
- meta['logging.googleapis.com/trace'] = `projects/${GOOGLE_CLOUD_PROJECT}/traces/${trace}`
95
- req.requestId = trace
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.replace(UNSAFE_PATH_CHAR, '_')
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
- name: info.filename,
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,