@orkestrel/middleware 0.0.1
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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/src/core/index.cjs +1463 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1339 -0
- package/dist/src/core/index.d.ts +1339 -0
- package/dist/src/core/index.js +1399 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +1217 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +729 -0
- package/dist/src/server/index.d.ts +729 -0
- package/dist/src/server/index.js +1180 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +108 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_fs = require("node:fs");
|
|
3
|
+
let node_fs_promises = require("node:fs/promises");
|
|
4
|
+
let node_os = require("node:os");
|
|
5
|
+
let node_path = require("node:path");
|
|
6
|
+
let node_crypto = require("node:crypto");
|
|
7
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
8
|
+
let _orkestrel_server = require("@orkestrel/server");
|
|
9
|
+
let _src_core = require("../core/index.cjs");
|
|
10
|
+
let node_zlib = require("node:zlib");
|
|
11
|
+
let node_util = require("node:util");
|
|
12
|
+
//#region src/server/constants.ts
|
|
13
|
+
/** The HTTP status `createMultipart` renders for each {@link MultipartReason}. */
|
|
14
|
+
var MULTIPART_REASON_STATUS = Object.freeze({
|
|
15
|
+
limit: 413,
|
|
16
|
+
malformed: 400,
|
|
17
|
+
rejected: 415
|
|
18
|
+
});
|
|
19
|
+
/** `createStatic`'s default directory-index filename. */
|
|
20
|
+
var DEFAULT_STATIC_INDEX = "index.html";
|
|
21
|
+
/** `createStatic`'s `fallback: true` default excluded path prefix. */
|
|
22
|
+
var DEFAULT_STATIC_FALLBACK_EXCLUDE = "/api";
|
|
23
|
+
/** The MIME type served when a file extension has no known mapping. */
|
|
24
|
+
var DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
25
|
+
/** `createMultipart`'s default per-file byte-size cap. */
|
|
26
|
+
var DEFAULT_MULTIPART_FILE = 10485760;
|
|
27
|
+
/** `createMultipart`'s default maximum file-part count. */
|
|
28
|
+
var DEFAULT_MULTIPART_FILES = 10;
|
|
29
|
+
/** `createMultipart`'s default per-field byte-size cap. */
|
|
30
|
+
var DEFAULT_MULTIPART_FIELD = 65536;
|
|
31
|
+
/** `createMultipart`'s default maximum field-part count. */
|
|
32
|
+
var DEFAULT_MULTIPART_FIELDS = 100;
|
|
33
|
+
/** `createMultipart`'s default combined request-body byte-size cap. */
|
|
34
|
+
var DEFAULT_MULTIPART_TOTAL = 52428800;
|
|
35
|
+
/** The maximum bytes a single multipart part's header block may occupy before it is malformed. */
|
|
36
|
+
var MULTIPART_MAX_HEADER_BLOCK = 16384;
|
|
37
|
+
/** The maximum bytes scanned before the first multipart boundary is found before it is malformed. */
|
|
38
|
+
var MULTIPART_MAX_PREAMBLE = 65536;
|
|
39
|
+
/**
|
|
40
|
+
* Windows reserved device-name stems (CVE-2025-27210) — matched
|
|
41
|
+
* case-insensitively against the segment's stem (before its first `.`).
|
|
42
|
+
*/
|
|
43
|
+
var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
|
|
44
|
+
"CON",
|
|
45
|
+
"PRN",
|
|
46
|
+
"AUX",
|
|
47
|
+
"NUL",
|
|
48
|
+
"COM1",
|
|
49
|
+
"COM2",
|
|
50
|
+
"COM3",
|
|
51
|
+
"COM4",
|
|
52
|
+
"COM5",
|
|
53
|
+
"COM6",
|
|
54
|
+
"COM7",
|
|
55
|
+
"COM8",
|
|
56
|
+
"COM9",
|
|
57
|
+
"LPT1",
|
|
58
|
+
"LPT2",
|
|
59
|
+
"LPT3",
|
|
60
|
+
"LPT4",
|
|
61
|
+
"LPT5",
|
|
62
|
+
"LPT6",
|
|
63
|
+
"LPT7",
|
|
64
|
+
"LPT8",
|
|
65
|
+
"LPT9"
|
|
66
|
+
]));
|
|
67
|
+
/** File-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. */
|
|
68
|
+
var EXTENSION_TYPES = Object.freeze({
|
|
69
|
+
".html": "text/html; charset=utf-8",
|
|
70
|
+
".htm": "text/html; charset=utf-8",
|
|
71
|
+
".css": "text/css; charset=utf-8",
|
|
72
|
+
".js": "text/javascript; charset=utf-8",
|
|
73
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
74
|
+
".json": "application/json; charset=utf-8",
|
|
75
|
+
".txt": "text/plain; charset=utf-8",
|
|
76
|
+
".xml": "application/xml; charset=utf-8",
|
|
77
|
+
".svg": "image/svg+xml",
|
|
78
|
+
".png": "image/png",
|
|
79
|
+
".jpg": "image/jpeg",
|
|
80
|
+
".jpeg": "image/jpeg",
|
|
81
|
+
".gif": "image/gif",
|
|
82
|
+
".webp": "image/webp",
|
|
83
|
+
".ico": "image/x-icon",
|
|
84
|
+
".pdf": "application/pdf",
|
|
85
|
+
".zip": "application/zip",
|
|
86
|
+
".woff": "font/woff",
|
|
87
|
+
".woff2": "font/woff2",
|
|
88
|
+
".wasm": "application/wasm"
|
|
89
|
+
});
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/server/errors.ts
|
|
92
|
+
/**
|
|
93
|
+
* An error `createMultipart` throws when a streamed multipart request fails
|
|
94
|
+
* a mid-stream limit, is structurally malformed, or has a file whose sniffed
|
|
95
|
+
* bytes are rejected by the configured `allowed` MIME list.
|
|
96
|
+
*
|
|
97
|
+
* @remarks
|
|
98
|
+
* Carries the HTTP `status` derived from `reason` (limit → 413, malformed →
|
|
99
|
+
* 400, rejected → 415) and an optional `context` record. Rendered by
|
|
100
|
+
* `createBoundary` like any other `HTTPError`-shaped throw. Narrow a caught
|
|
101
|
+
* value with {@link isMultipartError}.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { MultipartError } from '@orkestrel/middleware/server'
|
|
106
|
+
*
|
|
107
|
+
* throw new MultipartError('limit', 'too many files')
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
var MultipartError = class extends Error {
|
|
111
|
+
status;
|
|
112
|
+
reason;
|
|
113
|
+
context;
|
|
114
|
+
constructor(reason, message, context) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.status = MULTIPART_REASON_STATUS[reason];
|
|
117
|
+
this.reason = reason;
|
|
118
|
+
this.context = context;
|
|
119
|
+
Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Narrow an unknown caught value to a {@link MultipartError}.
|
|
124
|
+
*
|
|
125
|
+
* @remarks
|
|
126
|
+
* Structural, not `instanceof` — tests that `value` is a non-null object
|
|
127
|
+
* carrying the module-scope brand, a numeric `status`, and a `reason` in the
|
|
128
|
+
* parser's set of reason strings (`'limit' | 'malformed' | 'rejected'`).
|
|
129
|
+
* Total: never throws, returns `false` for any off-shape input.
|
|
130
|
+
*
|
|
131
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
132
|
+
* @returns `true` when `value` is a {@link MultipartError}
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* import { isMultipartError } from '@orkestrel/middleware/server'
|
|
137
|
+
*
|
|
138
|
+
* try {
|
|
139
|
+
* await parse(request)
|
|
140
|
+
* } catch (error) {
|
|
141
|
+
* if (isMultipartError(error)) console.log(error.status, error.reason)
|
|
142
|
+
* }
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
function isMultipartError(value) {
|
|
146
|
+
if (typeof value !== "object" || value === null) return false;
|
|
147
|
+
if (!(Symbol.for("@orkestrel/middleware.MultipartError") in value)) return false;
|
|
148
|
+
if (!("status" in value) || !("reason" in value)) return false;
|
|
149
|
+
if (typeof value.status !== "number") return false;
|
|
150
|
+
if (value.reason !== "limit" && value.reason !== "malformed" && value.reason !== "rejected") return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/server/helpers.ts
|
|
155
|
+
/**
|
|
156
|
+
* Whether `pathname` is `prefix` itself or lies under it on a SEGMENT
|
|
157
|
+
* boundary — the shared under-path test `resolveStaticPath`'s prefix strip
|
|
158
|
+
* and `createStatic`'s SPA-fallback `exclude` both apply, so `exclude:
|
|
159
|
+
* '/api'` matches `/api` and `/api/x` but never `/apifoo`.
|
|
160
|
+
*
|
|
161
|
+
* @param pathname - The request pathname to test
|
|
162
|
+
* @param prefix - The path prefix to test against
|
|
163
|
+
* @returns `true` when `pathname` equals `prefix` or starts with `prefix` + `/`
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* isUnderPath('/api/x', '/api') // true
|
|
168
|
+
* isUnderPath('/apifoo', '/api') // false
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
function isUnderPath(pathname, prefix) {
|
|
172
|
+
if (pathname === prefix) return true;
|
|
173
|
+
const boundary = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
174
|
+
return pathname.startsWith(boundary);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether `child` is `parent` itself or lies inside it on-disk — the
|
|
178
|
+
* FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
|
|
179
|
+
* output (never to a URL pathname — that is {@link isUnderPath}'s job).
|
|
180
|
+
*
|
|
181
|
+
* @remarks
|
|
182
|
+
* Argument order is `(child, parent)` — deliberately the OPPOSITE conceptual
|
|
183
|
+
* order from {@link isUnderPath}`(pathname, prefix)`, so a call site cannot
|
|
184
|
+
* casually swap one predicate in for the other. Built on `path.relative`,
|
|
185
|
+
* this is separator-correct on both POSIX (`/`) and win32 (`\`) — unlike a
|
|
186
|
+
* hardcoded `${parent}/` boundary check, which silently fails to match every
|
|
187
|
+
* realpath on Windows — and case-folds on win32 because `path.relative` does.
|
|
188
|
+
*
|
|
189
|
+
* @param child - The absolute on-disk path to test
|
|
190
|
+
* @param parent - The absolute on-disk directory it must lie under
|
|
191
|
+
* @returns `true` when `child` equals `parent` or resolves inside it
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* isContainedPath('/srv/public/a.html', '/srv/public') // true
|
|
196
|
+
* isContainedPath('/srv/other/a.html', '/srv/public') // false
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
function isContainedPath(child, parent) {
|
|
200
|
+
if (child === parent) return true;
|
|
201
|
+
const rel = (0, node_path.relative)(parent, child);
|
|
202
|
+
return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${node_path.sep}`) && !(0, node_path.isAbsolute)(rel);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Resolve a request pathname to an on-disk path UNDER `root`, or `undefined`
|
|
206
|
+
* when it cannot — the traversal guard, EXACT algorithm and order (PROPOSAL
|
|
207
|
+
* §4.14): strip `prefix` on a segment boundary → `decodeURIComponent` (a
|
|
208
|
+
* malformed escape refuses, never throws) → reject a NUL byte → strip the
|
|
209
|
+
* leading path separator FIRST (so a leading `..` survives `normalize` as a
|
|
210
|
+
* genuine climbing segment) → `normalize` → refuse any Windows reserved-
|
|
211
|
+
* device-name segment ({@link isReservedDeviceName}) → `resolve` and require
|
|
212
|
+
* the result under `root`.
|
|
213
|
+
*
|
|
214
|
+
* @param root - The absolute root directory every result must resolve under
|
|
215
|
+
* @param prefix - An optional URL path prefix stripped on a segment boundary
|
|
216
|
+
* @param pathname - The raw request pathname
|
|
217
|
+
* @returns The resolved absolute path under `root`, or `undefined` when the
|
|
218
|
+
* request does not resolve (out of prefix, malformed escape, NUL byte,
|
|
219
|
+
* reserved device name, or an attempted escape from `root`)
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* resolveStaticPath('/srv/public', '/api', '/api/../../etc/passwd') // undefined
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
function resolveStaticPath(root, prefix, pathname) {
|
|
227
|
+
let remainder = pathname;
|
|
228
|
+
if (prefix !== void 0) {
|
|
229
|
+
if (!isUnderPath(pathname, prefix)) return void 0;
|
|
230
|
+
remainder = pathname === prefix ? "/" : pathname.slice(prefix.length);
|
|
231
|
+
}
|
|
232
|
+
let decoded;
|
|
233
|
+
try {
|
|
234
|
+
decoded = decodeURIComponent(remainder);
|
|
235
|
+
} catch {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (decoded.includes("\0")) return void 0;
|
|
239
|
+
const normalized = (0, node_path.normalize)(decoded.replace(/^[/\\]+/, ""));
|
|
240
|
+
const segments = normalized.split(/[/\\]+/).filter((segment) => segment.length > 0);
|
|
241
|
+
for (const segment of segments) if (isReservedDeviceName(segment)) return void 0;
|
|
242
|
+
const resolved = (0, node_path.resolve)(root, normalized);
|
|
243
|
+
if (resolved === root || resolved.startsWith(`${root}${node_path.sep}`)) return resolved;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Whether a path segment is a Windows reserved device name (CVE-2025-27210).
|
|
247
|
+
*
|
|
248
|
+
* @remarks
|
|
249
|
+
* Normalizes superscript digits (`¹²³` → `123`) first, strips trailing dots
|
|
250
|
+
* and spaces (Windows drops them), takes the STEM before the first `.`,
|
|
251
|
+
* upper-cases it, and tests it against {@link RESERVED_DEVICE_NAMES}
|
|
252
|
+
* (`CON PRN AUX NUL COM1-9 LPT1-9`) — an exact-stem match only, so
|
|
253
|
+
* `console.js` and `nullable.css` are never flagged.
|
|
254
|
+
*
|
|
255
|
+
* @param segment - One path segment (no separators)
|
|
256
|
+
* @returns `true` when `segment` names a reserved device
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* isReservedDeviceName('NUL.json') // true
|
|
261
|
+
* isReservedDeviceName('nullable.css') // false
|
|
262
|
+
* isReservedDeviceName('CON¹') // true
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
function isReservedDeviceName(segment) {
|
|
266
|
+
const stem = segment.replace(/[¹²³]/g, (digit) => digit === "¹" ? "1" : digit === "²" ? "2" : "3").replace(/[. ]+$/, "").split(".")[0];
|
|
267
|
+
if (stem === void 0 || stem.length === 0) return false;
|
|
268
|
+
return RESERVED_DEVICE_NAMES.has(stem.toUpperCase());
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether a relative path (already resolved under a static root) has any
|
|
272
|
+
* segment starting with `.` — a dotfile or dot-directory.
|
|
273
|
+
*
|
|
274
|
+
* @param relativePath - A path relative to the static root
|
|
275
|
+
* @returns `true` when any segment starts with `.`
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* isDotfilePath('.env') // true
|
|
280
|
+
* isDotfilePath('a/.git/config') // true
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
function isDotfilePath(relativePath) {
|
|
284
|
+
return relativePath.split(/[/\\]+/).some((segment) => segment.startsWith("."));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Look up the MIME type for a static file path by its extension.
|
|
288
|
+
*
|
|
289
|
+
* @param pathname - The file's path (only its extension is read)
|
|
290
|
+
* @returns The mapped MIME type, or {@link DEFAULT_CONTENT_TYPE} when unknown
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```ts
|
|
294
|
+
* lookupContentType('/a/b.css') // 'text/css; charset=utf-8'
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
function lookupContentType(pathname) {
|
|
298
|
+
return EXTENSION_TYPES[(0, node_path.extname)(pathname).toLowerCase()] ?? "application/octet-stream";
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Compute a static file's weak ETag from its size and modification time.
|
|
302
|
+
*
|
|
303
|
+
* @param size - The file's byte size
|
|
304
|
+
* @param mtimeMs - The file's modification time in milliseconds
|
|
305
|
+
* @returns A weak entity-tag `W/"<size>-<floor(mtimeMs)>"`
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* computeFileETag(1024, 1700000000123.4) // 'W/"1024-1700000000123"'
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
function computeFileETag(size, mtimeMs) {
|
|
313
|
+
return `W/"${size}-${Math.floor(mtimeMs)}"`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Sniff a MIME type from a file's leading bytes against a small magic-byte
|
|
317
|
+
* table (jpeg, png, gif87a/89a, webp, pdf, zip) — the SNIFF-AUTHORITATIVE
|
|
318
|
+
* signal `createMultipart`'s type validation rests on, never the declared
|
|
319
|
+
* `Content-Type`.
|
|
320
|
+
*
|
|
321
|
+
* @param head - The file's first bytes (16 is sufficient for every signature)
|
|
322
|
+
* @returns The detected MIME type, or `undefined` when no signature matches
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```ts
|
|
326
|
+
* detectMIME(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) // 'image/png'
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
function detectMIME(head) {
|
|
330
|
+
function matches(signature, offset = 0) {
|
|
331
|
+
if (head.length < offset + signature.length) return false;
|
|
332
|
+
for (let index = 0; index < signature.length; index += 1) if (head[offset + index] !== signature[index]) return false;
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (matches([
|
|
336
|
+
255,
|
|
337
|
+
216,
|
|
338
|
+
255
|
|
339
|
+
])) return "image/jpeg";
|
|
340
|
+
if (matches([
|
|
341
|
+
137,
|
|
342
|
+
80,
|
|
343
|
+
78,
|
|
344
|
+
71,
|
|
345
|
+
13,
|
|
346
|
+
10,
|
|
347
|
+
26,
|
|
348
|
+
10
|
|
349
|
+
])) return "image/png";
|
|
350
|
+
if (matches([
|
|
351
|
+
71,
|
|
352
|
+
73,
|
|
353
|
+
70,
|
|
354
|
+
56,
|
|
355
|
+
55,
|
|
356
|
+
97
|
|
357
|
+
])) return "image/gif";
|
|
358
|
+
if (matches([
|
|
359
|
+
71,
|
|
360
|
+
73,
|
|
361
|
+
70,
|
|
362
|
+
56,
|
|
363
|
+
57,
|
|
364
|
+
97
|
|
365
|
+
])) return "image/gif";
|
|
366
|
+
if (matches([
|
|
367
|
+
82,
|
|
368
|
+
73,
|
|
369
|
+
70,
|
|
370
|
+
70
|
|
371
|
+
]) && matches([
|
|
372
|
+
87,
|
|
373
|
+
69,
|
|
374
|
+
66,
|
|
375
|
+
80
|
|
376
|
+
], 8)) return "image/webp";
|
|
377
|
+
if (matches([
|
|
378
|
+
37,
|
|
379
|
+
80,
|
|
380
|
+
68,
|
|
381
|
+
70,
|
|
382
|
+
45
|
|
383
|
+
])) return "application/pdf";
|
|
384
|
+
if (matches([
|
|
385
|
+
80,
|
|
386
|
+
75,
|
|
387
|
+
3,
|
|
388
|
+
4
|
|
389
|
+
]) || matches([
|
|
390
|
+
80,
|
|
391
|
+
75,
|
|
392
|
+
5,
|
|
393
|
+
6
|
|
394
|
+
]) || matches([
|
|
395
|
+
80,
|
|
396
|
+
75,
|
|
397
|
+
7,
|
|
398
|
+
8
|
|
399
|
+
])) return "application/zip";
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Extract the `boundary` parameter from a `Content-Type` header, or
|
|
403
|
+
* `undefined` when the request is not `multipart/form-data`.
|
|
404
|
+
*
|
|
405
|
+
* @param contentType - The request's `Content-Type` header value, if present
|
|
406
|
+
* @returns The multipart boundary token, or `undefined` for a non-multipart
|
|
407
|
+
* (or malformed/boundary-less) content type
|
|
408
|
+
*
|
|
409
|
+
* @example
|
|
410
|
+
* ```ts
|
|
411
|
+
* multipartBoundary('multipart/form-data; boundary=abc123') // 'abc123'
|
|
412
|
+
* multipartBoundary('application/json') // undefined
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
function multipartBoundary(contentType) {
|
|
416
|
+
if (contentType === null) return void 0;
|
|
417
|
+
const [type, ...params] = contentType.split(";").map((part) => part.trim());
|
|
418
|
+
if (type === void 0 || type.toLowerCase() !== "multipart/form-data") return void 0;
|
|
419
|
+
for (const param of params) {
|
|
420
|
+
const equals = param.indexOf("=");
|
|
421
|
+
if (equals === -1) continue;
|
|
422
|
+
if (param.slice(0, equals).trim().toLowerCase() !== "boundary") continue;
|
|
423
|
+
let value = param.slice(equals + 1).trim();
|
|
424
|
+
if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) value = value.slice(1, -1);
|
|
425
|
+
return value.length > 0 ? value : void 0;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Resolve `createMultipart`'s effective {@link MultipartLimits}, applying
|
|
430
|
+
* every documented default.
|
|
431
|
+
*
|
|
432
|
+
* @param limits - The caller's partial limits
|
|
433
|
+
* @returns The fully-resolved limits
|
|
434
|
+
*/
|
|
435
|
+
function resolveMultipartLimits(limits) {
|
|
436
|
+
return {
|
|
437
|
+
file: limits?.file ?? 10485760,
|
|
438
|
+
files: limits?.files ?? 10,
|
|
439
|
+
field: limits?.field ?? 65536,
|
|
440
|
+
fields: limits?.fields ?? 100,
|
|
441
|
+
total: limits?.total ?? 52428800
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Memoized `Promise` for `parseMultipartRequest`'s lazily-created default
|
|
446
|
+
* staging directory — created once per process via {@link resolveDefaultDirectory}.
|
|
447
|
+
*/
|
|
448
|
+
var defaultDirectory;
|
|
449
|
+
/**
|
|
450
|
+
* Resolve `parseMultipartRequest`'s default staging directory when the
|
|
451
|
+
* caller did not configure one — a process-owned directory created ONCE
|
|
452
|
+
* (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
|
|
453
|
+
* locked to mode `0o700`.
|
|
454
|
+
*
|
|
455
|
+
* @returns The absolute path of the process-owned staging directory
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* ```ts
|
|
459
|
+
* const directory = await resolveDefaultDirectory()
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
function resolveDefaultDirectory() {
|
|
463
|
+
if (defaultDirectory === void 0) defaultDirectory = (async () => {
|
|
464
|
+
const path = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-multipart-"));
|
|
465
|
+
await (0, node_fs_promises.chmod)(path, 448);
|
|
466
|
+
return path;
|
|
467
|
+
})();
|
|
468
|
+
return defaultDirectory;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Parse one multipart part's raw header block into its `name` (from
|
|
472
|
+
* `Content-Disposition`), optional `filename`, and optional `Content-Type`.
|
|
473
|
+
*
|
|
474
|
+
* @param block - The raw header block for one multipart part (before the
|
|
475
|
+
* terminating blank line)
|
|
476
|
+
* @returns The parsed `name`, `filename`, and `contentType` (each
|
|
477
|
+
* `undefined` when absent)
|
|
478
|
+
*
|
|
479
|
+
* @example
|
|
480
|
+
* ```ts
|
|
481
|
+
* parsePartHeaders('Content-Disposition: form-data; name="title"')
|
|
482
|
+
* // { name: 'title', filename: undefined, contentType: undefined }
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
function parsePartHeaders(block) {
|
|
486
|
+
let name;
|
|
487
|
+
let filename;
|
|
488
|
+
let contentType;
|
|
489
|
+
for (const line of block.split("\r\n")) {
|
|
490
|
+
const colon = line.indexOf(":");
|
|
491
|
+
if (colon === -1) continue;
|
|
492
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
493
|
+
const value = line.slice(colon + 1).trim();
|
|
494
|
+
if (key === "content-disposition") {
|
|
495
|
+
const nameMatch = /;\s*name="([^"]*)"/.exec(value);
|
|
496
|
+
const filenameMatch = /;\s*filename="([^"]*)"/.exec(value);
|
|
497
|
+
if (nameMatch !== null) name = nameMatch[1];
|
|
498
|
+
if (filenameMatch !== null) filename = filenameMatch[1];
|
|
499
|
+
} else if (key === "content-type") contentType = value;
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
name,
|
|
503
|
+
filename,
|
|
504
|
+
contentType
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Stream-parse a `multipart/form-data` request into its files and fields —
|
|
509
|
+
* the mid-stream state machine `createMultipart` drives (PROPOSAL §4.15).
|
|
510
|
+
*
|
|
511
|
+
* @remarks
|
|
512
|
+
* Reads `request.body` chunk by chunk via its `ReadableStream` reader —
|
|
513
|
+
* NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
|
|
514
|
+
* the instant it is exceeded (reading stops, every already-staged temp file
|
|
515
|
+
* is deleted, throws {@link MultipartError} with reason `'limit'`). Each file
|
|
516
|
+
* part streams to `join(directory, randomUUID())` — the client's declared
|
|
517
|
+
* filename is METADATA ONLY, never a path component. A field OR file part
|
|
518
|
+
* named `__proto__` / `constructor` / `prototype` is silently skipped and
|
|
519
|
+
* never keyed onto the returned {@link MultipartBody} (a skipped file's
|
|
520
|
+
* staged temp file is unlinked immediately, since it can never be
|
|
521
|
+
* referenced). A file part with an empty declared filename (`filename=""`)
|
|
522
|
+
* AND a zero-byte body — the browser convention for an unselected optional
|
|
523
|
+
* `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
|
|
524
|
+
* never counted against the `files` limit, and it never runs the `allowed`
|
|
525
|
+
* check. A malformed
|
|
526
|
+
* structure (missing/unterminated boundary, nameless part, an oversized
|
|
527
|
+
* header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
|
|
528
|
+
* the first boundary) throws with reason `'malformed'`. A file is accepted
|
|
529
|
+
* against the configured `allowed` MIME list iff its SNIFFED bytes detect a
|
|
530
|
+
* type present in the list — sniff-authoritative, independent of whether the
|
|
531
|
+
* declared `Content-Type` matches (that agreement is exposed separately as
|
|
532
|
+
* `validated`); otherwise throws with reason `'rejected'`. A
|
|
533
|
+
* request abort mid-upload triggers the same fail-closed cleanup as a limit
|
|
534
|
+
* breach. Returns `undefined` for a non-multipart request (untouched).
|
|
535
|
+
*
|
|
536
|
+
* @param request - The incoming multipart request
|
|
537
|
+
* @param options - See {@link MultipartOptions}
|
|
538
|
+
* @returns The parsed {@link MultipartBody}, or `undefined` when the request
|
|
539
|
+
* is not `multipart/form-data`
|
|
540
|
+
* @throws {MultipartError} On any limit breach, malformed structure, or
|
|
541
|
+
* rejected file type
|
|
542
|
+
*
|
|
543
|
+
* @example
|
|
544
|
+
* ```ts
|
|
545
|
+
* const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
async function parseMultipartRequest(request, options = {}) {
|
|
549
|
+
const boundary = multipartBoundary(request.headers.get("content-type"));
|
|
550
|
+
if (boundary === void 0) return void 0;
|
|
551
|
+
if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
|
|
552
|
+
const limits = resolveMultipartLimits(options.limits);
|
|
553
|
+
const allowed = options.allowed;
|
|
554
|
+
const directory = options.directory ?? await resolveDefaultDirectory();
|
|
555
|
+
const staged = [];
|
|
556
|
+
const files = Object.create(null);
|
|
557
|
+
const fields = Object.create(null);
|
|
558
|
+
let fileCount = 0;
|
|
559
|
+
let fieldCount = 0;
|
|
560
|
+
let totalBytes = 0;
|
|
561
|
+
let aborted = false;
|
|
562
|
+
async function cleanup() {
|
|
563
|
+
for (const path of staged) try {
|
|
564
|
+
await (0, node_fs_promises.unlink)(path);
|
|
565
|
+
} catch {}
|
|
566
|
+
}
|
|
567
|
+
const reader = request.body.getReader();
|
|
568
|
+
let buffer = Buffer.alloc(0);
|
|
569
|
+
let ended = false;
|
|
570
|
+
const onAbort = () => {
|
|
571
|
+
aborted = true;
|
|
572
|
+
};
|
|
573
|
+
request.signal.addEventListener("abort", onAbort);
|
|
574
|
+
async function pull() {
|
|
575
|
+
if (aborted || request.signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
576
|
+
if (ended) return false;
|
|
577
|
+
const { done, value } = await reader.read();
|
|
578
|
+
if (done) {
|
|
579
|
+
ended = true;
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
totalBytes += value.byteLength;
|
|
583
|
+
if (totalBytes > limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
|
|
584
|
+
buffer = Buffer.concat([buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
const openMarker = Buffer.from(`--${boundary}`);
|
|
589
|
+
let preambleScanned = 0;
|
|
590
|
+
let index = buffer.indexOf(openMarker);
|
|
591
|
+
while (index === -1) {
|
|
592
|
+
const carry = openMarker.length - 1;
|
|
593
|
+
if (buffer.length > carry) {
|
|
594
|
+
const drop = buffer.length - carry;
|
|
595
|
+
preambleScanned += drop;
|
|
596
|
+
if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
597
|
+
buffer = buffer.subarray(drop);
|
|
598
|
+
}
|
|
599
|
+
if (!await pull()) throw new MultipartError("malformed", "missing multipart boundary");
|
|
600
|
+
index = buffer.indexOf(openMarker);
|
|
601
|
+
}
|
|
602
|
+
buffer = buffer.subarray(index + openMarker.length);
|
|
603
|
+
for (;;) {
|
|
604
|
+
while (buffer.length < 2) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
605
|
+
if (buffer[0] === 45 && buffer[1] === 45) break;
|
|
606
|
+
if (buffer[0] !== 13 || buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
|
|
607
|
+
buffer = buffer.subarray(2);
|
|
608
|
+
let headerEnd = buffer.indexOf("\r\n\r\n");
|
|
609
|
+
while (headerEnd === -1) {
|
|
610
|
+
if (buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
611
|
+
if (!await pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
|
|
612
|
+
headerEnd = buffer.indexOf("\r\n\r\n");
|
|
613
|
+
}
|
|
614
|
+
const headerBlock = buffer.subarray(0, headerEnd).toString("utf8");
|
|
615
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
616
|
+
const { name, filename, contentType } = parsePartHeaders(headerBlock);
|
|
617
|
+
if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
|
|
618
|
+
const partDelimiter = Buffer.from(`\r\n--${boundary}`);
|
|
619
|
+
if (filename !== void 0) {
|
|
620
|
+
if (filename !== "") {
|
|
621
|
+
fileCount += 1;
|
|
622
|
+
if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
623
|
+
}
|
|
624
|
+
const path = (0, node_path.join)(directory, (0, node_crypto.randomUUID)());
|
|
625
|
+
staged.push(path);
|
|
626
|
+
const handle = await (0, node_fs_promises.open)(path, "w", 384);
|
|
627
|
+
let size = 0;
|
|
628
|
+
let head = Buffer.alloc(0);
|
|
629
|
+
try {
|
|
630
|
+
for (;;) {
|
|
631
|
+
const boundaryIndex = buffer.indexOf(partDelimiter);
|
|
632
|
+
if (boundaryIndex === -1) {
|
|
633
|
+
const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
|
|
634
|
+
if (safeLength > 0) {
|
|
635
|
+
const chunk = buffer.subarray(0, safeLength);
|
|
636
|
+
size += chunk.length;
|
|
637
|
+
if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
638
|
+
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
639
|
+
await handle.write(chunk);
|
|
640
|
+
buffer = buffer.subarray(safeLength);
|
|
641
|
+
}
|
|
642
|
+
if (!await pull()) throw new MultipartError("malformed", "unterminated multipart file part");
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const chunk = buffer.subarray(0, boundaryIndex);
|
|
646
|
+
size += chunk.length;
|
|
647
|
+
if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
648
|
+
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
649
|
+
await handle.write(chunk);
|
|
650
|
+
buffer = buffer.subarray(boundaryIndex + 2);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
} finally {
|
|
654
|
+
await handle.close();
|
|
655
|
+
}
|
|
656
|
+
if (filename === "" && size === 0) {
|
|
657
|
+
await (0, node_fs_promises.unlink)(path);
|
|
658
|
+
staged.splice(staged.indexOf(path), 1);
|
|
659
|
+
} else {
|
|
660
|
+
if (filename === "") {
|
|
661
|
+
fileCount += 1;
|
|
662
|
+
if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
663
|
+
}
|
|
664
|
+
const detected = detectMIME(head);
|
|
665
|
+
const declared = contentType ?? "application/octet-stream";
|
|
666
|
+
const validated = detected !== void 0 && detected === declared;
|
|
667
|
+
if (allowed !== void 0) {
|
|
668
|
+
if (!(detected !== void 0 && allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
|
|
669
|
+
}
|
|
670
|
+
if ((0, _orkestrel_server.isDangerousKey)(name)) {
|
|
671
|
+
await (0, node_fs_promises.unlink)(path);
|
|
672
|
+
staged.splice(staged.indexOf(path), 1);
|
|
673
|
+
} else {
|
|
674
|
+
const record = createUploadedFile({
|
|
675
|
+
field: name,
|
|
676
|
+
name: filename,
|
|
677
|
+
size,
|
|
678
|
+
mime: detected ?? declared,
|
|
679
|
+
validated,
|
|
680
|
+
status: "staged",
|
|
681
|
+
path
|
|
682
|
+
});
|
|
683
|
+
const existing = files[name];
|
|
684
|
+
if (existing === void 0) files[name] = [record];
|
|
685
|
+
else existing.push(record);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} else {
|
|
689
|
+
fieldCount += 1;
|
|
690
|
+
if (fieldCount > limits.fields) throw new MultipartError("limit", "too many multipart fields");
|
|
691
|
+
let value = Buffer.alloc(0);
|
|
692
|
+
for (;;) {
|
|
693
|
+
const boundaryIndex = buffer.indexOf(partDelimiter);
|
|
694
|
+
if (boundaryIndex === -1) {
|
|
695
|
+
const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
|
|
696
|
+
if (safeLength > 0) {
|
|
697
|
+
value = Buffer.concat([value, buffer.subarray(0, safeLength)]);
|
|
698
|
+
if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
699
|
+
buffer = buffer.subarray(safeLength);
|
|
700
|
+
}
|
|
701
|
+
if (!await pull()) throw new MultipartError("malformed", "unterminated multipart field part");
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
value = Buffer.concat([value, buffer.subarray(0, boundaryIndex)]);
|
|
705
|
+
if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
706
|
+
buffer = buffer.subarray(boundaryIndex + 2);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
if (!(0, _orkestrel_server.isDangerousKey)(name)) fields[name] = value.toString("utf8");
|
|
710
|
+
}
|
|
711
|
+
while (buffer.length < openMarker.length) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
712
|
+
buffer = buffer.subarray(openMarker.length);
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
await cleanup();
|
|
716
|
+
await reader.cancel().catch(() => {});
|
|
717
|
+
throw error;
|
|
718
|
+
} finally {
|
|
719
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
720
|
+
if (!ended) await reader.cancel().catch(() => {});
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
files: Object.freeze(files),
|
|
724
|
+
fields: Object.freeze(fields)
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Build a frozen {@link UploadedFileInterface} record.
|
|
729
|
+
*
|
|
730
|
+
* @param input - Every field of the record
|
|
731
|
+
* @returns A frozen {@link UploadedFileInterface}
|
|
732
|
+
*
|
|
733
|
+
* @example
|
|
734
|
+
* ```ts
|
|
735
|
+
* createUploadedFile({ field: 'avatar', name: 'a.png', size: 1024, mime: 'image/png', validated: true, status: 'staged', path: '/tmp/x' })
|
|
736
|
+
* ```
|
|
737
|
+
*/
|
|
738
|
+
function createUploadedFile(input) {
|
|
739
|
+
return Object.freeze({ ...input });
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Best-effort unlink every still-`'staged'` file in a parsed
|
|
743
|
+
* {@link MultipartBody} — the fail-closed cleanup `createMultipart` runs when
|
|
744
|
+
* its downstream handler throws, mirroring `parseMultipartRequest`'s own
|
|
745
|
+
* cleanup pattern (a missing file is already gone; failures are swallowed).
|
|
746
|
+
*
|
|
747
|
+
* @param body - The parsed multipart body to clean up
|
|
748
|
+
* @returns A promise that resolves once every staged file has been attempted
|
|
749
|
+
*
|
|
750
|
+
* @example
|
|
751
|
+
* ```ts
|
|
752
|
+
* await unlinkStagedFiles(body)
|
|
753
|
+
* ```
|
|
754
|
+
*/
|
|
755
|
+
async function unlinkStagedFiles(body) {
|
|
756
|
+
for (const records of Object.values(body.files)) for (const file of records) {
|
|
757
|
+
if (file.status !== "staged") continue;
|
|
758
|
+
try {
|
|
759
|
+
await (0, node_fs_promises.unlink)(file.path);
|
|
760
|
+
} catch {}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Adapt a `node:fs` read stream over `path` (or an already-open
|
|
765
|
+
* `FileHandle`) into a DOM-compatible `ReadableStream<Uint8Array>` — the
|
|
766
|
+
* single shared node↔web stream bridge every static-file and uploaded-file
|
|
767
|
+
* response body routes through.
|
|
768
|
+
*
|
|
769
|
+
* @remarks
|
|
770
|
+
* PULL-driven, not push-driven: the underlying node stream's async iterator
|
|
771
|
+
* is only advanced (`iterator.next()`) from inside `pull(controller)`, which
|
|
772
|
+
* the web `ReadableStream` invokes exactly when its internal queue has room
|
|
773
|
+
* for more data. Exactly one disk chunk is read and enqueued per `pull` —
|
|
774
|
+
* never more — so a slow or stalled consumer (a stalled HTTP connection)
|
|
775
|
+
* simply stops triggering `pull` calls and the source stops reading ahead;
|
|
776
|
+
* this is genuine consumer backpressure, not the "naturally backpressured"
|
|
777
|
+
* `for await`/`enqueue` pattern (which does not block on a slow consumer at
|
|
778
|
+
* all, since `enqueue` returns synchronously). The controller is closed on
|
|
779
|
+
* iterator completion and errored (never thrown into the process) on a
|
|
780
|
+
* mid-stream read failure. Cancelling the returned `ReadableStream` (e.g. the
|
|
781
|
+
* consumer aborts the response) calls the iterator's `return()`, which
|
|
782
|
+
* destroys the underlying node read stream so the file descriptor is
|
|
783
|
+
* released. When `path` is a `FileHandle`, `FileHandle.createReadStream`'s
|
|
784
|
+
* default `autoClose` closes the handle on every terminal path (end, error,
|
|
785
|
+
* or `destroy()` via the iterator's `return()`) — the caller never needs a
|
|
786
|
+
* separate `handle.close()` for a handle passed here.
|
|
787
|
+
*
|
|
788
|
+
* @param source - The absolute on-disk file path to stream, or an already-open
|
|
789
|
+
* `FileHandle` (e.g. one already `fstat`'d so the served bytes match the
|
|
790
|
+
* headers computed from that same `fstat`)
|
|
791
|
+
* @param range - An optional inclusive byte range (`start`/`end`, both
|
|
792
|
+
* 0-indexed and inclusive, matching `node:fs`'s `createReadStream` options)
|
|
793
|
+
* @returns A `ReadableStream<Uint8Array>` valid as a fetch `BodyInit`
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* ```ts
|
|
797
|
+
* new Response(streamFile('/srv/public/index.html'))
|
|
798
|
+
* ```
|
|
799
|
+
*/
|
|
800
|
+
function streamFile(source, range) {
|
|
801
|
+
const iterator = (typeof source === "string" ? range === void 0 ? (0, node_fs.createReadStream)(source) : (0, node_fs.createReadStream)(source, {
|
|
802
|
+
start: range.start,
|
|
803
|
+
end: range.end
|
|
804
|
+
}) : range === void 0 ? source.createReadStream() : source.createReadStream({
|
|
805
|
+
start: range.start,
|
|
806
|
+
end: range.end
|
|
807
|
+
}))[Symbol.asyncIterator]();
|
|
808
|
+
return new ReadableStream({
|
|
809
|
+
async pull(controller) {
|
|
810
|
+
try {
|
|
811
|
+
const { done, value } = await iterator.next();
|
|
812
|
+
if (done) {
|
|
813
|
+
controller.close();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!(value instanceof Uint8Array)) {
|
|
817
|
+
await iterator.return?.();
|
|
818
|
+
controller.error(/* @__PURE__ */ new TypeError("streamFile: read stream yielded a non-Uint8Array chunk"));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
controller.enqueue(value);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
await iterator.return?.();
|
|
824
|
+
controller.error(error);
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
async cancel() {
|
|
828
|
+
await iterator.return?.();
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Open a staged/moved uploaded file as a web `ReadableStream`.
|
|
834
|
+
*
|
|
835
|
+
* @param file - The {@link UploadedFileInterface} record to stream
|
|
836
|
+
* @returns A `ReadableStream<Uint8Array>` over the file's current on-disk path
|
|
837
|
+
*
|
|
838
|
+
* @example
|
|
839
|
+
* ```ts
|
|
840
|
+
* new Response(streamUploadedFile(file))
|
|
841
|
+
* ```
|
|
842
|
+
*/
|
|
843
|
+
function streamUploadedFile(file) {
|
|
844
|
+
return streamFile(file.path);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Read a staged/moved uploaded file's full contents into memory.
|
|
848
|
+
*
|
|
849
|
+
* @param file - The {@link UploadedFileInterface} record to read
|
|
850
|
+
* @returns The file's bytes
|
|
851
|
+
*
|
|
852
|
+
* @example
|
|
853
|
+
* ```ts
|
|
854
|
+
* const bytes = await readUploadedFile(file)
|
|
855
|
+
* ```
|
|
856
|
+
*/
|
|
857
|
+
async function readUploadedFile(file) {
|
|
858
|
+
return (0, node_fs_promises.readFile)(file.path);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Move a staged uploaded file to its final `destination`.
|
|
862
|
+
*
|
|
863
|
+
* @remarks
|
|
864
|
+
* Attempts a `rename` first; on a cross-device error (`EXDEV`) falls back to
|
|
865
|
+
* `copyFile` + `unlink`. Returns a new frozen record with `status: 'moved'`
|
|
866
|
+
* and `path: destination` — the input record is never mutated.
|
|
867
|
+
*
|
|
868
|
+
* @param file - The {@link UploadedFileInterface} record to move
|
|
869
|
+
* @param destination - The final on-disk path
|
|
870
|
+
* @returns A new {@link UploadedFileInterface} record reflecting the move
|
|
871
|
+
*
|
|
872
|
+
* @example
|
|
873
|
+
* ```ts
|
|
874
|
+
* const moved = await moveUploadedFile(file, '/var/uploads/final.png')
|
|
875
|
+
* ```
|
|
876
|
+
*/
|
|
877
|
+
async function moveUploadedFile(file, destination) {
|
|
878
|
+
try {
|
|
879
|
+
await (0, node_fs_promises.rename)(file.path, destination);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if ((0, _orkestrel_contract.isRecord)(error) && error.code === "EXDEV") {
|
|
882
|
+
await (0, node_fs_promises.copyFile)(file.path, destination);
|
|
883
|
+
await (0, node_fs_promises.unlink)(file.path);
|
|
884
|
+
} else throw error;
|
|
885
|
+
}
|
|
886
|
+
return createUploadedFile({
|
|
887
|
+
field: file.field,
|
|
888
|
+
name: file.name,
|
|
889
|
+
size: file.size,
|
|
890
|
+
mime: file.mime,
|
|
891
|
+
validated: file.validated,
|
|
892
|
+
status: "moved",
|
|
893
|
+
path: destination
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/server/middlewares.ts
|
|
898
|
+
/**
|
|
899
|
+
* Serve static files from `options.root` over `node:fs` — the node-bound
|
|
900
|
+
* static-file battery (PROPOSAL §4.14).
|
|
901
|
+
*
|
|
902
|
+
* @remarks
|
|
903
|
+
* Containment is enforced on CANONICAL paths, not merely the lexically
|
|
904
|
+
* resolved one: `options.root` is canonicalized once (memoized) and every
|
|
905
|
+
* request's candidate path is re-canonicalized (`fs.realpath`) before it is
|
|
906
|
+
* served, so a symlink whose target escapes `root` is refused (falls through
|
|
907
|
+
* to `next()`) even though the lexical path resolved inside `root`. A
|
|
908
|
+
* symlink that resolves to a target still INSIDE `root` is unaffected and
|
|
909
|
+
* still serves normally. A dangling symlink (`realpath` throws `ENOENT`) or
|
|
910
|
+
* any other `realpath` failure is treated as a miss — this battery never
|
|
911
|
+
* throws or 500s on a symlink surprise. On a streamed response (a 200 or 206
|
|
912
|
+
* that carries a file body), the open `FileHandle` is owned by the
|
|
913
|
+
* `Response` body and is released only once that body is fully read or
|
|
914
|
+
* cancelled — Node HTTP servers do this automatically when sending the
|
|
915
|
+
* response, but a caller that holds an unread `Response` (e.g. in a test)
|
|
916
|
+
* must cancel its body to release the handle promptly.
|
|
917
|
+
*
|
|
918
|
+
* @typeParam TState - The consumer's opaque per-request state type
|
|
919
|
+
* @param options - See {@link StaticOptions}
|
|
920
|
+
* @returns A `MiddlewareHandler<TState>`
|
|
921
|
+
* @throws {TypeError} When `options.root` is not a non-empty string
|
|
922
|
+
*
|
|
923
|
+
* @example
|
|
924
|
+
* ```ts
|
|
925
|
+
* import { createStatic } from '@orkestrel/middleware/server'
|
|
926
|
+
*
|
|
927
|
+
* const serveFiles = createStatic({ root: '/srv/public', fallback: true })
|
|
928
|
+
* ```
|
|
929
|
+
*/
|
|
930
|
+
function createStatic(options) {
|
|
931
|
+
if (!(0, _orkestrel_contract.isString)(options.root) || options.root.length === 0) throw new TypeError("createStatic requires options.root to be a non-empty string");
|
|
932
|
+
const root = (0, node_path.resolve)(options.root);
|
|
933
|
+
const index = options.index ?? "index.html";
|
|
934
|
+
const dotfiles = options.dotfiles ?? "ignore";
|
|
935
|
+
const useETag = options.etag ?? true;
|
|
936
|
+
const fallback = options.fallback === true ? { exclude: DEFAULT_STATIC_FALLBACK_EXCLUDE } : options.fallback === false || options.fallback === void 0 ? void 0 : { exclude: options.fallback.exclude ?? "/api" };
|
|
937
|
+
let canonicalRootPromise;
|
|
938
|
+
function canonicalRoot() {
|
|
939
|
+
if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
|
|
940
|
+
return canonicalRootPromise;
|
|
941
|
+
}
|
|
942
|
+
return async (request, context, next) => {
|
|
943
|
+
if (context.method !== "GET" && context.method !== "HEAD") return next();
|
|
944
|
+
const target = resolveStaticPath(root, options.prefix, context.url.pathname);
|
|
945
|
+
if (target === void 0) return next();
|
|
946
|
+
const relativePath = (0, node_path.relative)(root, target);
|
|
947
|
+
if (relativePath.length > 0 && isDotfilePath(relativePath)) {
|
|
948
|
+
if (dotfiles === "deny") throw new _orkestrel_server.HTTPError(403, "forbidden");
|
|
949
|
+
if (dotfiles === "ignore") return next();
|
|
950
|
+
}
|
|
951
|
+
let resolvedPath;
|
|
952
|
+
try {
|
|
953
|
+
const [rootReal, targetReal] = await Promise.all([canonicalRoot(), (0, node_fs_promises.realpath)(target)]);
|
|
954
|
+
if (!isContainedPath(targetReal, rootReal)) return next();
|
|
955
|
+
resolvedPath = targetReal;
|
|
956
|
+
} catch {
|
|
957
|
+
return trySpaFallback();
|
|
958
|
+
}
|
|
959
|
+
let directoryInfo;
|
|
960
|
+
try {
|
|
961
|
+
directoryInfo = await (0, node_fs_promises.stat)(resolvedPath);
|
|
962
|
+
} catch {
|
|
963
|
+
return trySpaFallback();
|
|
964
|
+
}
|
|
965
|
+
if (directoryInfo.isDirectory()) {
|
|
966
|
+
resolvedPath = (0, node_path.join)(resolvedPath, index);
|
|
967
|
+
try {
|
|
968
|
+
const [rootReal, indexReal] = await Promise.all([canonicalRoot(), (0, node_fs_promises.realpath)(resolvedPath)]);
|
|
969
|
+
if (!isContainedPath(indexReal, rootReal)) return trySpaFallback();
|
|
970
|
+
resolvedPath = indexReal;
|
|
971
|
+
} catch {
|
|
972
|
+
return trySpaFallback();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
let handle;
|
|
976
|
+
try {
|
|
977
|
+
handle = await (0, node_fs_promises.open)(resolvedPath, "r");
|
|
978
|
+
} catch {
|
|
979
|
+
return trySpaFallback();
|
|
980
|
+
}
|
|
981
|
+
let info;
|
|
982
|
+
try {
|
|
983
|
+
info = await handle.stat();
|
|
984
|
+
} catch {
|
|
985
|
+
await handle.close();
|
|
986
|
+
return trySpaFallback();
|
|
987
|
+
}
|
|
988
|
+
if (!info.isFile()) {
|
|
989
|
+
await handle.close();
|
|
990
|
+
return trySpaFallback();
|
|
991
|
+
}
|
|
992
|
+
let streaming = false;
|
|
993
|
+
try {
|
|
994
|
+
const headers = new Headers({
|
|
995
|
+
"content-type": lookupContentType(resolvedPath),
|
|
996
|
+
"accept-ranges": "bytes"
|
|
997
|
+
});
|
|
998
|
+
if (options.cache !== void 0) headers.set("cache-control", `max-age=${options.cache}`);
|
|
999
|
+
if (useETag) {
|
|
1000
|
+
const etag = computeFileETag(info.size, info.mtimeMs);
|
|
1001
|
+
headers.set("etag", etag);
|
|
1002
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
1003
|
+
if (ifNoneMatch !== null && (0, _orkestrel_server.matchesETag)(ifNoneMatch, etag)) {
|
|
1004
|
+
await handle.close();
|
|
1005
|
+
return new Response(null, {
|
|
1006
|
+
status: 304,
|
|
1007
|
+
headers
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (context.method === "HEAD") {
|
|
1012
|
+
await handle.close();
|
|
1013
|
+
headers.set("content-length", String(info.size));
|
|
1014
|
+
return new Response(null, {
|
|
1015
|
+
status: 200,
|
|
1016
|
+
headers
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
const rangeHeader = request.headers.get("range");
|
|
1020
|
+
const range = (0, _orkestrel_server.parseRange)(rangeHeader === null ? void 0 : rangeHeader, info.size);
|
|
1021
|
+
if (range === void 0) {
|
|
1022
|
+
headers.set("content-length", String(info.size));
|
|
1023
|
+
const body = streamFile(handle);
|
|
1024
|
+
streaming = true;
|
|
1025
|
+
return new Response(body, {
|
|
1026
|
+
status: 200,
|
|
1027
|
+
headers
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
if (!range.satisfiable) {
|
|
1031
|
+
await handle.close();
|
|
1032
|
+
headers.set("content-range", `bytes */${info.size}`);
|
|
1033
|
+
return new Response(null, {
|
|
1034
|
+
status: 416,
|
|
1035
|
+
headers
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
headers.set("content-range", `bytes ${range.start}-${range.end}/${info.size}`);
|
|
1039
|
+
headers.set("content-length", String(range.end - range.start + 1));
|
|
1040
|
+
const body = streamFile(handle, {
|
|
1041
|
+
start: range.start,
|
|
1042
|
+
end: range.end
|
|
1043
|
+
});
|
|
1044
|
+
streaming = true;
|
|
1045
|
+
return new Response(body, {
|
|
1046
|
+
status: 206,
|
|
1047
|
+
headers
|
|
1048
|
+
});
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (!streaming) await handle.close().catch(() => {});
|
|
1051
|
+
throw error;
|
|
1052
|
+
}
|
|
1053
|
+
function trySpaFallback() {
|
|
1054
|
+
if (fallback === void 0) return next();
|
|
1055
|
+
if (context.method !== "GET") return next();
|
|
1056
|
+
if ((0, node_path.extname)(context.url.pathname) !== "") return next();
|
|
1057
|
+
const accept = request.headers.get("accept") ?? "";
|
|
1058
|
+
if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
|
|
1059
|
+
if (isUnderPath(context.url.pathname, fallback.exclude)) return next();
|
|
1060
|
+
const shellPath = (0, node_path.join)(root, index);
|
|
1061
|
+
return Promise.all([canonicalRoot(), (0, node_fs_promises.realpath)(shellPath)]).then(([rootReal, shellReal]) => {
|
|
1062
|
+
if (!isContainedPath(shellReal, rootReal)) return next();
|
|
1063
|
+
return (0, node_fs_promises.open)(shellReal, "r").then((shellHandle) => {
|
|
1064
|
+
let shellStreaming = false;
|
|
1065
|
+
try {
|
|
1066
|
+
const body = streamFile(shellHandle);
|
|
1067
|
+
shellStreaming = true;
|
|
1068
|
+
return new Response(body, {
|
|
1069
|
+
status: 200,
|
|
1070
|
+
headers: new Headers({ "content-type": lookupContentType(shellReal) })
|
|
1071
|
+
});
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
if (!shellStreaming) shellHandle.close().catch(() => {});
|
|
1074
|
+
throw error;
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
}).catch(() => next());
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Parse a streamed `multipart/form-data` request body and stash its
|
|
1083
|
+
* {@link MultipartBody} on `context.state.multipart` — the node-bound
|
|
1084
|
+
* streaming multipart battery (PROPOSAL §4.15, ruling C).
|
|
1085
|
+
*
|
|
1086
|
+
* @remarks
|
|
1087
|
+
* A non-multipart request passes through untouched. Consumes `request.body`
|
|
1088
|
+
* as a stream — `context.body()` must not be called for a request this
|
|
1089
|
+
* battery has processed (the underlying stream is exhausted). Every
|
|
1090
|
+
* {@link MultipartError} this battery's parser throws is re-thrown as an
|
|
1091
|
+
* {@link HTTPError} carrying the same status/message, so `createBoundary`
|
|
1092
|
+
* (or any HTTPError-aware renderer) maps it correctly without depending on
|
|
1093
|
+
* this node face's error type. Fail-closed on the DOWNSTREAM handler too: if
|
|
1094
|
+
* `next()` throws, every still-`'staged'` uploaded file is unlinked
|
|
1095
|
+
* (best-effort) before the error is re-thrown, so an unhandled downstream
|
|
1096
|
+
* failure never leaks temp files. A normal return leaves staged files
|
|
1097
|
+
* untouched — the downstream handler owns moving/reading them.
|
|
1098
|
+
*
|
|
1099
|
+
* @typeParam TState - The consumer's state type, extending {@link MultipartState}
|
|
1100
|
+
* @param options - See {@link MultipartOptions}
|
|
1101
|
+
* @returns A `MiddlewareHandler<TState>`
|
|
1102
|
+
* @throws {HTTPError} When the underlying parse throws a {@link MultipartError}
|
|
1103
|
+
* (limit breach → 413, malformed structure → 400, rejected file type → 415)
|
|
1104
|
+
*
|
|
1105
|
+
* @example
|
|
1106
|
+
* ```ts
|
|
1107
|
+
* import { createMultipart } from '@orkestrel/middleware/server'
|
|
1108
|
+
*
|
|
1109
|
+
* const uploads = createMultipart({ allowed: ['image/png', 'image/jpeg'] })
|
|
1110
|
+
* ```
|
|
1111
|
+
*/
|
|
1112
|
+
function createMultipart(options = {}) {
|
|
1113
|
+
return async (request, context, next) => {
|
|
1114
|
+
let body;
|
|
1115
|
+
try {
|
|
1116
|
+
body = await parseMultipartRequest(request, options);
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
if (isMultipartError(error)) throw new _orkestrel_server.HTTPError(error.status, error.message, error.context);
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
if (body === void 0) return next();
|
|
1122
|
+
context.state.multipart = body;
|
|
1123
|
+
try {
|
|
1124
|
+
return await next();
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
await unlinkStagedFiles(body);
|
|
1127
|
+
throw error;
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Compress response bodies via `node:zlib` — the node-bound sibling of the
|
|
1133
|
+
* core face's `CompressionStream`-feature-detected `createCompression`,
|
|
1134
|
+
* guaranteed available on any Node runtime rather than dependent on the
|
|
1135
|
+
* WHATWG `CompressionStream` global (PROPOSAL §4.3, ruling J). Ships as a
|
|
1136
|
+
* SEPARATE package entry point (`@orkestrel/middleware/server`) from the core
|
|
1137
|
+
* face's `createCompression`, so the shared name is unambiguous per
|
|
1138
|
+
* consumer import path (ruling H).
|
|
1139
|
+
*
|
|
1140
|
+
* @remarks
|
|
1141
|
+
* Peer-type limitation (same one U1 recorded on the core face): the shipped
|
|
1142
|
+
* `@orkestrel/server` `Encoding` union is `'gzip' | 'deflate' | 'identity'`
|
|
1143
|
+
* — it does not include `'br'`, so this battery cannot honestly type or
|
|
1144
|
+
* negotiate a guaranteed brotli coding despite `node:zlib` shipping
|
|
1145
|
+
* `brotliCompress`. It guarantees `gzip`/`deflate` via `node:zlib` (never
|
|
1146
|
+
* feature-detected — always available) and negotiates only those.
|
|
1147
|
+
*
|
|
1148
|
+
* @typeParam TState - The consumer's opaque per-request state type
|
|
1149
|
+
* @param options - See {@link NodeCompressionOptions}
|
|
1150
|
+
* @returns A `MiddlewareHandler<TState>`
|
|
1151
|
+
* @throws {TypeError} When `options.threshold` is provided and is not a
|
|
1152
|
+
* finite number, or `options.filter` is provided and is not a function
|
|
1153
|
+
*
|
|
1154
|
+
* @example
|
|
1155
|
+
* ```ts
|
|
1156
|
+
* import { createCompression } from '@orkestrel/middleware/server'
|
|
1157
|
+
*
|
|
1158
|
+
* const compress = createCompression({ threshold: 512 })
|
|
1159
|
+
* ```
|
|
1160
|
+
*/
|
|
1161
|
+
function createCompression(options) {
|
|
1162
|
+
if (options?.threshold !== void 0 && !(0, _orkestrel_contract.isFiniteNumber)(options.threshold)) throw new TypeError("NodeCompressionOptions.threshold must be a finite number when provided");
|
|
1163
|
+
if (options?.filter !== void 0 && !(0, _orkestrel_contract.isFunction)(options.filter)) throw new TypeError("NodeCompressionOptions.filter must be a function when provided");
|
|
1164
|
+
const threshold = options?.threshold ?? _src_core.DEFAULT_COMPRESSION_THRESHOLD;
|
|
1165
|
+
const filter = options?.filter;
|
|
1166
|
+
const encodings = ["gzip", "deflate"];
|
|
1167
|
+
const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
|
|
1168
|
+
const deflateAsync = (0, node_util.promisify)(node_zlib.deflate);
|
|
1169
|
+
return async (request, context, next) => {
|
|
1170
|
+
return (0, _src_core.compressResponse)(request, context, await next(), {
|
|
1171
|
+
threshold,
|
|
1172
|
+
filter,
|
|
1173
|
+
encodings,
|
|
1174
|
+
compress: async (bytes, encoding) => encoding === "gzip" ? gzipAsync(bytes) : deflateAsync(bytes)
|
|
1175
|
+
});
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
//#endregion
|
|
1179
|
+
exports.DEFAULT_CONTENT_TYPE = DEFAULT_CONTENT_TYPE;
|
|
1180
|
+
exports.DEFAULT_MULTIPART_FIELD = DEFAULT_MULTIPART_FIELD;
|
|
1181
|
+
exports.DEFAULT_MULTIPART_FIELDS = DEFAULT_MULTIPART_FIELDS;
|
|
1182
|
+
exports.DEFAULT_MULTIPART_FILE = DEFAULT_MULTIPART_FILE;
|
|
1183
|
+
exports.DEFAULT_MULTIPART_FILES = DEFAULT_MULTIPART_FILES;
|
|
1184
|
+
exports.DEFAULT_MULTIPART_TOTAL = DEFAULT_MULTIPART_TOTAL;
|
|
1185
|
+
exports.DEFAULT_STATIC_FALLBACK_EXCLUDE = DEFAULT_STATIC_FALLBACK_EXCLUDE;
|
|
1186
|
+
exports.DEFAULT_STATIC_INDEX = DEFAULT_STATIC_INDEX;
|
|
1187
|
+
exports.EXTENSION_TYPES = EXTENSION_TYPES;
|
|
1188
|
+
exports.MULTIPART_MAX_HEADER_BLOCK = MULTIPART_MAX_HEADER_BLOCK;
|
|
1189
|
+
exports.MULTIPART_MAX_PREAMBLE = MULTIPART_MAX_PREAMBLE;
|
|
1190
|
+
exports.MULTIPART_REASON_STATUS = MULTIPART_REASON_STATUS;
|
|
1191
|
+
exports.MultipartError = MultipartError;
|
|
1192
|
+
exports.RESERVED_DEVICE_NAMES = RESERVED_DEVICE_NAMES;
|
|
1193
|
+
exports.computeFileETag = computeFileETag;
|
|
1194
|
+
exports.createCompression = createCompression;
|
|
1195
|
+
exports.createMultipart = createMultipart;
|
|
1196
|
+
exports.createStatic = createStatic;
|
|
1197
|
+
exports.createUploadedFile = createUploadedFile;
|
|
1198
|
+
exports.detectMIME = detectMIME;
|
|
1199
|
+
exports.isContainedPath = isContainedPath;
|
|
1200
|
+
exports.isDotfilePath = isDotfilePath;
|
|
1201
|
+
exports.isMultipartError = isMultipartError;
|
|
1202
|
+
exports.isReservedDeviceName = isReservedDeviceName;
|
|
1203
|
+
exports.isUnderPath = isUnderPath;
|
|
1204
|
+
exports.lookupContentType = lookupContentType;
|
|
1205
|
+
exports.moveUploadedFile = moveUploadedFile;
|
|
1206
|
+
exports.multipartBoundary = multipartBoundary;
|
|
1207
|
+
exports.parseMultipartRequest = parseMultipartRequest;
|
|
1208
|
+
exports.parsePartHeaders = parsePartHeaders;
|
|
1209
|
+
exports.readUploadedFile = readUploadedFile;
|
|
1210
|
+
exports.resolveDefaultDirectory = resolveDefaultDirectory;
|
|
1211
|
+
exports.resolveMultipartLimits = resolveMultipartLimits;
|
|
1212
|
+
exports.resolveStaticPath = resolveStaticPath;
|
|
1213
|
+
exports.streamFile = streamFile;
|
|
1214
|
+
exports.streamUploadedFile = streamUploadedFile;
|
|
1215
|
+
exports.unlinkStagedFiles = unlinkStagedFiles;
|
|
1216
|
+
|
|
1217
|
+
//# sourceMappingURL=index.cjs.map
|