@orkestrel/middleware 0.0.18 → 0.0.19

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.
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_server = require("@orkestrel/server");
2
3
  let node_fs = require("node:fs");
3
4
  let node_fs_promises = require("node:fs/promises");
4
5
  let node_path = require("node:path");
@@ -7,37 +8,51 @@ let node_zlib = require("node:zlib");
7
8
  let _orkestrel_contract = require("@orkestrel/contract");
8
9
  let node_crypto = require("node:crypto");
9
10
  let node_os = require("node:os");
10
- let _orkestrel_server = require("@orkestrel/server");
11
11
  let _src_core = require("../core/index.cjs");
12
12
  //#region src/server/constants.ts
13
- /** The HTTP status `createMultipart` renders for each {@link MultipartReason}. */
14
- var MULTIPART_REASON_STATUS = Object.freeze({
13
+ /** Holds the HTTP status `createMultipart` renders for each {@link MultipartErrorCode}. */
14
+ var MULTIPART_STATUS = Object.freeze({
15
15
  limit: 413,
16
16
  malformed: 400,
17
17
  rejected: 415
18
18
  });
19
- /** `createStatic`'s default directory-index filename. */
19
+ /**
20
+ * Holds the `Symbol.for` brand {@link MultipartError} carries so
21
+ * {@link isMultipartError} recognizes an instance across duplicate copies of
22
+ * this package — a registry symbol rather than a module-local `Symbol()`,
23
+ * which would mint an unequal symbol per copy.
24
+ */
25
+ var MULTIPART_ERROR_BRAND = Symbol.for("@orkestrel/middleware.MultipartError");
26
+ /** Names `createStatic`'s default directory-index filename. */
20
27
  var DEFAULT_STATIC_INDEX = "index.html";
21
- /** `createStatic`'s `fallback: true` default excluded path prefix. */
28
+ /** Names `createStatic`'s `fallback: true` default excluded path prefix. */
22
29
  var DEFAULT_STATIC_FALLBACK_EXCLUDE = "/api";
23
- /** The MIME type served when a file extension has no known mapping. */
30
+ /** Names `createStatic`'s default policy for a path carrying a dotfile segment. */
31
+ var DEFAULT_STATIC_DOTFILES = "ignore";
32
+ /**
33
+ * Lists the content-codings the node face's `createCompression` offers — the two
34
+ * `node:zlib` guarantees on every Node runtime, so this face never
35
+ * feature-detects.
36
+ */
37
+ var NODE_COMPRESSION_ENCODINGS = Object.freeze(["gzip", "deflate"]);
38
+ /** Names the MIME type served when a file extension has no known mapping. */
24
39
  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. */
40
+ /** Holds `createMultipart`'s default per-file byte-size cap. */
41
+ var DEFAULT_MULTIPART_FILE_SIZE = 10485760;
42
+ /** Holds `createMultipart`'s default maximum file-part count. */
43
+ var DEFAULT_MULTIPART_FILE_COUNT = 10;
44
+ /** Holds `createMultipart`'s default per-field byte-size cap. */
45
+ var DEFAULT_MULTIPART_FIELD_SIZE = 65536;
46
+ /** Holds `createMultipart`'s default maximum field-part count. */
47
+ var DEFAULT_MULTIPART_FIELD_COUNT = 100;
48
+ /** Holds `createMultipart`'s default combined request-body byte-size cap. */
34
49
  var DEFAULT_MULTIPART_TOTAL = 52428800;
35
- /** The maximum bytes a single multipart part's header block may occupy before it is malformed. */
50
+ /** Holds the maximum bytes a single multipart part's header block may occupy before it is malformed. */
36
51
  var MULTIPART_MAX_HEADER_BLOCK = 16384;
37
- /** The maximum bytes scanned before the first multipart boundary is found before it is malformed. */
52
+ /** Holds the maximum bytes scanned before the first multipart boundary is found before it is malformed. */
38
53
  var MULTIPART_MAX_PREAMBLE = 65536;
39
54
  /**
40
- * Windows reserved device-name stems (CVE-2025-27210) — matched
55
+ * Lists the Windows reserved device-name stems (CVE-2025-27210) — matched
41
56
  * case-insensitively against the segment's stem (before its first `.`).
42
57
  */
43
58
  var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
@@ -64,7 +79,7 @@ var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
64
79
  "LPT8",
65
80
  "LPT9"
66
81
  ]));
