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