@naturalcycles/backend-lib 9.70.0 → 9.71.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.
@@ -64,6 +64,7 @@ export function compressionMiddleware(options) {
64
64
  let length;
65
65
  let listeners = [];
66
66
  let stream;
67
+ let closed = false;
67
68
  const _end = res.end;
68
69
  const _on = res.on;
69
70
  const _write = res.write;
@@ -126,6 +127,17 @@ export function compressionMiddleware(options) {
126
127
  addListeners(res, _on, listeners);
127
128
  listeners = null;
128
129
  }
130
+ // Release the compression stream when the response closes, freeing native
131
+ // zlib memory even if the client disconnected before the response finished.
132
+ // Registered before onHeaders so a close that happens before the stream
133
+ // exists is not missed (the `closed` flag below handles a stream created
134
+ // afterwards). Fixes CVE-2026-87776 / GHSA-vc2v-76pw-4v95.
135
+ // We use `stream.destroy()` (not the `destroy` npm package upstream added)
136
+ // because our Node target frees the zlib handle on destroy().
137
+ _on.call(res, 'close', function onResponseClose() {
138
+ closed = true;
139
+ stream?.destroy();
140
+ });
129
141
  onHeaders(res, function onResponseHeaders() {
130
142
  // determine if request is filtered
131
143
  if (!filter(req, res)) {
@@ -184,6 +196,14 @@ export function compressionMiddleware(options) {
184
196
  else {
185
197
  stream = zlib.createDeflate(zlibOpts);
186
198
  }
199
+ // The response already closed before the stream was created, so the close
200
+ // listener above has already run. Release the stream now and drop the
201
+ // reference so later writes fall back to the raw response.
202
+ if (closed) {
203
+ stream.destroy();
204
+ stream = undefined;
205
+ return;
206
+ }
187
207
  // add buffered listeners to stream
188
208
  addListeners(stream, stream.on.bind(stream), listeners);
189
209
  // header fields
@@ -1,4 +1,7 @@
1
1
  import { inspect } from 'node:util';
2
+ import { _isPlainObject } from '@naturalcycles/js-lib';
3
+ import { _anyToErrorObject } from '@naturalcycles/js-lib/error';
4
+ import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringify.js';
2
5
  import { _objectAssign } from '@naturalcycles/js-lib/types';
3
6
  import { _inspect } from '@naturalcycles/nodejs-lib';
4
7
  import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
@@ -41,17 +44,46 @@ export const ciLogger = {
41
44
  // Documented here: https://cloud.google.com/logging/docs/structured-logging
42
45
  // Cloud Run logging: https://cloud.google.com/run/docs/logging
43
46
  function writeGCPStructuredLog(meta, args) {
44
- console.log(JSON.stringify({
45
- message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '),
46
- ...meta,
47
- }));
47
+ if (args.length !== 1 || !_isPlainObject(args[0])) {
48
+ console.log(JSON.stringify({
49
+ message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '),
50
+ ...meta,
51
+ }));
52
+ return;
53
+ }
54
+ const { msg, err, ...entry } = args[0];
55
+ if (err instanceof Error) {
56
+ entry['err'] = _anyToErrorObject(err);
57
+ entry['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err);
58
+ }
59
+ else {
60
+ if (err !== undefined)
61
+ entry['err'] = err;
62
+ if (msg !== undefined)
63
+ entry['message'] = msg;
64
+ }
65
+ console.log(_safeJsonStringify({ ...entry, ...meta }));
48
66
  }
49
67
  function logToDev(requestId, args) {
50
68
  // Run on local machine
51
- console.log([
52
- requestId ? [dimGrey(`[${requestId}]`)] : [],
53
- ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })),
54
- ].join(' '));
69
+ if (args.length !== 1 || !_isPlainObject(args[0])) {
70
+ console.log([
71
+ requestId ? [dimGrey(`[${requestId}]`)] : [],
72
+ ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })),
73
+ ].join(' '));
74
+ return;
75
+ }
76
+ const { msg, err, ...rest } = args[0];
77
+ const parts = [];
78
+ if (requestId)
79
+ parts.push(dimGrey(`[${requestId}]`));
80
+ if (msg !== undefined)
81
+ parts.push(String(msg));
82
+ if (err !== undefined)
83
+ parts.push(_inspect(err, { includeErrorStack: true, colors: true }));
84
+ if (Object.keys(rest).length)
85
+ parts.push(dimGrey(_inspect(rest, { colors: false })));
86
+ console.log(parts.join(' '));
55
87
  }
56
88
  /**
57
89
  * Same as logToDev, but without request and without colors.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.70.0",
4
+ "version": "9.71.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -113,6 +113,7 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
113
113
  let length: number | undefined
114
114
  let listeners: [string, (...args: any[]) => void][] | null = []
115
115
  let stream: zlib.Gzip | zlib.Deflate | zlib.BrotliCompress | zlib.ZstdCompress | undefined
116
+ let closed = false
116
117
 
117
118
  const _end = res.end
118
119
  const _on = res.on
@@ -199,6 +200,18 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
199
200
  listeners = null
200
201
  }
201
202
 
203
+ // Release the compression stream when the response closes, freeing native
204
+ // zlib memory even if the client disconnected before the response finished.
205
+ // Registered before onHeaders so a close that happens before the stream
206
+ // exists is not missed (the `closed` flag below handles a stream created
207
+ // afterwards). Fixes CVE-2026-87776 / GHSA-vc2v-76pw-4v95.
208
+ // We use `stream.destroy()` (not the `destroy` npm package upstream added)
209
+ // because our Node target frees the zlib handle on destroy().
210
+ _on.call(res, 'close', function onResponseClose() {
211
+ closed = true
212
+ stream?.destroy()
213
+ })
214
+
202
215
  onHeaders(res, function onResponseHeaders() {
203
216
  // determine if request is filtered
204
217
  if (!filter(req, res)) {
@@ -268,6 +281,15 @@ export function compressionMiddleware(options?: CompressionOptions): BackendRequ
268
281
  stream = zlib.createDeflate(zlibOpts)
269
282
  }
270
283
 
284
+ // The response already closed before the stream was created, so the close
285
+ // listener above has already run. Release the stream now and drop the
286
+ // reference so later writes fall back to the raw response.
287
+ if (closed) {
288
+ stream.destroy()
289
+ stream = undefined
290
+ return
291
+ }
292
+
271
293
  // add buffered listeners to stream
272
294
  addListeners(stream, stream.on.bind(stream), listeners!)
273
295
 
@@ -1,8 +1,4 @@
1
- import type {
2
- EventLoopMonitorOptions,
3
- EventLoopUtilization,
4
- IntervalHistogram,
5
- } from 'node:perf_hooks'
1
+ import type { ELDHistogram, EventLoopMonitorOptions, EventLoopUtilization } from 'node:perf_hooks'
6
2
  import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks'
7
3
  import type {
8
4
  Integer,
@@ -45,7 +41,7 @@ export class EventLoopMonitor {
45
41
  constructor(cfg: EventLoopMonitorCfg = {}) {
46
42
  const { measureInterval = 60_000 } = cfg
47
43
 
48
- const eldOptions: EventLoopMonitorOptions2 = {
44
+ const eldOptions: EventLoopMonitorOptions = {
49
45
  // Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
50
46
  // which measures actual per-iteration delay instead of timer jitter.
51
47
  // More accurate (no `resolution` offset baked into every percentile) and cheaper.
@@ -103,7 +99,7 @@ export class EventLoopMonitor {
103
99
  }
104
100
 
105
101
  private interval: NodeJS.Timeout
106
- private eld: IntervalHistogram
102
+ private eld: ELDHistogram
107
103
  private lastElu: EventLoopUtilization
108
104
  /**
109
105
  * Undefined until the first interval has completed.
@@ -167,11 +163,3 @@ export interface EventLoopStats {
167
163
  */
168
164
  gcCPU: Integer
169
165
  }
