@orkestrel/middleware 0.0.5 → 0.0.6

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,13 +1,13 @@
1
1
  import { createReadStream } from "node:fs";
2
2
  import { chmod, copyFile, mkdtemp, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
3
  import { extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
5
- import { randomUUID } from "node:crypto";
4
+ import { promisify } from "node:util";
5
+ import { deflate, gzip } from "node:zlib";
6
6
  import { isFiniteNumber, isFunction, isRecord, isString } from "@orkestrel/contract";
7
+ import { randomUUID } from "node:crypto";
8
+ import { tmpdir } from "node:os";
7
9
  import { HTTPError, isDangerousKey, matchesETag, parseRange } from "@orkestrel/server";
8
10
  import { DEFAULT_COMPRESSION_THRESHOLD, compressResponse } from "../core/index.js";
9
- import { deflate, gzip } from "node:zlib";
10
- import { promisify } from "node:util";
11
11
  //#region src/server/constants.ts
12
12
  /** The HTTP status `createMultipart` renders for each {@link MultipartReason}. */
13
13
  var MULTIPART_REASON_STATUS = Object.freeze({
@@ -114,7 +114,7 @@ var MultipartError = class extends Error {
114
114
  super(message);
115
115
  this.status = MULTIPART_REASON_STATUS[reason];
116
116
  this.reason = reason;
117
- this.context = context;
117
+ if (context !== void 0) this.context = context;
118
118
  Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
119
119
  }
120
120
  };
@@ -150,6 +150,210 @@ function isMultipartError(value) {
150
150
  return true;
151
151
  }
152
152
  //#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
153
357
  //#region src/server/helpers.ts
154
358
  /**
155
359
  * Whether `pathname` is `prefix` itself or lies under it on a SEGMENT
@@ -173,6 +377,31 @@ function isUnderPath(pathname, prefix) {
173
377
  return pathname.startsWith(boundary);
174
378
  }
175
379
  /**
380
+ * Resolve the fixed SPA shell path when a static-file miss is eligible for
381
+ * fallback.
382
+ *
383
+ * @param root - The configured static root
384
+ * @param index - The configured shell filename
385
+ * @param exclude - The URL prefix excluded from fallback
386
+ * @param method - The request method
387
+ * @param pathname - The request pathname
388
+ * @param accept - The request's `Accept` header value
389
+ * @returns The fixed shell path, or `undefined` when fallback is ineligible
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * resolveStaticFallbackPath('/srv/public', 'index.html', '/api', 'GET', '/dashboard', 'text/html')
394
+ * // '/srv/public/index.html'
395
+ * ```
396
+ */
397
+ function resolveStaticFallbackPath(root, index, exclude, method, pathname, accept) {
398
+ if (method !== "GET") return void 0;
399
+ if (extname(pathname) !== "") return void 0;
400
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return void 0;
401
+ if (isUnderPath(pathname, exclude)) return void 0;
402
+ return join(root, index);
403
+ }
404
+ /**
176
405
  * Whether `child` is `parent` itself or lies inside it on-disk — the
177
406
  * FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
178
407
  * output (never to a URL pathname — that is {@link isUnderPath}'s job).
@@ -312,6 +541,23 @@ function computeFileETag(size, mtimeMs) {
312
541
  return `W/"${size}-${Math.floor(mtimeMs)}"`;
313
542
  }
314
543
  /**
544
+ * Compress response bytes with Node's guaranteed zlib gzip/deflate codecs.
545
+ *
546
+ * @param bytes - The uncompressed response bytes
547
+ * @param encoding - The negotiated actionable coding
548
+ * @returns The compressed bytes
549
+ *
550
+ * @example
551
+ * ```ts
552
+ * const bytes = new TextEncoder().encode('compress me')
553
+ * const compressed = await compressNodeBytes(bytes, 'deflate')
554
+ * ```
555
+ */
556
+ async function compressNodeBytes(bytes, encoding) {
557
+ const compressed = encoding === "gzip" ? await promisify(gzip)(bytes) : await promisify(deflate)(bytes);
558
+ return Uint8Array.from(compressed);
559
+ }
560
+ /**
315
561
  * Sniff a MIME type from a file's leading bytes against a small magic-byte
316
562
  * table (jpeg, png, gif87a/89a, webp, pdf, zip) — the SNIFF-AUTHORITATIVE
317
563
  * signal `createMultipart`'s type validation rests on, never the declared
@@ -326,17 +572,12 @@ function computeFileETag(size, mtimeMs) {
326
572
  * ```
327
573
  */
328
574
  function detectMIME(head) {
329
- function matches(signature, offset = 0) {
330
- if (head.length < offset + signature.length) return false;
331
- for (let index = 0; index < signature.length; index += 1) if (head[offset + index] !== signature[index]) return false;
332
- return true;
333
- }
334
- if (matches([
575
+ if (matchesBytes(head, [
335
576
  255,
336
577
  216,
337
578
  255
338
579
  ])) return "image/jpeg";
339
- if (matches([
580
+ if (matchesBytes(head, [
340
581
  137,
341
582
  80,
342
583
  78,
@@ -346,7 +587,7 @@ function detectMIME(head) {
346
587
  26,
347
588
  10
348
589
  ])) return "image/png";
349
- if (matches([
590
+ if (matchesBytes(head, [
350
591
  71,
351
592
  73,
352
593
  70,
@@ -354,7 +595,7 @@ function detectMIME(head) {
354
595
  55,
355
596
  97
356
597
  ])) return "image/gif";
357
- if (matches([
598
+ if (matchesBytes(head, [
358
599
  71,
359
600
  73,
360
601
  70,
@@ -362,35 +603,35 @@ function detectMIME(head) {
362
603
  57,
363
604
  97
364
605
  ])) return "image/gif";
365
- if (matches([
606
+ if (matchesBytes(head, [
366
607
  82,
367
608
  73,
368
609
  70,
369
610
  70
370
- ]) && matches([
611
+ ]) && matchesBytes(head, [
371
612
  87,
372
613
  69,
373
614
  66,
374
615
  80
375
616
  ], 8)) return "image/webp";
376
- if (matches([
617
+ if (matchesBytes(head, [
377
618
  37,
378
619
  80,
379
620
  68,
380
621
  70,
381
622
  45
382
623
  ])) return "application/pdf";
383
- if (matches([
624
+ if (matchesBytes(head, [
384
625
  80,
385
626
  75,
386
627
  3,
387
628
  4
388
- ]) || matches([
629
+ ]) || matchesBytes(head, [
389
630
  80,
390
631
  75,
391
632
  5,
392
633
  6
393
- ]) || matches([
634
+ ]) || matchesBytes(head, [
394
635
  80,
395
636
  75,
396
637
  7,
@@ -398,6 +639,24 @@ function detectMIME(head) {
398
639
  ])) return "application/zip";
399
640
  }
400
641
  /**
642
+ * Whether `bytes` contains `signature` at the requested offset.
643
+ *
644
+ * @param bytes - The bytes to inspect
645
+ * @param signature - The exact byte sequence to match
646
+ * @param offset - The starting byte offset, defaulting to zero
647
+ * @returns `true` when the complete signature matches
648
+ *
649
+ * @example
650
+ * ```ts
651
+ * matchesBytes(Uint8Array.from([0, 0x50, 0x4b]), [0x50, 0x4b], 1) // true
652
+ * ```
653
+ */
654
+ function matchesBytes(bytes, signature, offset = 0) {
655
+ if (bytes.length < offset + signature.length) return false;
656
+ for (let index = 0; index < signature.length; index += 1) if (bytes[offset + index] !== signature[index]) return false;
657
+ return true;
658
+ }
659
+ /**
401
660
  * Extract the `boundary` parameter from a `Content-Type` header, or
402
661
  * `undefined` when the request is not `multipart/form-data`.
403
662
  *
@@ -441,11 +700,6 @@ function resolveMultipartLimits(limits) {
441
700
  };
442
701
  }
443
702
  /**
444
- * Memoized `Promise` for `parseMultipartRequest`'s lazily-created default
445
- * staging directory — created once per process via {@link resolveDefaultDirectory}.
446
- */
447
- var defaultDirectory;
448
- /**
449
703
  * Resolve `parseMultipartRequest`'s default staging directory when the
450
704
  * caller did not configure one — a process-owned directory created ONCE
451
705
  * (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
@@ -459,12 +713,7 @@ var defaultDirectory;
459
713
  * ```
460
714
  */
461
715
  function resolveDefaultDirectory() {
462
- if (defaultDirectory === void 0) defaultDirectory = (async () => {
463
- const path = await mkdtemp(join(tmpdir(), "orkestrel-multipart-"));
464
- await chmod(path, 448);
465
- return path;
466
- })();
467
- return defaultDirectory;
716
+ return MultipartParser.directory();
468
717
  }
469
718
  /**
470
719
  * Parse one multipart part's raw header block into its `name` (from
@@ -551,177 +800,7 @@ async function parseMultipartRequest(request, options = {}) {
551
800
  const limits = resolveMultipartLimits(options.limits);
552
801
  const allowed = options.allowed;
553
802
  const directory = options.directory ?? await resolveDefaultDirectory();
554
- const staged = [];
555
- const files = Object.create(null);
556
- const fields = Object.create(null);
557
- let fileCount = 0;
558
- let fieldCount = 0;
559
- let totalBytes = 0;
560
- let aborted = false;
561
- async function cleanup() {
562
- for (const path of staged) try {
563
- await unlink(path);
564
- } catch {}
565
- }
566
- const reader = request.body.getReader();
567
- let buffer = Buffer.alloc(0);
568
- let ended = false;
569
- const onAbort = () => {
570
- aborted = true;
571
- };
572
- request.signal.addEventListener("abort", onAbort);
573
- async function pull() {
574
- if (aborted || request.signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
575
- if (ended) return false;
576
- const { done, value } = await reader.read();
577
- if (done) {
578
- ended = true;
579
- return false;
580
- }
581
- totalBytes += value.byteLength;
582
- if (totalBytes > limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
583
- buffer = Buffer.concat([buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
584
- return true;
585
- }
586
- try {
587
- const openMarker = Buffer.from(`--${boundary}`);
588
- let preambleScanned = 0;
589
- let index = buffer.indexOf(openMarker);
590
- while (index === -1) {
591
- const carry = openMarker.length - 1;
592
- if (buffer.length > carry) {
593
- const drop = buffer.length - carry;
594
- preambleScanned += drop;
595
- if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
596
- buffer = buffer.subarray(drop);
597
- }
598
- if (!await pull()) throw new MultipartError("malformed", "missing multipart boundary");
599
- index = buffer.indexOf(openMarker);
600
- }
601
- buffer = buffer.subarray(index + openMarker.length);
602
- for (;;) {
603
- while (buffer.length < 2) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
604
- if (buffer[0] === 45 && buffer[1] === 45) break;
605
- if (buffer[0] !== 13 || buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
606
- buffer = buffer.subarray(2);
607
- let headerEnd = buffer.indexOf("\r\n\r\n");
608
- while (headerEnd === -1) {
609
- if (buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
610
- if (!await pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
611
- headerEnd = buffer.indexOf("\r\n\r\n");
612
- }
613
- const headerBlock = buffer.subarray(0, headerEnd).toString("utf8");
614
- buffer = buffer.subarray(headerEnd + 4);
615
- const { name, filename, contentType } = parsePartHeaders(headerBlock);
616
- if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
617
- const partDelimiter = Buffer.from(`\r\n--${boundary}`);
618
- if (filename !== void 0) {
619
- if (filename !== "") {
620
- fileCount += 1;
621
- if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
622
- }
623
- const path = join(directory, randomUUID());
624
- staged.push(path);
625
- const handle = await open(path, "w", 384);
626
- let size = 0;
627
- let head = Buffer.alloc(0);
628
- try {
629
- for (;;) {
630
- const boundaryIndex = buffer.indexOf(partDelimiter);
631
- if (boundaryIndex === -1) {
632
- const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
633
- if (safeLength > 0) {
634
- const chunk = buffer.subarray(0, safeLength);
635
- size += chunk.length;
636
- if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
637
- if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
638
- await handle.write(chunk);
639
- buffer = buffer.subarray(safeLength);
640
- }
641
- if (!await pull()) throw new MultipartError("malformed", "unterminated multipart file part");
642
- continue;
643
- }
644
- const chunk = buffer.subarray(0, boundaryIndex);
645
- size += chunk.length;
646
- if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
647
- if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
648
- await handle.write(chunk);
649
- buffer = buffer.subarray(boundaryIndex + 2);
650
- break;
651
- }
652
- } finally {
653
- await handle.close();
654
- }
655
- if (filename === "" && size === 0) {
656
- await unlink(path);
657
- staged.splice(staged.indexOf(path), 1);
658
- } else {
659
- if (filename === "") {
660
- fileCount += 1;
661
- if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
662
- }
663
- const detected = detectMIME(head);
664
- const declared = contentType ?? "application/octet-stream";
665
- const validated = detected !== void 0 && detected === declared;
666
- if (allowed !== void 0) {
667
- if (!(detected !== void 0 && allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
668
- }
669
- if (isDangerousKey(name)) {
670
- await unlink(path);
671
- staged.splice(staged.indexOf(path), 1);
672
- } else {
673
- const record = createUploadedFile({
674
- field: name,
675
- name: filename,
676
- size,
677
- mime: detected ?? declared,
678
- validated,
679
- status: "staged",
680
- path
681
- });
682
- const existing = files[name];
683
- if (existing === void 0) files[name] = [record];
684
- else existing.push(record);
685
- }
686
- }
687
- } else {
688
- fieldCount += 1;
689
- if (fieldCount > limits.fields) throw new MultipartError("limit", "too many multipart fields");
690
- let value = Buffer.alloc(0);
691
- for (;;) {
692
- const boundaryIndex = buffer.indexOf(partDelimiter);
693
- if (boundaryIndex === -1) {
694
- const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
695
- if (safeLength > 0) {
696
- value = Buffer.concat([value, buffer.subarray(0, safeLength)]);
697
- if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
698
- buffer = buffer.subarray(safeLength);
699
- }
700
- if (!await pull()) throw new MultipartError("malformed", "unterminated multipart field part");
701
- continue;
702
- }
703
- value = Buffer.concat([value, buffer.subarray(0, boundaryIndex)]);
704
- if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
705
- buffer = buffer.subarray(boundaryIndex + 2);
706
- break;
707
- }
708
- if (!isDangerousKey(name)) fields[name] = value.toString("utf8");
709
- }
710
- while (buffer.length < openMarker.length) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
711
- buffer = buffer.subarray(openMarker.length);
712
- }
713
- } catch (error) {
714
- await cleanup();
715
- await reader.cancel().catch(() => {});
716
- throw error;
717
- } finally {
718
- request.signal.removeEventListener("abort", onAbort);
719
- if (!ended) await reader.cancel().catch(() => {});
720
- }
721
- return {
722
- files: Object.freeze(files),
723
- fields: Object.freeze(fields)
724
- };
803
+ return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
725
804
  }
726
805
  /**
727
806
  * Build a frozen {@link UploadedFileInterface} record.
@@ -934,10 +1013,6 @@ function createStatic(options) {
934
1013
  const useETag = options.etag ?? true;
935
1014
  const fallback = options.fallback === true ? { exclude: DEFAULT_STATIC_FALLBACK_EXCLUDE } : options.fallback === false || options.fallback === void 0 ? void 0 : { exclude: options.fallback.exclude ?? "/api" };
936
1015
  let canonicalRootPromise;
937
- function canonicalRoot() {
938
- if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
939
- return canonicalRootPromise;
940
- }
941
1016
  return async (request, context, next) => {
942
1017
  if (context.method !== "GET" && context.method !== "HEAD") return next();
943
1018
  const target = resolveStaticPath(root, options.prefix, context.url.pathname);
@@ -947,47 +1022,83 @@ function createStatic(options) {
947
1022
  if (dotfiles === "deny") throw new HTTPError(403, "forbidden");
948
1023
  if (dotfiles === "ignore") return next();
949
1024
  }
950
- let resolvedPath;
1025
+ let resolvedPath = target;
1026
+ let fallbackNeeded = false;
951
1027
  try {
952
- const [rootReal, targetReal] = await Promise.all([canonicalRoot(), realpath(target)]);
1028
+ if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
1029
+ const [rootReal, targetReal] = await Promise.all([canonicalRootPromise, realpath(target)]);
953
1030
  if (!isContainedPath(targetReal, rootReal)) return next();
954
1031
  resolvedPath = targetReal;
955
1032
  } catch {
956
- return trySpaFallback();
1033
+ fallbackNeeded = true;
957
1034
  }
958
1035
  let directoryInfo;
959
- try {
1036
+ if (!fallbackNeeded) try {
960
1037
  directoryInfo = await stat(resolvedPath);
961
1038
  } catch {
962
- return trySpaFallback();
1039
+ fallbackNeeded = true;
963
1040
  }
964
- if (directoryInfo.isDirectory()) {
1041
+ if (directoryInfo?.isDirectory()) {
965
1042
  resolvedPath = join(resolvedPath, index);
966
1043
  try {
967
- const [rootReal, indexReal] = await Promise.all([canonicalRoot(), realpath(resolvedPath)]);
968
- if (!isContainedPath(indexReal, rootReal)) return trySpaFallback();
969
- resolvedPath = indexReal;
1044
+ if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
1045
+ const [rootReal, indexReal] = await Promise.all([canonicalRootPromise, realpath(resolvedPath)]);
1046
+ if (isContainedPath(indexReal, rootReal)) resolvedPath = indexReal;
1047
+ else fallbackNeeded = true;
970
1048
  } catch {
971
- return trySpaFallback();
1049
+ fallbackNeeded = true;
972
1050
  }
973
1051
  }
974
1052
  let handle;
975
- try {
1053
+ let info;
1054
+ if (!fallbackNeeded) try {
976
1055
  handle = await open(resolvedPath, "r");
977
1056
  } catch {
978
- return trySpaFallback();
1057
+ fallbackNeeded = true;
979
1058
  }
980
- let info;
981
- try {
1059
+ if (handle !== void 0) try {
982
1060
  info = await handle.stat();
983
1061
  } catch {
984
1062
  await handle.close();
985
- return trySpaFallback();
1063
+ handle = void 0;
1064
+ fallbackNeeded = true;
986
1065
  }
987
- if (!info.isFile()) {
1066
+ if (handle !== void 0 && info !== void 0 && !info.isFile()) {
988
1067
  await handle.close();
989
- return trySpaFallback();
1068
+ handle = void 0;
1069
+ info = void 0;
1070
+ fallbackNeeded = true;
1071
+ }
1072
+ if (fallbackNeeded) {
1073
+ const shellPath = resolveStaticFallbackPath(root, index, fallback?.exclude ?? "/api", context.method, context.url.pathname, request.headers.get("accept") ?? "");
1074
+ if (fallback === void 0 || shellPath === void 0) return next();
1075
+ let shellReal;
1076
+ try {
1077
+ if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
1078
+ const [rootReal, candidate] = await Promise.all([canonicalRootPromise, realpath(shellPath)]);
1079
+ if (!isContainedPath(candidate, rootReal)) return next();
1080
+ shellReal = candidate;
1081
+ } catch {
1082
+ return next();
1083
+ }
1084
+ let shellHandle;
1085
+ try {
1086
+ shellHandle = await open(shellReal, "r");
1087
+ } catch {
1088
+ return next();
1089
+ }
1090
+ try {
1091
+ const body = streamFile(shellHandle);
1092
+ return new Response(body, {
1093
+ status: 200,
1094
+ headers: new Headers({ "content-type": lookupContentType(shellReal) })
1095
+ });
1096
+ } catch (error) {
1097
+ await shellHandle.close().catch(() => {});
1098
+ throw error;
1099
+ }
990
1100
  }
1101
+ if (handle === void 0 || info === void 0) return next();
991
1102
  let streaming = false;
992
1103
  try {
993
1104
  const headers = new Headers({
@@ -1049,32 +1160,6 @@ function createStatic(options) {
1049
1160
  if (!streaming) await handle.close().catch(() => {});
1050
1161
  throw error;
1051
1162
  }
1052
- function trySpaFallback() {
1053
- if (fallback === void 0) return next();
1054
- if (context.method !== "GET") return next();
1055
- if (extname(context.url.pathname) !== "") return next();
1056
- const accept = request.headers.get("accept") ?? "";
1057
- if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
1058
- if (isUnderPath(context.url.pathname, fallback.exclude)) return next();
1059
- const shellPath = join(root, index);
1060
- return Promise.all([canonicalRoot(), realpath(shellPath)]).then(([rootReal, shellReal]) => {
1061
- if (!isContainedPath(shellReal, rootReal)) return next();
1062
- return open(shellReal, "r").then((shellHandle) => {
1063
- let shellStreaming = false;
1064
- try {
1065
- const body = streamFile(shellHandle);
1066
- shellStreaming = true;
1067
- return new Response(body, {
1068
- status: 200,
1069
- headers: new Headers({ "content-type": lookupContentType(shellReal) })
1070
- });
1071
- } catch (error) {
1072
- if (!shellStreaming) shellHandle.close().catch(() => {});
1073
- throw error;
1074
- }
1075
- });
1076
- }).catch(() => next());
1077
- }
1078
1163
  };
1079
1164
  }
1080
1165
  /**
@@ -1118,7 +1203,7 @@ function createMultipart(options = {}) {
1118
1203
  throw error;
1119
1204
  }
1120
1205
  if (body === void 0) return next();
1121
- context.state.multipart = body;
1206
+ Object.assign(context.state, { multipart: body });
1122
1207
  try {
1123
1208
  return await next();
1124
1209
  } catch (error) {
@@ -1163,18 +1248,16 @@ function createCompression(options) {
1163
1248
  const threshold = options?.threshold ?? DEFAULT_COMPRESSION_THRESHOLD;
1164
1249
  const filter = options?.filter;
1165
1250
  const encodings = ["gzip", "deflate"];
1166
- const gzipAsync = promisify(gzip);
1167
- const deflateAsync = promisify(deflate);
1168
1251
  return async (request, context, next) => {
1169
1252
  return compressResponse(request, context, await next(), {
1170
1253
  threshold,
1171
- filter,
1254
+ ...filter !== void 0 ? { filter } : {},
1172
1255
  encodings,
1173
- compress: async (bytes, encoding) => encoding === "gzip" ? gzipAsync(bytes) : deflateAsync(bytes)
1256
+ compress: compressNodeBytes
1174
1257
  });
1175
1258
  };
1176
1259
  }
1177
1260
  //#endregion
1178
- 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, computeFileETag, createCompression, createMultipart, createStatic, createUploadedFile, detectMIME, isContainedPath, isDotfilePath, isMultipartError, isReservedDeviceName, isUnderPath, lookupContentType, moveUploadedFile, multipartBoundary, parseMultipartRequest, parsePartHeaders, readUploadedFile, resolveDefaultDirectory, resolveMultipartLimits, resolveStaticPath, streamFile, streamUploadedFile, unlinkStagedFiles };
1261
+ 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, createCompression, createMultipart, createStatic, createUploadedFile, detectMIME, isContainedPath, isDotfilePath, isMultipartError, isReservedDeviceName, isUnderPath, lookupContentType, matchesBytes, moveUploadedFile, multipartBoundary, parseMultipartRequest, parsePartHeaders, readUploadedFile, resolveDefaultDirectory, resolveMultipartLimits, resolveStaticFallbackPath, resolveStaticPath, streamFile, streamUploadedFile, unlinkStagedFiles };
1179
1262
 
1180
1263
  //# sourceMappingURL=index.js.map