@naturalcycles/backend-lib 9.63.0 → 9.64.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.
@@ -32,7 +32,7 @@ export interface StartServerCfg extends DefaultAppCfg {
32
32
  */
33
33
  onShutdown?: () => Promise<void>;
34
34
  /**
35
- * @default 3000
35
+ * @default 8_000
36
36
  */
37
37
  forceShutdownTimeout?: number;
38
38
  sentryService?: SentrySharedService;
@@ -45,8 +45,13 @@ export class BackendServer {
45
45
  resolve(server);
46
46
  });
47
47
  });
48
- // This is to fix GCP LoadBalancer race condition
49
- this.server.keepAliveTimeout = 600 * 1000; // 10 minutes
48
+ // This is to fix GCP LoadBalancer race condition:
49
+ // GCLB's backend keep-alive timeout is fixed at 600s, and the backend must use
50
+ // a strictly higher value, otherwise the server can close an idle connection at the
51
+ // same moment GCLB reuses it, resulting in intermittent 502s.
52
+ // https://cloud.google.com/load-balancing/docs/https#timeouts_and_retries
53
+ this.server.keepAliveTimeout = 620 * 1000; // slightly above GCLB's 600s
54
+ this.server.headersTimeout = 630 * 1000; // conventionally kept above keepAliveTimeout
50
55
  let address = `http://localhost:${port}`; // default
51
56
  const addr = this.server.address();
52
57
  if (addr) {
@@ -77,7 +82,7 @@ export class BackendServer {
77
82
  const shutdownTimeout = setTimeout(() => {
78
83
  console.log(boldGrey('Forceful shutdown after timeout'));
79
84
  process.exit(0);
80
- }, this.cfg.forceShutdownTimeout ?? 10_000);
85
+ }, this.cfg.forceShutdownTimeout ?? 8000);
81
86
  try {
82
87
  await Promise.all([
83
88
  this.server && new Promise(r => this.server.close(r)),
@@ -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,27 +1,28 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.63.0",
4
+ "version": "9.64.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
8
8
  "@naturalcycles/nodejs-lib": "^15",
9
9
  "@types/body-parser": "^1",
10
- "@types/compressible": "^2",
11
10
  "@types/cookie-parser": "^1",
12
11
  "@types/cors": "^2",
13
12
  "@types/express": "^5",
13
+ "@types/compressible": "^2",
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",
20
21
  "express": "^5",
21
22
  "helmet": "^8",
22
23
  "negotiator": "^1",
23
- "on-finished": "^2",
24
24
  "on-headers": "^1",
25
+ "on-finished": "^2",
25
26
  "tslib": "^2",
26
27
  "vary": "^1"
27
28
  },
@@ -43,9 +44,10 @@
43
44
  },
44
45
  "devDependencies": {
45
46
  "@sentry/node-core": "^10",
46
- "fastify": "^5",
47
+ "@types/busboy": "^1",
47
48
  "typescript": "rc",
48
- "@naturalcycles/dev-lib": "20.50.0"
49
+ "fastify": "^5",
50
+ "@naturalcycles/dev-lib": "0.0.0"
49
51
  },
50
52
  "exports": {
51
53
  ".": "./dist/index.js",
@@ -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"
@@ -53,8 +53,13 @@ export class BackendServer {
53
53
  })
54
54
  })
55
55
 
56
- // This is to fix GCP LoadBalancer race condition
57
- this.server.keepAliveTimeout = 600 * 1000 // 10 minutes
56
+ // This is to fix GCP LoadBalancer race condition:
57
+ // GCLB's backend keep-alive timeout is fixed at 600s, and the backend must use
58
+ // a strictly higher value, otherwise the server can close an idle connection at the
59
+ // same moment GCLB reuses it, resulting in intermittent 502s.
60
+ // https://cloud.google.com/load-balancing/docs/https#timeouts_and_retries
61
+ this.server.keepAliveTimeout = 620 * 1000 // slightly above GCLB's 600s
62
+ this.server.headersTimeout = 630 * 1000 // conventionally kept above keepAliveTimeout
58
63
 
59
64
  let address = `http://localhost:${port}` // default
60
65
 
@@ -97,7 +102,7 @@ export class BackendServer {
97
102
  const shutdownTimeout = setTimeout(() => {
98
103
  console.log(boldGrey('Forceful shutdown after timeout'))
99
104
  process.exit(0)
100
- }, this.cfg.forceShutdownTimeout ?? 10_000)
105
+ }, this.cfg.forceShutdownTimeout ?? 8000)
101
106
 
102
107
  try {
103
108
  await Promise.all([
@@ -146,7 +151,7 @@ export interface StartServerCfg extends DefaultAppCfg {
146
151
  onShutdown?: () => Promise<void>
147
152
 
148
153
  /**
149
- * @default 3000
154
+ * @default 8_000
150
155
  */
151
156
  forceShutdownTimeout?: number
152
157
 
@@ -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
+ }