67
- /** File-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. */
82
+ /** Holds the file-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. */
68
83
  var EXTENSION_TYPES = Object.freeze({
69
84
  ".html": "text/html; charset=utf-8",
70
85
  ".htm": "text/html; charset=utf-8",
@@ -90,15 +105,18 @@ var EXTENSION_TYPES = Object.freeze({
90
105
  //#endregion
91
106
  //#region src/server/errors.ts
92
107
  /**
93
- * An error `createMultipart` throws when a streamed multipart request fails
108
+ * Represents an error `createMultipart` throws when a streamed multipart request fails
94
109
  * a mid-stream limit, is structurally malformed, or has a file whose sniffed
95
110
  * bytes are rejected by the configured `allowed` MIME list.
96
111
  *
97
112
  * @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}.
113
+ * Extends the peer `HTTPError`, which already publishes the `status`,
114
+ * `context`, and brand members every fleet error of this shape carries, and
115
+ * adds the machine-readable `code` axis a caller narrows on. `status` is
116
+ * derived from `code` through {@link MULTIPART_STATUS} (limit → 413, malformed →
117
+ * 400, rejected → 415), so `createBoundary` — or any other `isHTTPError`-aware
118
+ * renderer — maps it without knowing this face's error type. Narrow a caught
119
+ * value to the richer type with {@link isMultipartError}.
102
120
  *
103
121
  * @example
104
122
  * ```ts
@@ -107,29 +125,26 @@ var EXTENSION_TYPES = Object.freeze({
107
125
  * throw new MultipartError('limit', 'too many files')
108
126
  * ```
109
127
  */
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
- if (context !== void 0) this.context = context;
119
- Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
128
+ var MultipartError = class extends _orkestrel_server.HTTPError {
129
+ code;
130
+ [MULTIPART_ERROR_BRAND] = true;
131
+ constructor(code, message, context) {
132
+ super(MULTIPART_STATUS[code], message, context);
133
+ this.name = "MultipartError";
134
+ this.code = code;
120
135
  }
121
136
  };
122
137
  /**
123
- * Narrow an unknown caught value to a {@link MultipartError}.
138
+ * Narrows an unknown caught value to a {@link MultipartError}.
124
139
  *
125
140
  * @remarks
126
141
  * 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'`).
142
+ * carrying {@link MULTIPART_ERROR_BRAND}, a numeric `status`, and a `code`
143
+ * in the parser's {@link MultipartErrorCode} set (`'limit' | 'malformed' | 'rejected'`).
129
144
  * Total: never throws, returns `false` for any off-shape input.
130
145
  *
131
146
  * @param value - The value to test (typically a `catch` binding)
132
- * @returns `true` when `value` is a {@link MultipartError}
147
+ * @returns True if `value` is a {@link MultipartError}; false otherwise
133
148
  *
134
149
  * @example
135
150
  * ```ts
@@ -138,233 +153,29 @@ var MultipartError = class extends Error {
138
153
  * try {
139
154
  * await parse(request)
140
155
  * } catch (error) {
141
- * if (isMultipartError(error)) console.log(error.status, error.reason)
156
+ * if (isMultipartError(error)) console.log(error.status, error.code)
142
157
  * }
143
158
  * ```
144
159
  */
145
160
  function isMultipartError(value) {
146
161
  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;
162
+ if (!(MULTIPART_ERROR_BRAND in value)) return false;
163
+ if (!("status" in value) || !("code" in value)) return false;
149
164
  if (typeof value.status !== "number") return false;
150
- if (value.reason !== "limit" && value.reason !== "malformed" && value.reason !== "rejected") return false;
165
+ if (value.code !== "limit" && value.code !== "malformed" && value.code !== "rejected") return false;
151
166
  return true;
152
167
  }
153
168
  //#endregion
154
- //#region src/server/MultipartParser.ts
155
- var MultipartParser = class MultipartParser {
156
- static #defaultDirectory;
157
- #reader;
158
- #signal;
159
- #abort;
160
- #boundary;
161
- #limits;
162
- #allowed;
163
- #directory;
164
- #staged = [];
165
- #files = Object.create(null);
166
- #fields = Object.create(null);
167
- #buffer = Buffer.alloc(0);
168
- #ended = false;
169
- #fileCount = 0;
170
- #fieldCount = 0;
171
- #totalBytes = 0;
172
- constructor(stream, signal, boundary, limits, allowed, directory) {
173
- this.#reader = stream.getReader();
174
- this.#signal = signal;
175
- this.#abort = this.#wakeReader.bind(this);
176
- this.#boundary = boundary;
177
- this.#limits = limits;
178
- this.#allowed = allowed;
179
- this.#directory = directory;
180
- }
181
- static directory() {
182
- if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
183
- return MultipartParser.#defaultDirectory;
184
- }
185
- async parse() {
186
- this.#signal.addEventListener("abort", this.#abort, { once: true });
187
- try {
188
- const openMarker = Buffer.from(`--${this.#boundary}`);
189
- let preambleScanned = 0;
190
- let index = this.#buffer.indexOf(openMarker);
191
- while (index === -1) {
192
- const carry = openMarker.length - 1;
193
- if (this.#buffer.length > carry) {
194
- const drop = this.#buffer.length - carry;
195
- preambleScanned += drop;
196
- if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
197
- this.#buffer = this.#buffer.subarray(drop);
198
- }
199
- if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
200
- index = this.#buffer.indexOf(openMarker);
201
- }
202
- if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
203
- this.#buffer = this.#buffer.subarray(index + openMarker.length);
204
- for (;;) {
205
- while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
206
- if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
207
- if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
208
- this.#buffer = this.#buffer.subarray(2);
209
- let headerEnd = this.#buffer.indexOf("\r\n\r\n");
210
- while (headerEnd === -1) {
211
- if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
212
- if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
213
- headerEnd = this.#buffer.indexOf("\r\n\r\n");
214
- }
215
- if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
216
- const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
217
- this.#buffer = this.#buffer.subarray(headerEnd + 4);
218
- const { name, filename, contentType } = parsePartHeaders(headerBlock);
219
- if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
220
- const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
221
- if (filename !== void 0) {
222
- if (filename !== "") {
223
- this.#fileCount += 1;
224
- if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
225
- }
226
- const path = (0, node_path.join)(this.#directory, (0, node_crypto.randomUUID)());
227
- this.#staged.push(path);
228
- const handle = await (0, node_fs_promises.open)(path, "w", 384);
229
- let size = 0;
230
- let head = Buffer.alloc(0);
231
- try {
232
- for (;;) {
233
- const boundaryIndex = this.#buffer.indexOf(partDelimiter);
234
- if (boundaryIndex === -1) {
235
- const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
236
- if (safeLength > 0) {
237
- const chunk = this.#buffer.subarray(0, safeLength);
238
- size += chunk.length;
239
- if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
240
- if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
241
- await handle.write(chunk);
242
- this.#buffer = this.#buffer.subarray(safeLength);
243
- }
244
- if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart file part");
245
- continue;
246
- }
247
- const chunk = this.#buffer.subarray(0, boundaryIndex);
248
- size += chunk.length;
249
- if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
250
- if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
251
- await handle.write(chunk);
252
- this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
253
- break;
254
- }
255
- } finally {
256
- await handle.close();
257
- }
258
- if (filename === "" && size === 0) {
259
- await (0, node_fs_promises.unlink)(path);
260
- this.#staged.splice(this.#staged.indexOf(path), 1);
261
- } else {
262
- if (filename === "") {
263
- this.#fileCount += 1;
264
- if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
265
- }
266
- const detected = detectMIME(head);
267
- const declared = contentType ?? "application/octet-stream";
268
- const validated = detected !== void 0 && detected === declared;
269
- if (this.#allowed !== void 0) {
270
- if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
271
- }
272
- if ((0, _orkestrel_server.isDangerousKey)(name)) {
273
- await (0, node_fs_promises.unlink)(path);
274
- this.#staged.splice(this.#staged.indexOf(path), 1);
275
- } else {
276
- const record = createUploadedFile({
277
- field: name,
278
- name: filename,
279
- size,
280
- mime: detected ?? declared,
281
- validated,
282
- status: "staged",
283
- path
284
- });
285
- const existing = this.#files[name];
286
- if (existing === void 0) this.#files[name] = [record];
287
- else existing.push(record);
288
- }
289
- }
290
- } else {
291
- this.#fieldCount += 1;
292
- if (this.#fieldCount > this.#limits.fields) throw new MultipartError("limit", "too many multipart fields");
293
- let value = Buffer.alloc(0);
294
- for (;;) {
295
- const boundaryIndex = this.#buffer.indexOf(partDelimiter);
296
- if (boundaryIndex === -1) {
297
- const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
298
- if (safeLength > 0) {
299
- value = Buffer.concat([value, this.#buffer.subarray(0, safeLength)]);
300
- if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
301
- this.#buffer = this.#buffer.subarray(safeLength);
302
- }
303
- if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart field part");
304
- continue;
305
- }
306
- value = Buffer.concat([value, this.#buffer.subarray(0, boundaryIndex)]);
307
- if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
308
- this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
309
- break;
310
- }
311
- if (!(0, _orkestrel_server.isDangerousKey)(name)) this.#fields[name] = value.toString("utf8");
312
- }
313
- while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
314
- this.#buffer = this.#buffer.subarray(openMarker.length);
315
- }
316
- } catch (error) {
317
- await this.#cleanup();
318
- await this.#reader.cancel().catch(() => {});
319
- throw error;
320
- } finally {
321
- this.#signal.removeEventListener("abort", this.#abort);
322
- if (!this.#ended) await this.#reader.cancel().catch(() => {});
323
- }
324
- return {
325
- files: Object.freeze(this.#files),
326
- fields: Object.freeze(this.#fields)
327
- };
328
- }
329
- static async #createDirectory() {
330
- const path = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-multipart-"));
331
- await (0, node_fs_promises.chmod)(path, 448);
332
- return path;
333
- }
334
- async #cleanup() {
335
- for (const path of this.#staged) try {
336
- await (0, node_fs_promises.unlink)(path);
337
- } catch {}
338
- }
339
- async #pull() {
340
- if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
341
- if (this.#ended) return false;
342
- const { done, value } = await this.#reader.read();
343
- if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
344
- if (done) {
345
- this.#ended = true;
346
- return false;
347
- }
348
- this.#totalBytes += value.byteLength;
349
- if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
350
- this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
351
- return true;
352
- }
353
- #wakeReader() {
354
- this.#reader.cancel().catch(() => {});
355
- }
356
- };
357
- //#endregion
358
169
  //#region src/server/helpers.ts
359
170
  /**
360
- * Whether `pathname` is `prefix` itself or lies under it on a SEGMENT
171
+ * Checks whether `pathname` is `prefix` itself or lies under it on a SEGMENT
361
172
  * boundary — the shared under-path test `resolveStaticPath`'s prefix strip
362
173
  * and `createStatic`'s SPA-fallback `exclude` both apply, so `exclude:
363
174
  * '/api'` matches `/api` and `/api/x` but never `/apifoo`.
364
175
  *
365
176
  * @param pathname - The request pathname to test
366
177
  * @param prefix - The path prefix to test against
367
- * @returns `true` when `pathname` equals `prefix` or starts with `prefix` + `/`
178
+ * @returns True if `pathname` equals `prefix` or starts with `prefix` + `/`; false otherwise
368
179
  *
369
180
  * @example
370
181
  * ```ts
@@ -378,13 +189,19 @@ function isUnderPath(pathname, prefix) {
378
189
  return pathname.startsWith(boundary);
379
190
  }
380
191
  /**
381
- * Resolve the fixed SPA shell path when a static-file miss is eligible for
192
+ * Resolves the fixed SPA shell path when a static-file miss is eligible for
382
193
  * fallback.
383
194
  *
195
+ * @remarks
196
+ * `GET` and `HEAD` are both eligible, and resolve the SAME shell: `HEAD` is
197
+ * defined as `GET` without a body (RFC 9110 §9.3.2), so a navigation probe
198
+ * that answered `404` while its `GET` answered `200` would report a resource
199
+ * the very next request serves.
200
+ *
384
201
  * @param root - The configured static root
385
202
  * @param index - The configured shell filename
386
203
  * @param exclude - The URL prefix excluded from fallback
387
- * @param method - The request method
204
+ * @param method - The request method; only `GET` and `HEAD` are eligible
388
205
  * @param pathname - The request pathname
389
206
  * @param accept - The request's `Accept` header value
390
207
  * @returns The fixed shell path, or `undefined` when fallback is ineligible
@@ -396,14 +213,14 @@ function isUnderPath(pathname, prefix) {
396
213
  * ```
397
214
  */
398
215
  function resolveStaticFallbackPath(root, index, exclude, method, pathname, accept) {
399
- if (method !== "GET") return void 0;
216
+ if (method !== "GET" && method !== "HEAD") return void 0;
400
217
  if ((0, node_path.extname)(pathname) !== "") return void 0;
401
218
  if (!accept.includes("text/html") && !accept.includes("*/*")) return void 0;
402
219
  if (isUnderPath(pathname, exclude)) return void 0;
403
220
  return (0, node_path.join)(root, index);
404
221
  }
405
222
  /**
406
- * Whether `child` is `parent` itself or lies inside it on-disk — the
223
+ * Checks whether `child` is `parent` itself or lies inside it on-disk — the
407
224
  * FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
408
225
  * output (never to a URL pathname — that is {@link isUnderPath}'s job).
409
226
  *
@@ -417,7 +234,7 @@ function resolveStaticFallbackPath(root, index, exclude, method, pathname, accep
417
234
  *
418
235
  * @param child - The absolute on-disk path to test
419
236
  * @param parent - The absolute on-disk directory it must lie under
420
- * @returns `true` when `child` equals `parent` or resolves inside it
237
+ * @returns True if `child` equals `parent` or resolves inside it; false otherwise
421
238
  *
422
239
  * @example
423
240
  * ```ts
@@ -431,9 +248,9 @@ function isContainedPath(child, parent) {
431
248
  return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${node_path.sep}`) && !(0, node_path.isAbsolute)(rel);
432
249
  }
433
250
  /**
434
- * Resolve a request pathname to an on-disk path UNDER `root`, or `undefined`
435
- * when it cannot — the traversal guard, EXACT algorithm and order (PROPOSAL
436
- * §4.14): strip `prefix` on a segment boundary → `decodeURIComponent` (a
251
+ * Resolves a request pathname to an on-disk path UNDER `root`, or `undefined`
252
+ * when it cannot — the traversal guard, whose algorithm and order are exact:
253
+ * strip `prefix` on a segment boundary → `decodeURIComponent` (a
437
254
  * malformed escape refuses, never throws) → reject a NUL byte → strip the
438
255
  * leading path separator FIRST (so a leading `..` survives `normalize` as a
439
256
  * genuine climbing segment) → `normalize` → refuse any Windows reserved-
@@ -470,10 +287,40 @@ function resolveStaticPath(root, prefix, pathname) {
470
287
  const segments = normalized.split(/[/\\]+/).filter((segment) => segment.length > 0);
471
288
  for (const segment of segments) if (isReservedDeviceName(segment)) return void 0;
472
289
  const resolved = (0, node_path.resolve)(root, normalized);
473
- if (resolved === root || resolved.startsWith(`${root}${node_path.sep}`)) return resolved;
290
+ return isContainedPath(resolved, root) ? resolved : void 0;
474
291
  }
475
292
  /**
476
- * Whether a path segment is a Windows reserved device name (CVE-2025-27210).
293
+ * Canonicalizes `candidate` and returns it only when it lies inside `rootReal`
294
+ * — the shared realpath-then-contain step `createStatic` applies to a
295
+ * directory index and to its SPA shell.
296
+ *
297
+ * @remarks
298
+ * Total: a `realpath` failure (a dangling symlink, a missing file, a
299
+ * permission refusal) and an escape from `rootReal` both resolve `undefined`,
300
+ * so a caller that treats those two outcomes identically needs no `try`. A
301
+ * caller that must tell them apart keeps its own explicit branch instead.
302
+ *
303
+ * @param candidate - The on-disk path to canonicalize
304
+ * @param rootReal - The already-canonical root the result must lie under
305
+ * @returns The canonical path inside `rootReal`, or `undefined`
306
+ *
307
+ * @example
308
+ * ```ts
309
+ * await resolveContainedRealPath('/srv/public/index.html', '/srv/public')
310
+ * // '/srv/public/index.html'
311
+ * ```
312
+ */
313
+ async function resolveContainedRealPath(candidate, rootReal) {
314
+ let real;
315
+ try {
316
+ real = await (0, node_fs_promises.realpath)(candidate);
317
+ } catch {
318
+ return;
319
+ }
320
+ return isContainedPath(real, rootReal) ? real : void 0;
321
+ }
322
+ /**
323
+ * Checks whether a path segment is a Windows reserved device name (CVE-2025-27210).
477
324
  *
478
325
  * @remarks
479
326
  * Normalizes superscript digits (`¹²³` → `123`) first, strips trailing dots
@@ -483,7 +330,7 @@ function resolveStaticPath(root, prefix, pathname) {
483
330
  * `console.js` and `nullable.css` are never flagged.
484
331
  *
485
332
  * @param segment - One path segment (no separators)
486
- * @returns `true` when `segment` names a reserved device
333
+ * @returns True if `segment` names a reserved device; false otherwise
487
334
  *
488
335
  * @example
489
336
  * ```ts
@@ -498,11 +345,11 @@ function isReservedDeviceName(segment) {
498
345
  return RESERVED_DEVICE_NAMES.has(stem.toUpperCase());
499
346
  }
500
347
  /**
501
- * Whether a relative path (already resolved under a static root) has any
348
+ * Checks whether a relative path (already resolved under a static root) has any
502
349
  * segment starting with `.` — a dotfile or dot-directory.
503
350
  *
504
351
  * @param relativePath - A path relative to the static root
505
- * @returns `true` when any segment starts with `.`
352
+ * @returns True if any segment starts with `.`; false otherwise
506
353
  *
507
354
  * @example
508
355
  * ```ts
@@ -514,7 +361,7 @@ function isDotfilePath(relativePath) {
514
361
  return relativePath.split(/[/\\]+/).some((segment) => segment.startsWith("."));
515
362
  }
516
363
  /**
517
- * Look up the MIME type for a static file path by its extension.
364
+ * Looks up the MIME type for a static file path by its extension.
518
365
  *
519
366
  * @param pathname - The file's path (only its extension is read)
520
367
  * @returns The mapped MIME type, or {@link DEFAULT_CONTENT_TYPE} when unknown
@@ -528,7 +375,7 @@ function lookupContentType(pathname) {
528
375
  return EXTENSION_TYPES[(0, node_path.extname)(pathname).toLowerCase()] ?? "application/octet-stream";
529
376
  }
530
377
  /**
531
- * Compute a static file's weak ETag from its size and modification time.
378
+ * Computes a static file's weak ETag from its size and modification time.
532
379
  *
533
380
  * @param size - The file's byte size
534
381
  * @param mtimeMs - The file's modification time in milliseconds
@@ -543,7 +390,7 @@ function computeFileETag(size, mtimeMs) {
543
390
  return `W/"${size}-${Math.floor(mtimeMs)}"`;
544
391
  }
545
392
  /**
546
- * Compress response bytes with Node's guaranteed zlib gzip/deflate codecs.
393
+ * Compresses response bytes with Node's guaranteed zlib gzip/deflate codecs.
547
394
  *
548
395
  * @param bytes - The uncompressed response bytes
549
396
  * @param encoding - The negotiated actionable coding
@@ -560,10 +407,16 @@ async function compressNodeBytes(bytes, encoding) {
560
407
  return Uint8Array.from(compressed);
561
408
  }
562
409
  /**
563
- * Sniff a MIME type from a file's leading bytes against a small magic-byte
564
- * table (jpeg, png, gif87a/89a, webp, pdf, zip) — the SNIFF-AUTHORITATIVE
565
- * signal `createMultipart`'s type validation rests on, never the declared
566
- * `Content-Type`.
410
+ * Sniffs a MIME type from a file's leading bytes against a small magic-byte
411
+ * table (jpeg, png, gif87a/89a, webp, pdf, zip).
412
+ *
413
+ * @remarks
414
+ * `createMultipart`'s `allowed` check reads this signal alone and never the
415
+ * declared `Content-Type`, so a file whose bytes match no signature can never
416
+ * be placed on an `allowed` list. The record's `mime` field is a different
417
+ * question: it falls back to the declared `Content-Type` (then to
418
+ * {@link DEFAULT_CONTENT_TYPE}) when nothing sniffs, and its `validated` flag
419
+ * reports whether the sniffed and declared types agreed.
567
420
  *
568
421
  * @param head - The file's first bytes (16 is sufficient for every signature)
569
422
  * @returns The detected MIME type, or `undefined` when no signature matches
@@ -641,12 +494,12 @@ function detectMIME(head) {
641
494
  ])) return "application/zip";
642
495
  }
643
496
  /**
644
- * Whether `bytes` contains `signature` at the requested offset.
497
+ * Checks whether `bytes` contains `signature` at the requested offset.
645
498
  *
646
499
  * @param bytes - The bytes to inspect
647
500
  * @param signature - The exact byte sequence to match
648
501
  * @param offset - The starting byte offset, defaulting to zero
649
- * @returns `true` when the complete signature matches
502
+ * @returns True if the complete signature matches; false otherwise
650
503
  *
651
504
  * @example
652
505
  * ```ts
@@ -659,7 +512,7 @@ function matchesBytes(bytes, signature, offset = 0) {
659
512
  return true;
660
513
  }
661
514
  /**
662
- * Extract the `boundary` parameter from a `Content-Type` header, or
515
+ * Extracts the `boundary` parameter from a `Content-Type` header, or
663
516
  * `undefined` when the request is not `multipart/form-data`.
664
517
  *
665
518
  * @param contentType - The request's `Content-Type` header value, if present
@@ -668,11 +521,11 @@ function matchesBytes(bytes, signature, offset = 0) {
668
521
  *
669
522
  * @example
670
523
  * ```ts
671
- * multipartBoundary('multipart/form-data; boundary=abc123') // 'abc123'
672
- * multipartBoundary('application/json') // undefined
524
+ * extractMultipartBoundary('multipart/form-data; boundary=abc123') // 'abc123'
525
+ * extractMultipartBoundary('application/json') // undefined
673
526
  * ```
674
527
  */
675
- function multipartBoundary(contentType) {
528
+ function extractMultipartBoundary(contentType) {
676
529
  if (contentType === null) return void 0;
677
530
  const [type, ...params] = contentType.split(";").map((part) => part.trim());
678
531
  if (type === void 0 || type.toLowerCase() !== "multipart/form-data") return void 0;
@@ -686,56 +539,49 @@ function multipartBoundary(contentType) {
686
539
  }
687
540
  }
688
541
  /**
689
- * Resolve `createMultipart`'s effective {@link MultipartLimits}, applying
690
- * every documented default.
542
+ * Resolves `createMultipart`'s effective {@link MultipartLimits}, applying
543
+ * every documented default to an omitted leaf.
691
544
  *
692
545
  * @param limits - The caller's partial limits
693
546
  * @returns The fully-resolved limits
694
- */
695
- function resolveMultipartLimits(limits) {
696
- return {
697
- file: limits?.file ?? 10485760,
698
- files: limits?.files ?? 10,
699
- field: limits?.field ?? 65536,
700
- fields: limits?.fields ?? 100,
701
- total: limits?.total ?? 52428800
702
- };
703
- }
704
- /**
705
- * Resolve `parseMultipartRequest`'s default staging directory when the
706
- * caller did not configure one — a process-owned directory created ONCE
707
- * (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
708
- * locked to mode `0o700`.
709
- *
710
- * @returns The absolute path of the process-owned staging directory
711
547
  *
712
548
  * @example
713
549
  * ```ts
714
- * const directory = await resolveDefaultDirectory()
550
+ * resolveMultipartLimits({ file: { size: 1_048_576 } })
715
551
  * ```
716
552
  */
717
- function resolveDefaultDirectory() {
718
- return MultipartParser.directory();
553
+ function resolveMultipartLimits(limits) {
554
+ return {
555
+ file: {
556
+ size: limits?.file?.size ?? 10485760,
557
+ count: limits?.file?.count ?? 10
558
+ },
559
+ field: {
560
+ size: limits?.field?.size ?? 65536,
561
+ count: limits?.field?.count ?? 100
562
+ },
563
+ total: limits?.total ?? 52428800
564
+ };
719
565
  }
720
566
  /**
721
- * Parse one multipart part's raw header block into its `name` (from
567
+ * Parses one multipart part's raw header block into its `name` (from
722
568
  * `Content-Disposition`), optional `filename`, and optional `Content-Type`.
723
569
  *
724
570
  * @param block - The raw header block for one multipart part (before the
725
571
  * terminating blank line)
726
- * @returns The parsed `name`, `filename`, and `contentType` (each
727
- * `undefined` when absent)
572
+ * @returns The parsed `name`, `filename`, and `mime` (each `undefined` when
573
+ * absent)
728
574
  *
729
575
  * @example
730
576
  * ```ts
731
577
  * parsePartHeaders('Content-Disposition: form-data; name="title"')
732
- * // { name: 'title', filename: undefined, contentType: undefined }
578
+ * // { name: 'title', filename: undefined, mime: undefined }
733
579
  * ```
734
580
  */
735
581
  function parsePartHeaders(block) {
736
582
  let name;
737
583
  let filename;
738
- let contentType;
584
+ let mime;
739
585
  for (const line of block.split("\r\n")) {
740
586
  const colon = line.indexOf(":");
741
587
  if (colon === -1) continue;
@@ -746,69 +592,19 @@ function parsePartHeaders(block) {
746
592
  const filenameMatch = /;\s*filename="([^"]*)"/.exec(value);
747
593
  if (nameMatch !== null) name = nameMatch[1];
748
594
  if (filenameMatch !== null) filename = filenameMatch[1];
749
- } else if (key === "content-type") contentType = value;
595
+ } else if (key === "content-type") mime = value;
750
596
  }
751
597
  return {
752
598
  name,
753
599
  filename,
754
- contentType
600
+ mime
755
601
  };
756
602
  }
757
603
  /**
758
- * Stream-parse a `multipart/form-data` request into its files and fields —
759
- * the mid-stream state machine `createMultipart` drives (PROPOSAL §4.15).
760
- *
761
- * @remarks
762
- * Reads `request.body` chunk by chunk via its `ReadableStream` reader —
763
- * NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
764
- * the instant it is exceeded (reading stops, every already-staged temp file
765
- * is deleted, throws {@link MultipartError} with reason `'limit'`). Each file
766
- * part streams to `join(directory, randomUUID())` — the client's declared
767
- * filename is METADATA ONLY, never a path component. A field OR file part
768
- * named `__proto__` / `constructor` / `prototype` is silently skipped and
769
- * never keyed onto the returned {@link MultipartBody} (a skipped file's
770
- * staged temp file is unlinked immediately, since it can never be
771
- * referenced). A file part with an empty declared filename (`filename=""`)
772
- * AND a zero-byte body — the browser convention for an unselected optional
773
- * `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
774
- * never counted against the `files` limit, and it never runs the `allowed`
775
- * check. A malformed
776
- * structure (missing/unterminated boundary, nameless part, an oversized
777
- * header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
778
- * the first boundary) throws with reason `'malformed'`. A file is accepted
779
- * against the configured `allowed` MIME list iff its SNIFFED bytes detect a
780
- * type present in the list — sniff-authoritative, independent of whether the
781
- * declared `Content-Type` matches (that agreement is exposed separately as
782
- * `validated`); otherwise throws with reason `'rejected'`. A
783
- * request abort mid-upload triggers the same fail-closed cleanup as a limit
784
- * breach. Returns `undefined` for a non-multipart request (untouched).
785
- *
786
- * @param request - The incoming multipart request
787
- * @param options - See {@link MultipartOptions}
788
- * @returns The parsed {@link MultipartBody}, or `undefined` when the request
789
- * is not `multipart/form-data`
790
- * @throws {MultipartError} On any limit breach, malformed structure, or
791
- * rejected file type
792
- *
793
- * @example
794
- * ```ts
795
- * const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
796
- * ```
797
- */
798
- async function parseMultipartRequest(request, options = {}) {
799
- const boundary = multipartBoundary(request.headers.get("content-type"));
800
- if (boundary === void 0) return void 0;
801
- if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
802
- const limits = resolveMultipartLimits(options.limits);
803
- const allowed = options.allowed;
804
- const directory = options.directory ?? await resolveDefaultDirectory();
805
- return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
806
- }
807
- /**
808
- * Build a frozen {@link UploadedFileInterface} record.
604
+ * Builds a frozen {@link UploadedFile} record.
809
605
  *
810
606
  * @param input - Every field of the record
811
- * @returns A frozen {@link UploadedFileInterface}
607
+ * @returns A frozen {@link UploadedFile}
812
608
  *
813
609
  * @example
814
610
  * ```ts
@@ -819,7 +615,7 @@ function createUploadedFile(input) {
819
615
  return Object.freeze({ ...input });
820
616
  }
821
617
  /**
822
- * Best-effort unlink every still-`'staged'` file in a parsed
618
+ * Attempts to unlink every still-`'staged'` file in a parsed
823
619
  * {@link MultipartBody} — the fail-closed cleanup `createMultipart` runs when
824
620
  * its downstream handler throws, mirroring `parseMultipartRequest`'s own
825
621
  * cleanup pattern (a missing file is already gone; failures are swallowed).
@@ -841,7 +637,7 @@ async function unlinkStagedFiles(body) {
841
637
  }
842
638
  }
843
639
  /**
844
- * Adapt a `node:fs` read stream over `path` (or an already-open
640
+ * Adapts a `node:fs` read stream over a file path (or an already-open
845
641
  * `FileHandle`) into a DOM-compatible `ReadableStream<Uint8Array>` — the
846
642
  * single shared node↔web stream bridge every static-file and uploaded-file
847
643
  * response body routes through.
@@ -852,24 +648,23 @@ async function unlinkStagedFiles(body) {
852
648
  * the web `ReadableStream` invokes exactly when its internal queue has room
853
649
  * for more data. Exactly one disk chunk is read and enqueued per `pull` —
854
650
  * never more — so a slow or stalled consumer (a stalled HTTP connection)
855
- * simply stops triggering `pull` calls and the source stops reading ahead;
651
+ * stops triggering `pull` calls and the source stops reading ahead;
856
652
  * this is genuine consumer backpressure, not the "naturally backpressured"
857
653
  * `for await`/`enqueue` pattern (which does not block on a slow consumer at
858
- * all, since `enqueue` returns synchronously). The controller is closed on
654
+ * all, because `enqueue` returns synchronously). The controller is closed on
859
655
  * iterator completion and errored (never thrown into the process) on a
860
- * mid-stream read failure. Cancelling the returned `ReadableStream` (e.g. the
861
- * consumer aborts the response) calls the iterator's `return()`, which
656
+ * mid-stream read failure. Cancelling the returned `ReadableStream` (for
657
+ * example the consumer aborts the response) calls the iterator's `return()`, which
862
658
  * destroys the underlying node read stream so the file descriptor is
863
- * released. When `path` is a `FileHandle`, `FileHandle.createReadStream`'s
659
+ * released. When `source` is a `FileHandle`, `FileHandle.createReadStream`'s
864
660
  * default `autoClose` closes the handle on every terminal path (end, error,
865
- * or `destroy()` via the iterator's `return()`) — the caller never needs a
866
- * separate `handle.close()` for a handle passed here.
661
+ * or `destroy()` through the iterator's `return()`) — the caller never needs a
662
+ * separate `handle.close()` for a `FileHandle` passed as `source`.
867
663
  *
868
664
  * @param source - The absolute on-disk file path to stream, or an already-open
869
- * `FileHandle` (e.g. one already `fstat`'d so the served bytes match the
665
+ * `FileHandle` (for example one already `fstat`'d so the served bytes match the
870
666
  * headers computed from that same `fstat`)
871
- * @param range - An optional inclusive byte range (`start`/`end`, both
872
- * 0-indexed and inclusive, matching `node:fs`'s `createReadStream` options)
667
+ * @param range - An optional inclusive byte range; see {@link ByteRange}
873
668
  * @returns A `ReadableStream<Uint8Array>` valid as a fetch `BodyInit`
874
669
  *
875
670
  * @example
@@ -910,9 +705,9 @@ function streamFile(source, range) {
910
705
  });
911
706
  }
912
707
  /**
913
- * Open a staged/moved uploaded file as a web `ReadableStream`.
708
+ * Opens a staged/moved uploaded file as a web `ReadableStream`.
914
709
  *
915
- * @param file - The {@link UploadedFileInterface} record to stream
710
+ * @param file - The {@link UploadedFile} record to stream
916
711
  * @returns A `ReadableStream<Uint8Array>` over the file's current on-disk path
917
712
  *
918
713
  * @example
@@ -924,9 +719,9 @@ function streamUploadedFile(file) {
924
719
  return streamFile(file.path);
925
720
  }
926
721
  /**
927
- * Read a staged/moved uploaded file's full contents into memory.
722
+ * Reads a staged/moved uploaded file's full contents into memory.
928
723
  *
929
- * @param file - The {@link UploadedFileInterface} record to read
724
+ * @param file - The {@link UploadedFile} record to read
930
725
  * @returns The file's bytes
931
726
  *
932
727
  * @example
@@ -938,16 +733,18 @@ async function readUploadedFile(file) {
938
733
  return (0, node_fs_promises.readFile)(file.path);
939
734
  }
940
735
  /**
941
- * Move a staged uploaded file to its final `destination`.
736
+ * Moves a staged uploaded file to its final `destination`.
942
737
  *
943
738
  * @remarks
944
739
  * Attempts a `rename` first; on a cross-device error (`EXDEV`) falls back to
945
740
  * `copyFile` + `unlink`. Returns a new frozen record with `status: 'moved'`
946
- * and `path: destination` — the input record is never mutated.
741
+ * and `path: destination` — the input record is never mutated. The suite
742
+ * drives the `EXDEV` fallback only on a host whose device probe finds a
743
+ * second filesystem, so a single-device host leaves that branch unproven.
947
744
  *
948
- * @param file - The {@link UploadedFileInterface} record to move
745
+ * @param file - The {@link UploadedFile} record to move
949
746
  * @param destination - The final on-disk path
950
- * @returns A new {@link UploadedFileInterface} record reflecting the move
747
+ * @returns A new {@link UploadedFile} record reflecting the move
951
748
  *
952
749
  * @example
953
750
  * ```ts
@@ -958,7 +755,7 @@ async function moveUploadedFile(file, destination) {
958
755
  try {
959
756
  await (0, node_fs_promises.rename)(file.path, destination);
960
757
  } catch (error) {
961
- if ((0, _orkestrel_contract.isRecord)(error) && error.code === "EXDEV") {
758
+ if ((0, _orkestrel_contract.isError)(error) && "code" in error && error.code === "EXDEV") {
962
759
  await (0, node_fs_promises.copyFile)(file.path, destination);
963
760
  await (0, node_fs_promises.unlink)(file.path);
964
761
  } else throw error;
@@ -974,9 +771,267 @@ async function moveUploadedFile(file, destination) {
974
771
  });
975
772
  }
976
773
  //#endregion
774
+ //#region src/server/MultipartParser.ts
775
+ var MultipartParser = class MultipartParser {
776
+ static #defaultDirectory;
777
+ #reader;
778
+ #signal;
779
+ #abort;
780
+ #boundary;
781
+ #limits;
782
+ #allowed;
783
+ #directory;
784
+ #staged = [];
785
+ #files = Object.create(null);
786
+ #fields = Object.create(null);
787
+ #buffer = Buffer.alloc(0);
788
+ #ended = false;
789
+ #fileCount = 0;
790
+ #fieldCount = 0;
791
+ #totalBytes = 0;
792
+ constructor(stream, signal, boundary, limits, allowed, directory) {
793
+ this.#reader = stream.getReader();
794
+ this.#signal = signal;
795
+ this.#abort = this.#wakeReader.bind(this);
796
+ this.#boundary = boundary;
797
+ this.#limits = limits;
798
+ this.#allowed = allowed;
799
+ this.#directory = directory;
800
+ }
801
+ static directory() {
802
+ if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
803
+ return MultipartParser.#defaultDirectory;
804
+ }
805
+ async parse() {
806
+ this.#signal.addEventListener("abort", this.#abort, { once: true });
807
+ try {
808
+ const openMarker = Buffer.from(`--${this.#boundary}`);
809
+ let preambleScanned = 0;
810
+ let index = this.#buffer.indexOf(openMarker);
811
+ while (index === -1) {
812
+ const carry = openMarker.length - 1;
813
+ if (this.#buffer.length > carry) {
814
+ const drop = this.#buffer.length - carry;
815
+ preambleScanned += drop;
816
+ if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
817
+ this.#buffer = this.#buffer.subarray(drop);
818
+ }
819
+ if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
820
+ index = this.#buffer.indexOf(openMarker);
821
+ }
822
+ if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
823
+ this.#buffer = this.#buffer.subarray(index + openMarker.length);
824
+ for (;;) {
825
+ while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
826
+ if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
827
+ if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
828
+ this.#buffer = this.#buffer.subarray(2);
829
+ let headerEnd = this.#buffer.indexOf("\r\n\r\n");
830
+ while (headerEnd === -1) {
831
+ if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
832
+ if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
833
+ headerEnd = this.#buffer.indexOf("\r\n\r\n");
834
+ }
835
+ if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
836
+ const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
837
+ this.#buffer = this.#buffer.subarray(headerEnd + 4);
838
+ const { name, filename, mime } = parsePartHeaders(headerBlock);
839
+ if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
840
+ const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
841
+ if (filename !== void 0) await this.#consumeFile(name, filename, mime, partDelimiter);
842
+ else await this.#consumeField(name, partDelimiter);
843
+ while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
844
+ this.#buffer = this.#buffer.subarray(openMarker.length);
845
+ }
846
+ } catch (error) {
847
+ await this.#cleanup();
848
+ await this.#reader.cancel().catch(() => {});
849
+ throw error;
850
+ } finally {
851
+ this.#signal.removeEventListener("abort", this.#abort);
852
+ if (!this.#ended) await this.#reader.cancel().catch(() => {});
853
+ }
854
+ const files = Object.create(null);
855
+ for (const [field, records] of Object.entries(this.#files)) files[field] = Object.freeze([...records]);
856
+ const fields = Object.assign(Object.create(null), this.#fields);
857
+ return {
858
+ files: Object.freeze(files),
859
+ fields: Object.freeze(fields)
860
+ };
861
+ }
862
+ async #consumeFile(name, filename, mime, delimiter) {
863
+ if (filename !== "") {
864
+ this.#fileCount += 1;
865
+ if (this.#fileCount > this.#limits.file.count) throw new MultipartError("limit", "too many multipart files");
866
+ }
867
+ const path = (0, node_path.join)(this.#directory, (0, node_crypto.randomUUID)());
868
+ this.#staged.push(path);
869
+ const handle = await (0, node_fs_promises.open)(path, "w", 384);
870
+ let size = 0;
871
+ let head = Buffer.alloc(0);
872
+ try {
873
+ await this.#scan(delimiter, "unterminated multipart file part", async (chunk) => {
874
+ size += chunk.length;
875
+ if (size > this.#limits.file.size) throw new MultipartError("limit", "multipart file exceeds size limit");
876
+ if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
877
+ await handle.write(chunk);
878
+ });
879
+ } finally {
880
+ await handle.close();
881
+ }
882
+ if (filename === "" && size === 0) {
883
+ await this.#discard(path);
884
+ return;
885
+ }
886
+ if (filename === "") {
887
+ this.#fileCount += 1;
888
+ if (this.#fileCount > this.#limits.file.count) throw new MultipartError("limit", "too many multipart files");
889
+ }
890
+ const detected = detectMIME(head);
891
+ const declared = mime ?? "application/octet-stream";
892
+ const validated = detected !== void 0 && detected === declared;
893
+ if (this.#allowed !== void 0) {
894
+ if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
895
+ }
896
+ if ((0, _orkestrel_server.isDangerousKey)(name)) {
897
+ await this.#discard(path);
898
+ return;
899
+ }
900
+ const record = createUploadedFile({
901
+ field: name,
902
+ name: filename,
903
+ size,
904
+ mime: detected ?? declared,
905
+ validated,
906
+ status: "staged",
907
+ path
908
+ });
909
+ const existing = this.#files[name];
910
+ if (existing === void 0) this.#files[name] = [record];
911
+ else existing.push(record);
912
+ }
913
+ async #consumeField(name, delimiter) {
914
+ this.#fieldCount += 1;
915
+ if (this.#fieldCount > this.#limits.field.count) throw new MultipartError("limit", "too many multipart fields");
916
+ let value = Buffer.alloc(0);
917
+ await this.#scan(delimiter, "unterminated multipart field part", (chunk) => {
918
+ value = Buffer.concat([value, chunk]);
919
+ if (value.length > this.#limits.field.size) throw new MultipartError("limit", "multipart field exceeds size limit");
920
+ });
921
+ if (!(0, _orkestrel_server.isDangerousKey)(name)) this.#fields[name] = value.toString("utf8");
922
+ }
923
+ async #scan(delimiter, unterminated, sink) {
924
+ for (;;) {
925
+ const boundaryIndex = this.#buffer.indexOf(delimiter);
926
+ if (boundaryIndex === -1) {
927
+ const safeLength = Math.max(0, this.#buffer.length - (delimiter.length - 1));
928
+ if (safeLength > 0) {
929
+ await sink(this.#buffer.subarray(0, safeLength));
930
+ this.#buffer = this.#buffer.subarray(safeLength);
931
+ }
932
+ if (!await this.#pull()) throw new MultipartError("malformed", unterminated);
933
+ continue;
934
+ }
935
+ await sink(this.#buffer.subarray(0, boundaryIndex));
936
+ this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
937
+ return;
938
+ }
939
+ }
940
+ async #discard(path) {
941
+ try {
942
+ await (0, node_fs_promises.unlink)(path);
943
+ } catch {}
944
+ const index = this.#staged.indexOf(path);
945
+ if (index !== -1) this.#staged.splice(index, 1);
946
+ }
947
+ static async #createDirectory() {
948
+ const path = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-multipart-"));
949
+ await (0, node_fs_promises.chmod)(path, 448);
950
+ return path;
951
+ }
952
+ async #cleanup() {
953
+ for (const path of this.#staged) try {
954
+ await (0, node_fs_promises.unlink)(path);
955
+ } catch {}
956
+ }
957
+ async #pull() {
958
+ if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
959
+ if (this.#ended) return false;
960
+ const { done, value } = await this.#reader.read();
961
+ if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
962
+ if (done) {
963
+ this.#ended = true;
964
+ return false;
965
+ }
966
+ this.#totalBytes += value.byteLength;
967
+ if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
968
+ this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
969
+ return true;
970
+ }
971
+ #wakeReader() {
972
+ this.#reader.cancel().catch(() => {});
973
+ }
974
+ };
975
+ //#endregion
976
+ //#region src/server/parsers.ts
977
+ /**
978
+ * Stream-parses a `multipart/form-data` request into its files and fields —
979
+ * the mid-stream state machine `createMultipart` drives.
980
+ *
981
+ * @remarks
982
+ * Reads `request.body` chunk by chunk through its `ReadableStream` reader —
983
+ * NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
984
+ * the instant it is exceeded (reading stops, every already-staged temp file
985
+ * is deleted, throws {@link MultipartError} with code `'limit'`). Each file
986
+ * part streams to `join(directory, randomUUID())` — the client's declared
987
+ * filename is METADATA ONLY, never a path component. A field OR file part
988
+ * named `__proto__` / `constructor` / `prototype` is silently skipped and
989
+ * never keyed onto the returned {@link MultipartBody} (a skipped file's
990
+ * staged temp file is unlinked immediately, since it can never be
991
+ * referenced). A file part with an empty declared filename (`filename=""`)
992
+ * AND a zero-byte body — the browser convention for an unselected optional
993
+ * `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
994
+ * never counted against the `file.count` limit, and it never runs the
995
+ * `allowed` check. A malformed
996
+ * structure (missing/unterminated boundary, nameless part, an oversized
997
+ * header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
998
+ * the first boundary) throws with code `'malformed'`. A file is accepted
999
+ * against the configured `allowed` MIME list iff its SNIFFED bytes detect a
1000
+ * type present in the list — sniff-authoritative, independent of whether the
1001
+ * declared `Content-Type` matches (that agreement is exposed separately as
1002
+ * `validated`); otherwise throws with code `'rejected'`. A
1003
+ * request abort mid-upload triggers the same fail-closed cleanup as a limit
1004
+ * breach. Returns `undefined` for a non-multipart request (untouched).
1005
+ *
1006
+ * Staging defaults to a process-owned directory created ONCE (lazily,
1007
+ * memoized across calls) with `mkdtemp` under `os.tmpdir()` and locked to
1008
+ * mode `0o700`; `options.directory` overrides it.
1009
+ *
1010
+ * @param request - The incoming multipart request
1011
+ * @param options - See {@link MultipartOptions}
1012
+ * @returns The parsed {@link MultipartBody}, or `undefined` when the request
1013
+ * is not `multipart/form-data`
1014
+ * @throws {MultipartError} On any limit breach, malformed structure, or
1015
+ * rejected file type
1016
+ *
1017
+ * @example
1018
+ * ```ts
1019
+ * const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
1020
+ * ```
1021
+ */
1022
+ async function parseMultipartRequest(request, options = {}) {
1023
+ const boundary = extractMultipartBoundary(request.headers.get("content-type"));
1024
+ if (boundary === void 0) return void 0;
1025
+ if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
1026
+ const limits = resolveMultipartLimits(options.limits);
1027
+ const allowed = options.allowed;
1028
+ const directory = options.directory ?? await MultipartParser.directory();
1029
+ return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
1030
+ }
1031
+ //#endregion
977
1032
  //#region src/server/middlewares.ts
978
1033
  /**
979
- * Serve validated in-memory assets with identity/Brotli negotiation.
1034
+ * Serves validated in-memory assets with identity/Brotli negotiation.
980
1035
  *
981
1036
  * @remarks
982
1037
  * Only `GET` and `HEAD` are served. `/` resolves to `index.html`. Every other
@@ -1069,8 +1124,8 @@ function createAssets(options) {
1069
1124
  };
1070
1125
  }
1071
1126
  /**
1072
- * Serve static files from `options.root` over `node:fs` — the node-bound
1073
- * static-file battery (PROPOSAL §4.14).
1127
+ * Serves static files from `options.root` over `node:fs` — the node-bound
1128
+ * static-file battery.
1074
1129
  *
1075
1130
  * @remarks
1076
1131
  * Containment is enforced on CANONICAL paths, not merely the lexically
@@ -1085,7 +1140,7 @@ function createAssets(options) {
1085
1140
  * that carries a file body), the open `FileHandle` is owned by the
1086
1141
  * `Response` body and is released only once that body is fully read or
1087
1142
  * cancelled — Node HTTP servers do this automatically when sending the
1088
- * response, but a caller that holds an unread `Response` (e.g. in a test)
1143
+ * response, but a caller that holds an unread `Response` (for example in a test)
1089
1144
  * must cancel its body to release the handle promptly.
1090
1145
  *
1091
1146
  * @typeParam TState - The consumer's opaque per-request state type
@@ -1137,9 +1192,10 @@ function createStatic(options) {
1137
1192
  resolvedPath = (0, node_path.join)(resolvedPath, index);
1138
1193
  try {
1139
1194
  if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
1140
- const [rootReal, indexReal] = await Promise.all([canonicalRootPromise, (0, node_fs_promises.realpath)(resolvedPath)]);
1141
- if (isContainedPath(indexReal, rootReal)) resolvedPath = indexReal;
1142
- else fallbackNeeded = true;
1195
+ const rootReal = await canonicalRootPromise;
1196
+ const indexReal = await resolveContainedRealPath(resolvedPath, rootReal);
1197
+ if (indexReal === void 0) fallbackNeeded = true;
1198
+ else resolvedPath = indexReal;
1143
1199
  } catch {
1144
1200
  fallbackNeeded = true;
1145
1201
  }
@@ -1170,8 +1226,8 @@ function createStatic(options) {
1170
1226
  let shellReal;
1171
1227
  try {
1172
1228
  if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
1173
- const [rootReal, candidate] = await Promise.all([canonicalRootPromise, (0, node_fs_promises.realpath)(shellPath)]);
1174
- if (!isContainedPath(candidate, rootReal)) return next();
1229
+ const candidate = await resolveContainedRealPath(shellPath, await canonicalRootPromise);
1230
+ if (candidate === void 0) return next();
1175
1231
  shellReal = candidate;
1176
1232
  } catch {
1177
1233
  return next();
@@ -1182,16 +1238,20 @@ function createStatic(options) {
1182
1238
  } catch {
1183
1239
  return next();
1184
1240
  }
1241
+ let shellInfo;
1185
1242
  try {
1186
- const body = streamFile(shellHandle);
1187
- return new Response(body, {
1188
- status: 200,
1189
- headers: new Headers({ "content-type": lookupContentType(shellReal) })
1190
- });
1191
- } catch (error) {
1243
+ shellInfo = await shellHandle.stat();
1244
+ } catch {
1192
1245
  await shellHandle.close().catch(() => {});
1193
- throw error;
1246
+ return next();
1247
+ }
1248
+ if (!shellInfo.isFile()) {
1249
+ await shellHandle.close().catch(() => {});
1250
+ return next();
1194
1251
  }
1252
+ resolvedPath = shellReal;
1253
+ handle = shellHandle;
1254
+ info = shellInfo;
1195
1255
  }
1196
1256
  if (handle === void 0 || info === void 0) return next();
1197
1257
  let streaming = false;
@@ -1258,9 +1318,9 @@ function createStatic(options) {
1258
1318
  };
1259
1319
  }
1260
1320
  /**
1261
- * Parse a streamed `multipart/form-data` request body and stash its
1321
+ * Parses a streamed `multipart/form-data` request body and stashes its
1262
1322
  * {@link MultipartBody} on `context.state.multipart` — the node-bound
1263
- * streaming multipart battery (PROPOSAL §4.15, ruling C).
1323
+ * streaming multipart battery.
1264
1324
  *
1265
1325
  * @remarks
1266
1326
  * A non-multipart request passes through untouched. Consumes `request.body`
@@ -1308,20 +1368,19 @@ function createMultipart(options = {}) {
1308
1368
  };
1309
1369
  }
1310
1370
  /**
1311
- * Compress response bodies via `node:zlib` — the node-bound sibling of the
1312
- * core face's `CompressionStream`-feature-detected `createCompression`,
1371
+ * Compresses response bodies through `node:zlib` — the node-bound sibling of
1372
+ * the core face's `CompressionStream`-feature-detected `createCompression`,
1313
1373
  * guaranteed available on any Node runtime rather than dependent on the
1314
- * WHATWG `CompressionStream` global (PROPOSAL §4.3, ruling J). Ships as a
1315
- * SEPARATE package entry point (`@orkestrel/middleware/server`) from the core
1316
- * face's `createCompression`, so the shared name is unambiguous per
1317
- * consumer import path (ruling H).
1374
+ * WHATWG `CompressionStream` global. Ships as a SEPARATE package entry point
1375
+ * (`@orkestrel/middleware/server`) from the core face's `createCompression`,
1376
+ * so the shared name is unambiguous per consumer import path.
1318
1377
  *
1319
1378
  * @remarks
1320
- * Peer-type limitation (same one U1 recorded on the core face): the shipped
1379
+ * Peer-type limitation, the same one the core face carries: the shipped
1321
1380
  * `@orkestrel/server` `Encoding` union is `'gzip' | 'deflate' | 'identity'`
1322
1381
  * — it does not include `'br'`, so this battery cannot honestly type or
1323
1382
  * negotiate a guaranteed brotli coding despite `node:zlib` shipping
1324
- * `brotliCompress`. It guarantees `gzip`/`deflate` via `node:zlib` (never
1383
+ * `brotliCompress`. It guarantees `gzip`/`deflate` through `node:zlib` (never
1325
1384
  * feature-detected — always available) and negotiates only those.
1326
1385
  *
1327
1386
  * @typeParam TState - The consumer's opaque per-request state type
@@ -1342,7 +1401,7 @@ function createCompression(options) {
1342
1401
  if (options?.filter !== void 0 && !(0, _orkestrel_contract.isFunction)(options.filter)) throw new TypeError("NodeCompressionOptions.filter must be a function when provided");
1343
1402
  const threshold = options?.threshold ?? _src_core.DEFAULT_COMPRESSION_THRESHOLD;
1344
1403
  const filter = options?.filter;
1345
- const encodings = ["gzip", "deflate"];
1404
+ const encodings = NODE_COMPRESSION_ENCODINGS;
1346
1405
  return async (request, context, next) => {
1347
1406
  const response = await next();
1348
1407
  return (0, _src_core.compressResponse)(request, context, response, {
@@ -1355,18 +1414,21 @@ function createCompression(options) {
1355
1414
  }
1356
1415
  //#endregion
1357
1416
  exports.DEFAULT_CONTENT_TYPE = DEFAULT_CONTENT_TYPE;
1358
- exports.DEFAULT_MULTIPART_FIELD = DEFAULT_MULTIPART_FIELD;
1359
- exports.DEFAULT_MULTIPART_FIELDS = DEFAULT_MULTIPART_FIELDS;
1360
- exports.DEFAULT_MULTIPART_FILE = DEFAULT_MULTIPART_FILE;
1361
- exports.DEFAULT_MULTIPART_FILES = DEFAULT_MULTIPART_FILES;
1417
+ exports.DEFAULT_MULTIPART_FIELD_COUNT = DEFAULT_MULTIPART_FIELD_COUNT;
1418
+ exports.DEFAULT_MULTIPART_FIELD_SIZE = DEFAULT_MULTIPART_FIELD_SIZE;
1419
+ exports.DEFAULT_MULTIPART_FILE_COUNT = DEFAULT_MULTIPART_FILE_COUNT;
1420
+ exports.DEFAULT_MULTIPART_FILE_SIZE = DEFAULT_MULTIPART_FILE_SIZE;
1362
1421
  exports.DEFAULT_MULTIPART_TOTAL = DEFAULT_MULTIPART_TOTAL;
1422
+ exports.DEFAULT_STATIC_DOTFILES = DEFAULT_STATIC_DOTFILES;
1363
1423
  exports.DEFAULT_STATIC_FALLBACK_EXCLUDE = DEFAULT_STATIC_FALLBACK_EXCLUDE;
1364
1424
  exports.DEFAULT_STATIC_INDEX = DEFAULT_STATIC_INDEX;
1365
1425
  exports.EXTENSION_TYPES = EXTENSION_TYPES;
1426
+ exports.MULTIPART_ERROR_BRAND = MULTIPART_ERROR_BRAND;
1366
1427
  exports.MULTIPART_MAX_HEADER_BLOCK = MULTIPART_MAX_HEADER_BLOCK;
1367
1428
  exports.MULTIPART_MAX_PREAMBLE = MULTIPART_MAX_PREAMBLE;
1368
- exports.MULTIPART_REASON_STATUS = MULTIPART_REASON_STATUS;
1429
+ exports.MULTIPART_STATUS = MULTIPART_STATUS;
1369
1430
  exports.MultipartError = MultipartError;
1431
+ exports.NODE_COMPRESSION_ENCODINGS = NODE_COMPRESSION_ENCODINGS;
1370
1432
  exports.RESERVED_DEVICE_NAMES = RESERVED_DEVICE_NAMES;
1371
1433
  exports.compressNodeBytes = compressNodeBytes;
1372
1434
  exports.computeFileETag = computeFileETag;
@@ -1376,6 +1438,7 @@ exports.createMultipart = createMultipart;
1376
1438
  exports.createStatic = createStatic;
1377
1439
  exports.createUploadedFile = createUploadedFile;
1378
1440
  exports.detectMIME = detectMIME;
1441
+ exports.extractMultipartBoundary = extractMultipartBoundary;
1379
1442
  exports.isContainedPath = isContainedPath;
1380
1443
  exports.isDotfilePath = isDotfilePath;
1381
1444
  exports.isMultipartError = isMultipartError;
@@ -1384,11 +1447,10 @@ exports.isUnderPath = isUnderPath;
1384
1447
  exports.lookupContentType = lookupContentType;
1385
1448
  exports.matchesBytes = matchesBytes;
1386
1449
  exports.moveUploadedFile = moveUploadedFile;
1387
- exports.multipartBoundary = multipartBoundary;
1388
1450
  exports.parseMultipartRequest = parseMultipartRequest;
1389
1451
  exports.parsePartHeaders = parsePartHeaders;
1390
1452
  exports.readUploadedFile = readUploadedFile;
1391
- exports.resolveDefaultDirectory = resolveDefaultDirectory;
1453
+ exports.resolveContainedRealPath = resolveContainedRealPath;
1392
1454
  exports.resolveMultipartLimits = resolveMultipartLimits;
1393
1455
  exports.resolveStaticFallbackPath = resolveStaticFallbackPath;
1394
1456
  exports.resolveStaticPath = resolveStaticPath;