@riddl/core 0.2.3 → 0.2.4

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/api.js CHANGED
@@ -3,6 +3,7 @@ import { json, recursiveAwait } from "./utils.js";
3
3
  import { CORS_DEFAULT, METHOD } from "./constants.js";
4
4
  import { log } from "./log.js";
5
5
  import { parseBody, parseOutput, parseParam, parseQuery } from "./validation.js";
6
+ import { cleanupTempFiles } from "./multipart.js";
6
7
  import { parseRouteId } from "./parser.js";
7
8
  import { buildRouter } from "./routing.js";
8
9
  import { openapiHandler, registerRoutes } from "./openapi.js";
@@ -41,6 +42,10 @@ export const api = async ({ routeModuleMap, config, base = "", cors = true, regi
41
42
  const effectiveCorsSettings = cors ? typeof cors === "object" ? cors : CORS_DEFAULT : undefined;
42
43
  const handler = async (request) => {
43
44
  var _a, _b, _c;
45
+ // Temp files produced by streamed multipart uploads; removed once the
46
+ // handler has finished reading them (it copies bytes to permanent
47
+ // storage before returning).
48
+ const tempFiles = [];
44
49
  try {
45
50
  log("request: %O", request);
46
51
  const event = {
@@ -69,7 +74,7 @@ export const api = async ({ routeModuleMap, config, base = "", cors = true, regi
69
74
  }
70
75
  const param = await parseParam(params !== null && params !== void 0 ? params : {}, endpoint);
71
76
  const query = await parseQuery(request, endpoint);
72
- const body = await parseBody(request, endpoint);
77
+ const body = await parseBody(request, endpoint, tempFiles);
73
78
  log("handler: %O", endpoint.handle);
74
79
  const output = await endpoint.handle({ body, query, param, event: ctxEvent });
75
80
  for (const mw of [...routeWithMiddlewares.middlewares].reverse()) {
@@ -124,6 +129,14 @@ export const api = async ({ routeModuleMap, config, base = "", cors = true, regi
124
129
  status: 500,
125
130
  });
126
131
  }
132
+ finally {
133
+ // Runs after the Response is built (handlers copy uploads to
134
+ // permanent storage before returning), so deleting the temp files
135
+ // here is safe. Streaming responses (SSE) never set `tempFiles`.
136
+ if (tempFiles.length > 0) {
137
+ await cleanupTempFiles(tempFiles);
138
+ }
139
+ }
127
140
  };
128
141
  return {
129
142
  handler,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A file part materialised on disk. It quacks like the WHATWG `File` the
3
+ * previous `request.formData()` path produced — `name`, `type`, `size`,
4
+ * `stream()`, `arrayBuffer()`, `slice()` — but its bytes live in a temp file
5
+ * and are read lazily, so a large upload is never held in memory.
6
+ */
7
+ export type UploadedFile = Blob & {
8
+ name: string;
9
+ };
10
+ export type FormValue = string | UploadedFile;
11
+ export type FormBody = Record<string, FormValue | FormValue[]>;
12
+ /**
13
+ * Stream-parse a `multipart/form-data` request body, writing each uploaded
14
+ * file straight to a temp file on disk instead of buffering the whole payload
15
+ * in memory (which is what `request.formData()` does). Text fields are
16
+ * collected as strings; file fields become disk-backed {@link UploadedFile}s.
17
+ *
18
+ * Returns the parsed body together with the temp paths the caller must delete
19
+ * once the handler has finished reading the files — see
20
+ * {@link cleanupTempFiles}.
21
+ */
22
+ export declare const parseMultipart: (req: Request) => Promise<{
23
+ body: FormBody;
24
+ tempFiles: string[];
25
+ }>;
26
+ /** Best-effort removal of the temp files produced by {@link parseMultipart}. */
27
+ export declare const cleanupTempFiles: (tempFiles: string[]) => Promise<unknown>;
@@ -0,0 +1,65 @@
1
+ import busboy from "busboy";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createWriteStream, openAsBlob } from "node:fs";
4
+ import { unlink } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import { pipeline } from "node:stream/promises";
9
+ const addEntry = (body, name, value) => {
10
+ const existing = body[name];
11
+ if (existing === undefined) {
12
+ body[name] = value;
13
+ }
14
+ else if (Array.isArray(existing)) {
15
+ existing.push(value);
16
+ }
17
+ else {
18
+ body[name] = [existing, value];
19
+ }
20
+ };
21
+ /**
22
+ * Stream-parse a `multipart/form-data` request body, writing each uploaded
23
+ * file straight to a temp file on disk instead of buffering the whole payload
24
+ * in memory (which is what `request.formData()` does). Text fields are
25
+ * collected as strings; file fields become disk-backed {@link UploadedFile}s.
26
+ *
27
+ * Returns the parsed body together with the temp paths the caller must delete
28
+ * once the handler has finished reading the files — see
29
+ * {@link cleanupTempFiles}.
30
+ */
31
+ export const parseMultipart = async (req) => {
32
+ const contentType = req.headers.get("content-type");
33
+ if (!req.body || !contentType) {
34
+ return { body: {}, tempFiles: [] };
35
+ }
36
+ const tempFiles = [];
37
+ const body = {};
38
+ await new Promise((resolve, reject) => {
39
+ const bb = busboy({ headers: { "content-type": contentType } });
40
+ // File parts finish writing asynchronously; `close` can fire before the
41
+ // last temp file is fully flushed, so we wait on these before resolving.
42
+ const pending = [];
43
+ bb.on("field", (name, value) => addEntry(body, name, value));
44
+ bb.on("file", (name, stream, info) => {
45
+ const tempPath = join(tmpdir(), `riddl-upload-${randomBytes(16).toString("hex")}`);
46
+ tempFiles.push(tempPath);
47
+ pending.push(pipeline(stream, createWriteStream(tempPath)).then(async () => {
48
+ const blob = await openAsBlob(tempPath, {
49
+ type: info.mimeType,
50
+ });
51
+ addEntry(body, name, Object.assign(blob, { name: info.filename }));
52
+ }));
53
+ });
54
+ bb.on("error", reject);
55
+ bb.on("close", () => {
56
+ Promise.all(pending)
57
+ .then(() => resolve())
58
+ .catch(reject);
59
+ });
60
+ Readable.fromWeb(req.body).pipe(bb);
61
+ });
62
+ return { body, tempFiles };
63
+ };
64
+ /** Best-effort removal of the temp files produced by {@link parseMultipart}. */
65
+ export const cleanupTempFiles = (tempFiles) => Promise.all(tempFiles.map((path) => unlink(path).catch(() => undefined)));
@@ -1,5 +1,5 @@
1
1
  import { GenericApiEndpoint } from "./types";
2
- export declare const parseBody: (req: Request, module: GenericApiEndpoint) => Promise<unknown>;
2
+ export declare const parseBody: (req: Request, module: GenericApiEndpoint, tempFiles?: string[]) => Promise<unknown>;
3
3
  export declare const parseQuery: (request: Request, module: GenericApiEndpoint) => Promise<Record<string, string | string[]>>;
4
4
  export declare const parseParam: (params: Record<string, string>, module: GenericApiEndpoint) => Promise<Record<string, string | never>>;
5
5
  export declare const parseOutput: (out: unknown, module: GenericApiEndpoint) => Promise<Record<string, unknown> | unknown[]>;
@@ -2,6 +2,7 @@ import { fromZodError } from "zod-validation-error";
2
2
  import { log } from "./log.js";
3
3
  import { z } from "./zod.js";
4
4
  import { error } from "./errors.js";
5
+ import { parseMultipart } from "./multipart.js";
5
6
  const parseJsonBody = async (req) => {
6
7
  var _a;
7
8
  const clonedRequest = req.clone();
@@ -17,27 +18,33 @@ const parseJsonBody = async (req) => {
17
18
  }
18
19
  }
19
20
  };
20
- export const parseBody = async (req, module) => {
21
- var _a, _b, _c, _d, _e, _f;
21
+ export const parseBody = async (req, module, tempFiles = []) => {
22
+ var _a, _b;
22
23
  let body = {};
23
24
  // GET, HEAD, DELETE, OPTIONS have no body
24
25
  const method = req.method.toUpperCase();
25
26
  if (["GET", "HEAD", "DELETE", "OPTIONS"].includes(method)) {
26
27
  return body;
27
28
  }
28
- const clonedRequest = req.clone();
29
+ const contentType = (_a = req.headers.get("content-type")) !== null && _a !== void 0 ? _a : "";
29
30
  // JSON body
30
- if ((_a = clonedRequest.headers.get("content-type")) === null || _a === void 0 ? void 0 : _a.startsWith("application/json")) {
31
- body = await clonedRequest.json().catch(() => {
31
+ if (contentType.startsWith("application/json")) {
32
+ body = await req.clone().json().catch(() => {
32
33
  throw error(400, "Invalid JSON body");
33
34
  });
34
35
  }
35
- // Form body
36
- else if (((_b = clonedRequest.headers
37
- .get("content-type")) === null || _b === void 0 ? void 0 : _b.startsWith("application/x-www-form-urlencoded")) ||
38
- ((_c = clonedRequest.headers.get("content-type")) === null || _c === void 0 ? void 0 : _c.startsWith("multipart/form-data"))) {
36
+ // Multipart form body: stream uploads to disk rather than buffering the
37
+ // whole payload in memory. Consume `req` directly (no `clone()` — a tee
38
+ // would re-buffer the unread branch, defeating the point).
39
+ else if (contentType.startsWith("multipart/form-data")) {
40
+ const result = await parseMultipart(req);
41
+ tempFiles.push(...result.tempFiles);
42
+ body = result.body;
43
+ }
44
+ // URL-encoded form body (always small — buffering is fine)
45
+ else if (contentType.startsWith("application/x-www-form-urlencoded")) {
39
46
  const formBody = {};
40
- const form = await clonedRequest.formData();
47
+ const form = await req.clone().formData();
41
48
  for (const [key, value] of form.entries()) {
42
49
  if (formBody[key]) {
43
50
  const existing = formBody[key];
@@ -55,16 +62,16 @@ export const parseBody = async (req, module) => {
55
62
  body = formBody;
56
63
  }
57
64
  // Text body
58
- else if ((_d = clonedRequest.headers.get("content-type")) === null || _d === void 0 ? void 0 : _d.startsWith("text/plain")) {
59
- body = { text: await clonedRequest.text() };
65
+ else if (contentType.startsWith("text/plain")) {
66
+ body = { text: await req.clone().text() };
60
67
  }
61
68
  // Binary body
62
- else if ((_e = clonedRequest.headers.get("content-type")) === null || _e === void 0 ? void 0 : _e.startsWith("application/octet-stream")) {
63
- const buffer = await clonedRequest.arrayBuffer();
69
+ else if (contentType.startsWith("application/octet-stream")) {
70
+ const buffer = await req.clone().arrayBuffer();
64
71
  body = { buffer };
65
72
  }
66
73
  log("body: %O", body);
67
- const validator = (_f = module.Input) !== null && _f !== void 0 ? _f : z.object({});
74
+ const validator = (_b = module.Input) !== null && _b !== void 0 ? _b : z.object({});
68
75
  const validation = validator.safeParse(body);
69
76
  if (!validation.success) {
70
77
  throw error(400, `Invalid body.\n${fromZodError(validation.error).message}`);
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@riddl/core",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@asteasolutions/zod-to-openapi": "^8.4.0",
8
+ "busboy": "^1.6.0",
8
9
  "debug": "^4.4.0",
9
10
  "express": "^5.2.1",
10
11
  "set-cookie-parser": "^3.0.1",
@@ -22,6 +23,7 @@
22
23
  "/dist"
23
24
  ],
24
25
  "devDependencies": {
26
+ "@types/busboy": "^1.5.4",
25
27
  "@types/debug": "^4.1.12",
26
28
  "@types/express": "^5.0.6",
27
29
  "@types/set-cookie-parser": "^2.4.10",