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