@naturalcycles/backend-lib 9.63.1 → 9.65.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.
@@ -5,6 +5,11 @@ import type { Integer, NonNegativeInteger, NumberOfMilliseconds, NumberOfPercent
5
5
  * Also, monitors GC performance.
6
6
  * Once per `measureInterval` sends a callback with stats.
7
7
  *
8
+ * On Node >=24.19 (or >=26.5) delays are sampled once per event loop iteration
9
+ * (`samplePerIteration`), so stats reflect true per-iteration delay:
10
+ * expect p50/mean near 0 on a healthy service, instead of the ~`resolution` baseline
11
+ * reported by older interval-based sampling.
12
+ *
8
13
  * @experimental
9
14
  */
10
15
  export declare class EventLoopMonitor {
@@ -22,10 +27,6 @@ export declare class EventLoopMonitor {
22
27
  stop(): void;
23
28
  }
24
29
  export interface EventLoopMonitorCfg {
25
- /**
26
- * Defaults to 20.
27
- */
28
- resolution?: NumberOfMilliseconds;
29
30
  /**
30
31
  * Defaults to 60_000 ms
31
32
  */
@@ -21,12 +21,24 @@ import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:pe
21
21
  * Also, monitors GC performance.
22
22
  * Once per `measureInterval` sends a callback with stats.
23
23
  *
24
+ * On Node >=24.19 (or >=26.5) delays are sampled once per event loop iteration
25
+ * (`samplePerIteration`), so stats reflect true per-iteration delay:
26
+ * expect p50/mean near 0 on a healthy service, instead of the ~`resolution` baseline
27
+ * reported by older interval-based sampling.
28
+ *
24
29
  * @experimental
25
30
  */
26
31
  export class EventLoopMonitor {
27
32
  constructor(cfg = {}) {
28
- const { resolution = 20, measureInterval = 60_000 } = cfg;
29
- this.eld = monitorEventLoopDelay({ resolution });
33
+ const { measureInterval = 60_000 } = cfg;
34
+ const eldOptions = {
35
+ // Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
36
+ // which measures actual per-iteration delay instead of timer jitter.
37
+ // More accurate (no `resolution` offset baked into every percentile) and cheaper.
38
+ // Older Node silently ignores this option and falls back to interval-based sampling.
39
+ samplePerIteration: true,
40
+ };
41
+ this.eld = monitorEventLoopDelay(eldOptions);
30
42
  this.eld.enable();
31
43
  this.lastElu = performance.eventLoopUtilization();
32
44
  this.po = new PerformanceObserver(list => {
@@ -0,0 +1,43 @@
1
+ import type { NumberOfBytes } from '@naturalcycles/js-lib/types';
2
+ import type { BackendRequest } from '../server/server.model.js';
3
+ /**
4
+ * Parses a `multipart/form-data` request body (on top of `busboy`), populates `req.body` with the
5
+ * form fields, and returns the requested files keyed by their form field name:
6
+ *
7
+ * const { csv } = await getUploadedFiles(req, { files: ['csv'] })
8
+ * const { csv, avatar } = await getUploadedFiles(req, {
9
+ * files: ['csv'], // required -> UploadedFile
10
+ * optionalFiles: ['avatar'], // optional -> UploadedFile | undefined
11
+ * })
12
+ *
13
+ * `files` are required: each is asserted to be present and non-empty (throws 400 otherwise).
14
+ * `optionalFiles` are returned as `UploadedFile | undefined` — `undefined` when the field is
15
+ * absent or empty (e.g. a blank file input), so "optional" is genuinely optional.
16
+ *
17
+ * Throws 413 as soon as ANY uploaded file exceeds `maxFileSize` bytes (default: 10 MB), aborting
18
+ * the parse immediately without buffering the rest of the body.
19
+ *
20
+ * Meant to be called from inside a request handler, AFTER authentication — so an
21
+ * unauthenticated/unauthorized request is rejected before its (potentially large) multipart body
22
+ * is buffered into memory.
23
+ */
24
+ export declare function getUploadedFiles<const FILENAMES extends string = never, const OPTIONAL_FILENAMES extends string = never>(req: BackendRequest, opt: {
25
+ files?: readonly FILENAMES[];
26
+ optionalFiles?: readonly OPTIONAL_FILENAMES[];
27
+ maxFileSize?: NumberOfBytes;
28
+ }): Promise<Record<FILENAMES, UploadedFile> & Record<OPTIONAL_FILENAMES, UploadedFile | undefined>>;
29
+ /**
30
+ * A single uploaded file, buffered in memory.
31
+ */
32
+ export interface UploadedFile {
33
+ /** Original filename from the client. */
34
+ name: string;
35
+ /** Content-Type of the file part. */
36
+ mimeType: string;
37
+ /** Content-Transfer-Encoding of the file part. */
38
+ encoding: string;
39
+ /** File contents. */
40
+ data: Buffer;
41
+ /** Size of `data` in bytes. */
42
+ size: NumberOfBytes;
43
+ }
@@ -0,0 +1,94 @@
1
+ import { _assert, AppError } from '@naturalcycles/js-lib/error';
2
+ import busboy from 'busboy';
3
+ /**
4
+ * Parses a `multipart/form-data` request body (on top of `busboy`), populates `req.body` with the
5
+ * form fields, and returns the requested files keyed by their form field name:
6
+ *
7
+ * const { csv } = await getUploadedFiles(req, { files: ['csv'] })
8
+ * const { csv, avatar } = await getUploadedFiles(req, {
9
+ * files: ['csv'], // required -> UploadedFile
10
+ * optionalFiles: ['avatar'], // optional -> UploadedFile | undefined
11
+ * })
12
+ *
13
+ * `files` are required: each is asserted to be present and non-empty (throws 400 otherwise).
14
+ * `optionalFiles` are returned as `UploadedFile | undefined` — `undefined` when the field is
15
+ * absent or empty (e.g. a blank file input), so "optional" is genuinely optional.
16
+ *
17
+ * Throws 413 as soon as ANY uploaded file exceeds `maxFileSize` bytes (default: 10 MB), aborting
18
+ * the parse immediately without buffering the rest of the body.
19
+ *
20
+ * Meant to be called from inside a request handler, AFTER authentication — so an
21
+ * unauthenticated/unauthorized request is rejected before its (potentially large) multipart body
22
+ * is buffered into memory.
23
+ */
24
+ export async function getUploadedFiles(req, opt) {
25
+ const { files: requiredNames = [], optionalFiles = [], maxFileSize = 10 * MB } = opt;
26
+ const uploadedFiles = await parseMultipart(req, maxFileSize);
27
+ const result = {};
28
+ for (const name of requiredNames) {
29
+ const file = uploadedFiles[name];
30
+ _assert(file, `Uploaded file "${name}" is missing`, { backendResponseStatusCode: 400 });
31
+ _assert(file.size > 0, `Uploaded file "${name}" is empty`, { backendResponseStatusCode: 400 });
32
+ result[name] = file;
33
+ }
34
+ for (const name of optionalFiles) {
35
+ const file = uploadedFiles[name];
36
+ // Absent or empty (e.g. a blank file input) is treated as "not provided".
37
+ result[name] = file && file.size > 0 ? file : undefined;
38
+ }
39
+ return result;
40
+ }
41
+ /**
42
+ * Streams the request through busboy, buffering each file into memory and collecting the form
43
+ * fields onto `req.body`. Resolves with the uploaded files keyed by their form field name.
44
+ *
45
+ * Rejects (aborting the parse) the moment a file exceeds `maxFileSize`.
46
+ */
47
+ async function parseMultipart(req, maxFileSize) {
48
+ return new Promise((resolve, reject) => {
49
+ const files = {};
50
+ const body = {};
51
+ let bb;
52
+ try {
53
+ bb = busboy({ headers: req.headers, limits: { fileSize: maxFileSize } });
54
+ }
55
+ catch {
56
+ // Not a multipart request (or malformed Content-Type) — no files to parse
57
+ resolve(files);
58
+ return;
59
+ }
60
+ bb.on('field', (name, value) => {
61
+ body[name] = value;
62
+ });
63
+ bb.on('file', (name, stream, info) => {
64
+ const chunks = [];
65
+ stream.on('data', (chunk) => chunks.push(chunk));
66
+ // Abort as soon as the size limit is hit: stop feeding busboy and reject right away,
67
+ // rather than buffering the whole over-limit body and reporting it at the end.
68
+ stream.on('limit', () => {
69
+ req.unpipe(bb);
70
+ req.resume(); // drain the remainder so the connection can close cleanly
71
+ reject(new AppError(`Uploaded file "${name}" exceeds the size limit of ${maxFileSize} bytes`, {
72
+ backendResponseStatusCode: 413,
73
+ }));
74
+ });
75
+ stream.on('close', () => {
76
+ const data = Buffer.concat(chunks);
77
+ files[name] = {
78
+ name: info.filename,
79
+ mimeType: info.mimeType,
80
+ encoding: info.encoding,
81
+ data,
82
+ size: data.length,
83
+ };
84
+ });
85
+ });
86
+ bb.on('error', reject);
87
+ bb.on('close', () => {
88
+ req.body = body;
89
+ resolve(files);
90
+ });
91
+ req.pipe(bb);
92
+ });
93
+ }
94
+ const MB = 1024 * 1024;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.63.1",
4
+ "version": "9.65.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -14,6 +14,7 @@
14
14
  "@types/on-finished": "^2",
15
15
  "@types/on-headers": "^1",
16
16
  "@types/vary": "^1",
17
+ "busboy": "^1",
17
18
  "compressible": "^2",
18
19
  "cookie-parser": "^1",
19
20
  "cors": "^2",
@@ -43,7 +44,8 @@
43
44
  },
44
45
  "devDependencies": {
45
46
  "@sentry/node-core": "^10",
46
- "typescript": "rc",
47
+ "@types/busboy": "^1",
48
+ "typescript": "^7",
47
49
  "fastify": "^5",
48
50
  "@naturalcycles/dev-lib": "0.0.0"
49
51
  },
@@ -55,6 +57,7 @@
55
57
  "./deploy": "./dist/deploy/index.js",
56
58
  "./deploy/*.js": "./dist/deploy/*.js",
57
59
  "./express/*.js": "./dist/express/*.js",
60
+ "./upload": "./dist/upload/getUploadedFiles.js",
58
61
  "./validateRequest": "./dist/validation/ajv/validateRequest.js",
59
62
  "./onFinished": "./dist/onFinished.js",
60
63
  "./testing": "./dist/testing/index.js"
@@ -1,4 +1,8 @@
1
- import type { EventLoopUtilization, IntervalHistogram } from 'node:perf_hooks'
1
+ import type {
2
+ EventLoopMonitorOptions,
3
+ EventLoopUtilization,
4
+ IntervalHistogram,
5
+ } from 'node:perf_hooks'
2
6
  import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks'
3
7
  import type {
4
8
  Integer,
@@ -30,13 +34,25 @@ import type {
30
34
  * Also, monitors GC performance.
31
35
  * Once per `measureInterval` sends a callback with stats.
32
36
  *
37
+ * On Node >=24.19 (or >=26.5) delays are sampled once per event loop iteration
38
+ * (`samplePerIteration`), so stats reflect true per-iteration delay:
39
+ * expect p50/mean near 0 on a healthy service, instead of the ~`resolution` baseline
40
+ * reported by older interval-based sampling.
41
+ *
33
42
  * @experimental
34
43
  */
35
44
  export class EventLoopMonitor {
36
45
  constructor(cfg: EventLoopMonitorCfg = {}) {
37
- const { resolution = 20, measureInterval = 60_000 } = cfg
38
-
39
- this.eld = monitorEventLoopDelay({ resolution })
46
+ const { measureInterval = 60_000 } = cfg
47
+
48
+ const eldOptions: EventLoopMonitorOptions2 = {
49
+ // Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
50
+ // which measures actual per-iteration delay instead of timer jitter.
51
+ // More accurate (no `resolution` offset baked into every percentile) and cheaper.
52
+ // Older Node silently ignores this option and falls back to interval-based sampling.
53
+ samplePerIteration: true,
54
+ }
55
+ this.eld = monitorEventLoopDelay(eldOptions)
40
56
  this.eld.enable()
41
57
 
42
58
  this.lastElu = performance.eventLoopUtilization()
@@ -107,11 +123,6 @@ export class EventLoopMonitor {
107
123
  }
108
124
 
109
125
  export interface EventLoopMonitorCfg {
110
- /**
111
- * Defaults to 20.
112
- */
113
- resolution?: NumberOfMilliseconds
114
-
115
126
  /**
116
127
  * Defaults to 60_000 ms
117
128
  */
@@ -156,3 +167,11 @@ export interface EventLoopStats {
156
167
  */
157
168
  gcCPU: Integer
158
169
  }
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
+ }
@@ -0,0 +1,140 @@
1
+ import { _assert, AppError } from '@naturalcycles/js-lib/error'
2
+ import type { NumberOfBytes } from '@naturalcycles/js-lib/types'
3
+ import busboy from 'busboy'
4
+ import type { Busboy, FileInfo } from 'busboy'
5
+ import type { BackendRequest } from '../server/server.model.js'
6
+
7
+ /**
8
+ * Parses a `multipart/form-data` request body (on top of `busboy`), populates `req.body` with the
9
+ * form fields, and returns the requested files keyed by their form field name:
10
+ *
11
+ * const { csv } = await getUploadedFiles(req, { files: ['csv'] })
12
+ * const { csv, avatar } = await getUploadedFiles(req, {
13
+ * files: ['csv'], // required -> UploadedFile
14
+ * optionalFiles: ['avatar'], // optional -> UploadedFile | undefined
15
+ * })
16
+ *
17
+ * `files` are required: each is asserted to be present and non-empty (throws 400 otherwise).
18
+ * `optionalFiles` are returned as `UploadedFile | undefined` — `undefined` when the field is
19
+ * absent or empty (e.g. a blank file input), so "optional" is genuinely optional.
20
+ *
21
+ * Throws 413 as soon as ANY uploaded file exceeds `maxFileSize` bytes (default: 10 MB), aborting
22
+ * the parse immediately without buffering the rest of the body.
23
+ *
24
+ * Meant to be called from inside a request handler, AFTER authentication — so an
25
+ * unauthenticated/unauthorized request is rejected before its (potentially large) multipart body
26
+ * is buffered into memory.
27
+ */
28
+ export async function getUploadedFiles<
29
+ const FILENAMES extends string = never,
30
+ const OPTIONAL_FILENAMES extends string = never,
31
+ >(
32
+ req: BackendRequest,
33
+ opt: {
34
+ files?: readonly FILENAMES[]
35
+ optionalFiles?: readonly OPTIONAL_FILENAMES[]
36
+ maxFileSize?: NumberOfBytes
37
+ },
38
+ ): Promise<Record<FILENAMES, UploadedFile> & Record<OPTIONAL_FILENAMES, UploadedFile | undefined>> {
39
+ const { files: requiredNames = [], optionalFiles = [], maxFileSize = 10 * MB } = opt
40
+
41
+ const uploadedFiles = await parseMultipart(req, maxFileSize)
42
+ const result: Record<string, UploadedFile | undefined> = {}
43
+
44
+ for (const name of requiredNames) {
45
+ const file = uploadedFiles[name]
46
+ _assert(file, `Uploaded file "${name}" is missing`, { backendResponseStatusCode: 400 })
47
+ _assert(file.size > 0, `Uploaded file "${name}" is empty`, { backendResponseStatusCode: 400 })
48
+ result[name] = file
49
+ }
50
+
51
+ for (const name of optionalFiles) {
52
+ const file = uploadedFiles[name]
53
+ // Absent or empty (e.g. a blank file input) is treated as "not provided".
54
+ result[name] = file && file.size > 0 ? file : undefined
55
+ }
56
+
57
+ return result as Record<FILENAMES, UploadedFile> &
58
+ Record<OPTIONAL_FILENAMES, UploadedFile | undefined>
59
+ }
60
+
61
+ /**
62
+ * Streams the request through busboy, buffering each file into memory and collecting the form
63
+ * fields onto `req.body`. Resolves with the uploaded files keyed by their form field name.
64
+ *
65
+ * Rejects (aborting the parse) the moment a file exceeds `maxFileSize`.
66
+ */
67
+ async function parseMultipart(
68
+ req: BackendRequest,
69
+ maxFileSize: NumberOfBytes,
70
+ ): Promise<Record<string, UploadedFile>> {
71
+ return new Promise((resolve, reject) => {
72
+ const files: Record<string, UploadedFile> = {}
73
+ const body: Record<string, string> = {}
74
+
75
+ let bb: Busboy
76
+ try {
77
+ bb = busboy({ headers: req.headers, limits: { fileSize: maxFileSize } })
78
+ } catch {
79
+ // Not a multipart request (or malformed Content-Type) — no files to parse
80
+ resolve(files)
81
+ return
82
+ }
83
+
84
+ bb.on('field', (name, value) => {
85
+ body[name] = value
86
+ })
87
+
88
+ bb.on('file', (name: string, stream, info: FileInfo) => {
89
+ const chunks: Buffer[] = []
90
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk))
91
+ // Abort as soon as the size limit is hit: stop feeding busboy and reject right away,
92
+ // rather than buffering the whole over-limit body and reporting it at the end.
93
+ stream.on('limit', () => {
94
+ req.unpipe(bb)
95
+ req.resume() // drain the remainder so the connection can close cleanly
96
+ reject(
97
+ new AppError(`Uploaded file "${name}" exceeds the size limit of ${maxFileSize} bytes`, {
98
+ backendResponseStatusCode: 413,
99
+ }),
100
+ )
101
+ })
102
+ stream.on('close', () => {
103
+ const data = Buffer.concat(chunks)
104
+ files[name] = {
105
+ name: info.filename,
106
+ mimeType: info.mimeType,
107
+ encoding: info.encoding,
108
+ data,
109
+ size: data.length,
110
+ }
111
+ })
112
+ })
113
+
114
+ bb.on('error', reject)
115
+ bb.on('close', () => {
116
+ req.body = body
117
+ resolve(files)
118
+ })
119
+
120
+ req.pipe(bb)
121
+ })
122
+ }
123
+
124
+ const MB = 1024 * 1024
125
+
126
+ /**
127
+ * A single uploaded file, buffered in memory.
128
+ */
129
+ export interface UploadedFile {
130
+ /** Original filename from the client. */
131
+ name: string
132
+ /** Content-Type of the file part. */
133
+ mimeType: string
134
+ /** Content-Transfer-Encoding of the file part. */
135
+ encoding: string
136
+ /** File contents. */
137
+ data: Buffer
138
+ /** Size of `data` in bytes. */
139
+ size: NumberOfBytes
140
+ }