170
-
171
- /**
172
- * Extends the Node types with `samplePerIteration` (added in Node 24.19 / 26.5).
173
- * Remove once @types/node declares it.
174
- */
175
- interface EventLoopMonitorOptions2 extends EventLoopMonitorOptions {
176
- samplePerIteration?: boolean
177
- }
@@ -1,5 +1,8 @@
1
1
  import { inspect } from 'node:util'
2
+ import { _isPlainObject } from '@naturalcycles/js-lib'
3
+ import { _anyToErrorObject } from '@naturalcycles/js-lib/error'
2
4
  import type { CommonLogger } from '@naturalcycles/js-lib/log'
5
+ import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringify.js'
3
6
  import { _objectAssign } from '@naturalcycles/js-lib/types'
4
7
  import type { AnyObject } from '@naturalcycles/js-lib/types'
5
8
  import { _inspect } from '@naturalcycles/nodejs-lib'
@@ -50,22 +53,46 @@ export const ciLogger: CommonLogger = {
50
53
  // Documented here: https://cloud.google.com/logging/docs/structured-logging
51
54
  // Cloud Run logging: https://cloud.google.com/run/docs/logging
52
55
  function writeGCPStructuredLog(meta: AnyObject, args: any[]): void {
53
- console.log(
54
- JSON.stringify({
55
- message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '),
56
- ...meta,
57
- }),
58
- )
56
+ if (args.length !== 1 || !_isPlainObject(args[0])) {
57
+ console.log(
58
+ JSON.stringify({
59
+ message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '),
60
+ ...meta,
61
+ }),
62
+ )
63
+ return
64
+ }
65
+
66
+ const { msg, err, ...entry } = args[0]
67
+ if (err instanceof Error) {
68
+ entry['err'] = _anyToErrorObject(err)
69
+ entry['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err)
70
+ } else {
71
+ if (err !== undefined) entry['err'] = err
72
+ if (msg !== undefined) entry['message'] = msg
73
+ }
74
+ console.log(_safeJsonStringify({ ...entry, ...meta }))
59
75
  }
60
76
 
61
77
  function logToDev(requestId: string | null, args: any[]): void {
62
78
  // Run on local machine
63
- console.log(
64
- [
65
- requestId ? [dimGrey(`[${requestId}]`)] : [],
66
- ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })),
67
- ].join(' '),
68
- )
79
+ if (args.length !== 1 || !_isPlainObject(args[0])) {
80
+ console.log(
81
+ [
82
+ requestId ? [dimGrey(`[${requestId}]`)] : [],
83
+ ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })),
84
+ ].join(' '),
85
+ )
86
+ return
87
+ }
88
+
89
+ const { msg, err, ...rest } = args[0]
90
+ const parts: string[] = []
91
+ if (requestId) parts.push(dimGrey(`[${requestId}]`))
92
+ if (msg !== undefined) parts.push(String(msg))
93
+ if (err !== undefined) parts.push(_inspect(err, { includeErrorStack: true, colors: true }))
94
+ if (Object.keys(rest).length) parts.push(dimGrey(_inspect(rest, { colors: false })))
95
+ console.log(parts.join(' '))
69
96
  }
70
97
 
71
98
  /**