@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,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