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