@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 +14 -1
- package/dist/multipart.d.ts +27 -0
- package/dist/multipart.js +65 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +22 -15
- package/package.json +3 -1
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)));
|
package/dist/validation.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/validation.js
CHANGED
|
@@ -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
|
|
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
|
|
29
|
+
const contentType = (_a = req.headers.get("content-type")) !== null && _a !== void 0 ? _a : "";
|
|
29
30
|
// JSON body
|
|
30
|
-
if (
|
|
31
|
-
body = await
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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 (
|
|
59
|
-
body = { text: await
|
|
65
|
+
else if (contentType.startsWith("text/plain")) {
|
|
66
|
+
body = { text: await req.clone().text() };
|
|
60
67
|
}
|
|
61
68
|
// Binary body
|
|
62
|
-
else if (
|
|
63
|
-
const buffer = await
|
|
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 = (
|
|
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
|
+
"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",
|