@orkestrel/middleware 0.0.18 → 0.0.20

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