@orkestrel/middleware 0.0.4 → 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,14 +1,14 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_fs = require("node:fs");
3
3
  let node_fs_promises = require("node:fs/promises");
4
- let node_os = require("node:os");
5
4
  let node_path = require("node:path");
6
- let node_crypto = require("node:crypto");
5
+ let node_util = require("node:util");
6
+ let node_zlib = require("node:zlib");
7
7
  let _orkestrel_contract = require("@orkestrel/contract");
8
+ let node_crypto = require("node:crypto");
9
+ let node_os = require("node:os");
8
10
  let _orkestrel_server = require("@orkestrel/server");
9
11
  let _src_core = require("../core/index.cjs");
10
- let node_zlib = require("node:zlib");
11
- let node_util = require("node:util");
12
12
  //#region src/server/constants.ts
13
13
  /** The HTTP status `createMultipart` renders for each {@link MultipartReason}. */
14
14
  var MULTIPART_REASON_STATUS = Object.freeze({
@@ -115,7 +115,7 @@ var MultipartError = class extends Error {
115
115
  super(message);
116
116
  this.status = MULTIPART_REASON_STATUS[reason];
117
117
  this.reason = reason;
118
- this.context = context;
118
+ if (context !== void 0) this.context = context;
119
119
  Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
120
120
  }
121
121
  };
@@ -151,6 +151,210 @@ function isMultipartError(value) {
151
151
  return true;
152
152
  }
153
153
  //#endregion
154
+ //#region src/server/MultipartParser.ts
155
+ var MultipartParser = class MultipartParser {
156
+ static #defaultDirectory;
157
+ #reader;
158
+ #signal;
159
+ #abort;
160
+ #boundary;
161
+ #limits;
162
+ #allowed;
163
+ #directory;
164
+ #staged = [];
165
+ #files = Object.create(null);
166
+ #fields = Object.create(null);
167
+ #buffer = Buffer.alloc(0);
168
+ #ended = false;
169
+ #fileCount = 0;
170
+ #fieldCount = 0;
171
+ #totalBytes = 0;
172
+ constructor(stream, signal, boundary, limits, allowed, directory) {
173
+ this.#reader = stream.getReader();
174
+ this.#signal = signal;
175
+ this.#abort = this.#wakeReader.bind(this);
176
+ this.#boundary = boundary;
177
+ this.#limits = limits;
178
+ this.#allowed = allowed;
179
+ this.#directory = directory;
180
+ }
181
+ static directory() {
182
+ if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
183
+ return MultipartParser.#defaultDirectory;
184
+ }
185
+ async parse() {
186
+ this.#signal.addEventListener("abort", this.#abort, { once: true });
187
+ try {
188
+ const openMarker = Buffer.from(`--${this.#boundary}`);
189
+ let preambleScanned = 0;
190
+ let index = this.#buffer.indexOf(openMarker);
191
+ while (index === -1) {
192
+ const carry = openMarker.length - 1;
193
+ if (this.#buffer.length > carry) {
194
+ const drop = this.#buffer.length - carry;
195
+ preambleScanned += drop;
196
+ if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
197
+ this.#buffer = this.#buffer.subarray(drop);
198
+ }
199
+ if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
200
+ index = this.#buffer.indexOf(openMarker);
201
+ }
202
+ if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
203
+ this.#buffer = this.#buffer.subarray(index + openMarker.length);
204
+ for (;;) {
205
+ while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
206
+ if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
207
+ if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
208
+ this.#buffer = this.#buffer.subarray(2);
209
+ let headerEnd = this.#buffer.indexOf("\r\n\r\n");
210
+ while (headerEnd === -1) {
211
+ if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
212
+ if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
213
+ headerEnd = this.#buffer.indexOf("\r\n\r\n");
214
+ }
215
+ if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
216
+ const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
217
+ this.#buffer = this.#buffer.subarray(headerEnd + 4);
218
+ const { name, filename, contentType } = parsePartHeaders(headerBlock);
219
+ if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
220
+ const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
221
+ if (filename !== void 0) {
222
+ if (filename !== "") {
223
+ this.#fileCount += 1;
224
+ if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
225
+ }
226
+ const path = (0, node_path.join)(this.#directory, (0, node_crypto.randomUUID)());
227
+ this.#staged.push(path);
228
+ const handle = await (0, node_fs_promises.open)(path, "w", 384);
229
+ let size = 0;
230
+ let head = Buffer.alloc(0);
231
+ try {
232
+ for (;;) {
233
+ const boundaryIndex = this.#buffer.indexOf(partDelimiter);
234
+ if (boundaryIndex === -1) {
235
+ const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
236
+ if (safeLength > 0) {
237
+ const chunk = this.#buffer.subarray(0, safeLength);
238
+ size += chunk.length;
239
+ if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
240
+ if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
241
+ await handle.write(chunk);
242
+ this.#buffer = this.#buffer.subarray(safeLength);
243
+ }
244
+ if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart file part");
245
+ continue;
246
+ }
247
+ const chunk = this.#buffer.subarray(0, boundaryIndex);
248
+ size += chunk.length;
249
+ if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
250
+ if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
251
+ await handle.write(chunk);
252
+ this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
253
+ break;
254
+ }
255
+ } finally {
256
+ await handle.close();
257
+ }
258
+ if (filename === "" && size === 0) {
259
+ await (0, node_fs_promises.unlink)(path);
260
+ this.#staged.splice(this.#staged.indexOf(path), 1);
261
+ } else {
262
+ if (filename === "") {
263
+ this.#fileCount += 1;
264
+ if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
265
+ }
266
+ const detected = detectMIME(head);
267
+ const declared = contentType ?? "application/octet-stream";
268
+ const validated = detected !== void 0 && detected === declared;
269
+ if (this.#allowed !== void 0) {
270
+ if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
271
+ }
272
+ if ((0, _orkestrel_server.isDangerousKey)(name)) {
273
+ await (0, node_fs_promises.unlink)(path);
274
+ this.#staged.splice(this.#staged.indexOf(path), 1);
275
+ } else {
276
+ const record = createUploadedFile({
277
+ field: name,
278
+ name: filename,
279
+ size,
280
+ mime: detected ?? declared,
281
+ validated,
282
+ status: "staged",
283
+ path
284
+ });
285
+ const existing = this.#files[name];
286
+ if (existing === void 0) this.#files[name] = [record];
287
+ else existing.push(record);
288
+ }
289
+ }
290
+ } else {
291
+ this.#fieldCount += 1;
292
+ if (this.#fieldCount > this.#limits.fields) throw new MultipartError("limit", "too many multipart fields");
293
+ let value = Buffer.alloc(0);
294
+ for (;;) {
295
+ const boundaryIndex = this.#buffer.indexOf(partDelimiter);
296
+ if (boundaryIndex === -1) {
297
+ const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
298
+ if (safeLength > 0) {
299
+ value = Buffer.concat([value, this.#buffer.subarray(0, safeLength)]);
300
+ if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
301
+ this.#buffer = this.#buffer.subarray(safeLength);
302
+ }
303
+ if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart field part");
304
+ continue;
305
+ }
306
+ value = Buffer.concat([value, this.#buffer.subarray(0, boundaryIndex)]);
307
+ if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
308
+ this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
309
+ break;
310
+ }
311
+ if (!(0, _orkestrel_server.isDangerousKey)(name)) this.#fields[name] = value.toString("utf8");
312
+ }
313
+ while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
314
+ this.#buffer = this.#buffer.subarray(openMarker.length);
315
+ }
316
+ } catch (error) {
317
+ await this.#cleanup();
318
+ await this.#reader.cancel().catch(() => {});
319
+ throw error;
320
+ } finally {
321
+ this.#signal.removeEventListener("abort", this.#abort);
322
+ if (!this.#ended) await this.#reader.cancel().catch(() => {});
323
+ }
324
+ return {
325
+ files: Object.freeze(this.#files),
326
+ fields: Object.freeze(this.#fields)
327
+ };
328
+ }
329
+ static async #createDirectory() {
330
+ const path = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-multipart-"));
331
+ await (0, node_fs_promises.chmod)(path, 448);
332
+ return path;
333
+ }
334
+ async #cleanup() {
335
+ for (const path of this.#staged) try {
336
+ await (0, node_fs_promises.unlink)(path);
337
+ } catch {}
338
+ }
339
+ async #pull() {
340
+ if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
341
+ if (this.#ended) return false;
342
+ const { done, value } = await this.#reader.read();
343
+ if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
344
+ if (done) {
345
+ this.#ended = true;
346
+ return false;
347
+ }
348
+ this.#totalBytes += value.byteLength;
349
+ if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
350
+ this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
351
+ return true;
352
+ }
353
+ #wakeReader() {
354
+ this.#reader.cancel().catch(() => {});
355
+ }
356
+ };
357
+ //#endregion
154
358
  //#region src/server/helpers.ts
155
359
  /**
156
360
  * Whether `pathname` is `prefix` itself or lies under it on a SEGMENT
@@ -174,6 +378,31 @@ function isUnderPath(pathname, prefix) {
174
378
  return pathname.startsWith(boundary);
175
379
  }
176
380
  /**
381
+ * Resolve the fixed SPA shell path when a static-file miss is eligible for
382
+ * fallback.
383
+ *
384
+ * @param root - The configured static root
385
+ * @param index - The configured shell filename
386
+ * @param exclude - The URL prefix excluded from fallback
387
+ * @param method - The request method
388
+ * @param pathname - The request pathname
389
+ * @param accept - The request's `Accept` header value
390
+ * @returns The fixed shell path, or `undefined` when fallback is ineligible
391
+ *
392
+ * @example
393
+ * ```ts
394
+ * resolveStaticFallbackPath('/srv/public', 'index.html', '/api', 'GET', '/dashboard', 'text/html')
395
+ * // '/srv/public/index.html'
396
+ * ```
397
+ */
398
+ function resolveStaticFallbackPath(root, index, exclude, method, pathname, accept) {
399
+ if (method !== "GET") return void 0;
400
+ if ((0, node_path.extname)(pathname) !== "") return void 0;
401
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return void 0;
402
+ if (isUnderPath(pathname, exclude)) return void 0;
403
+ return (0, node_path.join)(root, index);
404
+ }
405
+ /**
177
406
  * Whether `child` is `parent` itself or lies inside it on-disk — the
178
407
  * FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
179
408
  * output (never to a URL pathname — that is {@link isUnderPath}'s job).
@@ -313,6 +542,23 @@ function computeFileETag(size, mtimeMs) {
313
542
  return `W/"${size}-${Math.floor(mtimeMs)}"`;
314
543
  }
315
544
  /**
545
+ * Compress response bytes with Node's guaranteed zlib gzip/deflate codecs.
546
+ *
547
+ * @param bytes - The uncompressed response bytes
548
+ * @param encoding - The negotiated actionable coding
549
+ * @returns The compressed bytes
550
+ *
551
+ * @example
552
+ * ```ts
553
+ * const bytes = new TextEncoder().encode('compress me')
554
+ * const compressed = await compressNodeBytes(bytes, 'deflate')
555
+ * ```
556
+ */
557
+ async function compressNodeBytes(bytes, encoding) {
558
+ const compressed = encoding === "gzip" ? await (0, node_util.promisify)(node_zlib.gzip)(bytes) : await (0, node_util.promisify)(node_zlib.deflate)(bytes);
559
+ return Uint8Array.from(compressed);
560
+ }
561
+ /**
316
562
  * Sniff a MIME type from a file's leading bytes against a small magic-byte
317
563
  * table (jpeg, png, gif87a/89a, webp, pdf, zip) — the SNIFF-AUTHORITATIVE
318
564
  * signal `createMultipart`'s type validation rests on, never the declared
@@ -327,17 +573,12 @@ function computeFileETag(size, mtimeMs) {
327
573
  * ```
328
574
  */
329
575
  function detectMIME(head) {
330
- function matches(signature, offset = 0) {
331
- if (head.length < offset + signature.length) return false;
332
- for (let index = 0; index < signature.length; index += 1) if (head[offset + index] !== signature[index]) return false;
333
- return true;
334
- }
335
- if (matches([
576
+ if (matchesBytes(head, [
336
577
  255,
337
578
  216,
338
579
  255
339
580
  ])) return "image/jpeg";
340
- if (matches([
581
+ if (matchesBytes(head, [
341
582
  137,
342
583
  80,
343
584
  78,
@@ -347,7 +588,7 @@ function detectMIME(head) {
347
588
  26,
348
589
  10
349
590
  ])) return "image/png";
350
- if (matches([
591
+ if (matchesBytes(head, [
351
592
  71,
352
593
  73,
353
594
  70,
@@ -355,7 +596,7 @@ function detectMIME(head) {
355
596
  55,
356
597
  97
357
598
  ])) return "image/gif";
358
- if (matches([
599
+ if (matchesBytes(head, [
359
600
  71,
360
601
  73,
361
602
  70,
@@ -363,35 +604,35 @@ function detectMIME(head) {
363
604
  57,
364
605
  97
365
606
  ])) return "image/gif";
366
- if (matches([
607
+ if (matchesBytes(head, [
367
608
  82,
368
609
  73,
369
610
  70,
370
611
  70
371
- ]) && matches([
612
+ ]) && matchesBytes(head, [
372
613
  87,
373
614
  69,
374
615
  66,
375
616
  80
376
617
  ], 8)) return "image/webp";
377
- if (matches([
618
+ if (matchesBytes(head, [
378
619
  37,
379
620
  80,
380
621
  68,
381
622
  70,
382
623
  45
383
624
  ])) return "application/pdf";
384
- if (matches([
625
+ if (matchesBytes(head, [
385
626
  80,
386
627
  75,
387
628
  3,
388
629
  4
389
- ]) || matches([
630
+ ]) || matchesBytes(head, [
390
631
  80,
391
632
  75,
392
633
  5,
393
634
  6
394
- ]) || matches([
635
+ ]) || matchesBytes(head, [
395
636
  80,
396
637
  75,
397
638
  7,
@@ -399,6 +640,24 @@ function detectMIME(head) {
399
640
  ])) return "application/zip";
400
641
  }
401
642
  /**
643
+ * Whether `bytes` contains `signature` at the requested offset.
644
+ *
645
+ * @param bytes - The bytes to inspect
646
+ * @param signature - The exact byte sequence to match
647
+ * @param offset - The starting byte offset, defaulting to zero
648
+ * @returns `true` when the complete signature matches
649
+ *
650
+ * @example
651
+ * ```ts
652
+ * matchesBytes(Uint8Array.from([0, 0x50, 0x4b]), [0x50, 0x4b], 1) // true
653
+ * ```
654
+ */
655
+ function matchesBytes(bytes, signature, offset = 0) {
656
+ if (bytes.length < offset + signature.length) return false;
657
+ for (let index = 0; index < signature.length; index += 1) if (bytes[offset + index] !== signature[index]) return false;
658
+ return true;
659
+ }
660
+ /**
402
661
  * Extract the `boundary` parameter from a `Content-Type` header, or
403
662
  * `undefined` when the request is not `multipart/form-data`.
404
663
  *
@@ -442,11 +701,6 @@ function resolveMultipartLimits(limits) {
442
701
  };
443
702
  }
444
703
  /**
445
- * Memoized `Promise` for `parseMultipartRequest`'s lazily-created default
446
- * staging directory — created once per process via {@link resolveDefaultDirectory}.
447
- */
448
- var defaultDirectory;
449
- /**
450
704
  * Resolve `parseMultipartRequest`'s default staging directory when the
451
705
  * caller did not configure one — a process-owned directory created ONCE
452
706
  * (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
@@ -460,12 +714,7 @@ var defaultDirectory;
460
714
  * ```
461
715
  */
462
716
  function resolveDefaultDirectory() {
463
- if (defaultDirectory === void 0) defaultDirectory = (async () => {
464
- const path = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-multipart-"));
465
- await (0, node_fs_promises.chmod)(path, 448);
466
- return path;
467
- })();
468
- return defaultDirectory;
717
+ return MultipartParser.directory();
469
718
  }
470
719
  /**
471
720
  * Parse one multipart part's raw header block into its `name` (from
@@ -552,177 +801,7 @@ async function parseMultipartRequest(request, options = {}) {
552
801
  const limits = resolveMultipartLimits(options.limits);
553
802
  const allowed = options.allowed;
554
803
  const directory = options.directory ?? await resolveDefaultDirectory();
555
- const staged = [];
556
- const files = Object.create(null);
557
- const fields = Object.create(null);
558
- let fileCount = 0;
559
- let fieldCount = 0;
560
- let totalBytes = 0;
561
- let aborted = false;
562
- async function cleanup() {
563
- for (const path of staged) try {
564
- await (0, node_fs_promises.unlink)(path);
565
- } catch {}
566
- }
567
- const reader = request.body.getReader();
568
- let buffer = Buffer.alloc(0);
569
- let ended = false;
570
- const onAbort = () => {
571
- aborted = true;
572
- };
573
- request.signal.addEventListener("abort", onAbort);
574
- async function pull() {
575
- if (aborted || request.signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
576
- if (ended) return false;
577
- const { done, value } = await reader.read();
578
- if (done) {
579
- ended = true;
580
- return false;
581
- }
582
- totalBytes += value.byteLength;
583
- if (totalBytes > limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
584
- buffer = Buffer.concat([buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
585
- return true;
586
- }
587
- try {
588
- const openMarker = Buffer.from(`--${boundary}`);
589
- let preambleScanned = 0;
590
- let index = buffer.indexOf(openMarker);
591
- while (index === -1) {
592
- const carry = openMarker.length - 1;
593
- if (buffer.length > carry) {
594
- const drop = buffer.length - carry;
595
- preambleScanned += drop;
596
- if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
597
- buffer = buffer.subarray(drop);
598
- }
599
- if (!await pull()) throw new MultipartError("malformed", "missing multipart boundary");
600
- index = buffer.indexOf(openMarker);
601
- }
602
- buffer = buffer.subarray(index + openMarker.length);
603
- for (;;) {
604
- while (buffer.length < 2) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
605
- if (buffer[0] === 45 && buffer[1] === 45) break;
606
- if (buffer[0] !== 13 || buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
607
- buffer = buffer.subarray(2);
608
- let headerEnd = buffer.indexOf("\r\n\r\n");
609
- while (headerEnd === -1) {
610
- if (buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
611
- if (!await pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
612
- headerEnd = buffer.indexOf("\r\n\r\n");
613
- }
614
- const headerBlock = buffer.subarray(0, headerEnd).toString("utf8");
615
- buffer = buffer.subarray(headerEnd + 4);
616
- const { name, filename, contentType } = parsePartHeaders(headerBlock);
617
- if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
618
- const partDelimiter = Buffer.from(`\r\n--${boundary}`);
619
- if (filename !== void 0) {
620
- if (filename !== "") {
621
- fileCount += 1;
622
- if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
623
- }
624
- const path = (0, node_path.join)(directory, (0, node_crypto.randomUUID)());
625
- staged.push(path);
626
- const handle = await (0, node_fs_promises.open)(path, "w", 384);
627
- let size = 0;
628
- let head = Buffer.alloc(0);
629
- try {
630
- for (;;) {
631
- const boundaryIndex = buffer.indexOf(partDelimiter);
632
- if (boundaryIndex === -1) {
633
- const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
634
- if (safeLength > 0) {
635
- const chunk = buffer.subarray(0, safeLength);
636
- size += chunk.length;
637
- if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
638
- if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
639
- await handle.write(chunk);
640
- buffer = buffer.subarray(safeLength);
641
- }
642
- if (!await pull()) throw new MultipartError("malformed", "unterminated multipart file part");
643
- continue;
644
- }
645
- const chunk = buffer.subarray(0, boundaryIndex);
646
- size += chunk.length;
647
- if (size > limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
648
- if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
649
- await handle.write(chunk);
650
- buffer = buffer.subarray(boundaryIndex + 2);
651
- break;
652
- }
653
- } finally {
654
- await handle.close();
655
- }
656
- if (filename === "" && size === 0) {
657
- await (0, node_fs_promises.unlink)(path);
658
- staged.splice(staged.indexOf(path), 1);
659
- } else {
660
- if (filename === "") {
661
- fileCount += 1;
662
- if (fileCount > limits.files) throw new MultipartError("limit", "too many multipart files");
663
- }
664
- const detected = detectMIME(head);
665
- const declared = contentType ?? "application/octet-stream";
666
- const validated = detected !== void 0 && detected === declared;
667
- if (allowed !== void 0) {
668
- if (!(detected !== void 0 && allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
669
- }
670
- if ((0, _orkestrel_server.isDangerousKey)(name)) {
671
- await (0, node_fs_promises.unlink)(path);
672
- staged.splice(staged.indexOf(path), 1);
673
- } else {
674
- const record = createUploadedFile({
675
- field: name,
676
- name: filename,
677
- size,
678
- mime: detected ?? declared,
679
- validated,
680
- status: "staged",
681
- path
682
- });
683
- const existing = files[name];
684
- if (existing === void 0) files[name] = [record];
685
- else existing.push(record);
686
- }
687
- }
688
- } else {
689
- fieldCount += 1;
690
- if (fieldCount > limits.fields) throw new MultipartError("limit", "too many multipart fields");
691
- let value = Buffer.alloc(0);
692
- for (;;) {
693
- const boundaryIndex = buffer.indexOf(partDelimiter);
694
- if (boundaryIndex === -1) {
695
- const safeLength = Math.max(0, buffer.length - (partDelimiter.length - 1));
696
- if (safeLength > 0) {
697
- value = Buffer.concat([value, buffer.subarray(0, safeLength)]);
698
- if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
699
- buffer = buffer.subarray(safeLength);
700
- }
701
- if (!await pull()) throw new MultipartError("malformed", "unterminated multipart field part");
702
- continue;
703
- }
704
- value = Buffer.concat([value, buffer.subarray(0, boundaryIndex)]);
705
- if (value.length > limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
706
- buffer = buffer.subarray(boundaryIndex + 2);
707
- break;
708
- }
709
- if (!(0, _orkestrel_server.isDangerousKey)(name)) fields[name] = value.toString("utf8");
710
- }
711
- while (buffer.length < openMarker.length) if (!await pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
712
- buffer = buffer.subarray(openMarker.length);
713
- }
714
- } catch (error) {
715
- await cleanup();
716
- await reader.cancel().catch(() => {});
717
- throw error;
718
- } finally {
719
- request.signal.removeEventListener("abort", onAbort);
720
- if (!ended) await reader.cancel().catch(() => {});
721
- }
722
- return {
723
- files: Object.freeze(files),
724
- fields: Object.freeze(fields)
725
- };
804
+ return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
726
805
  }
727
806
  /**
728
807
  * Build a frozen {@link UploadedFileInterface} record.
@@ -935,10 +1014,6 @@ function createStatic(options) {
935
1014
  const useETag = options.etag ?? true;
936
1015
  const fallback = options.fallback === true ? { exclude: DEFAULT_STATIC_FALLBACK_EXCLUDE } : options.fallback === false || options.fallback === void 0 ? void 0 : { exclude: options.fallback.exclude ?? "/api" };
937
1016
  let canonicalRootPromise;
938
- function canonicalRoot() {
939
- if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
940
- return canonicalRootPromise;
941
- }
942
1017
  return async (request, context, next) => {
943
1018
  if (context.method !== "GET" && context.method !== "HEAD") return next();
944
1019
  const target = resolveStaticPath(root, options.prefix, context.url.pathname);
@@ -948,47 +1023,83 @@ function createStatic(options) {
948
1023
  if (dotfiles === "deny") throw new _orkestrel_server.HTTPError(403, "forbidden");
949
1024
  if (dotfiles === "ignore") return next();
950
1025
  }
951
- let resolvedPath;
1026
+ let resolvedPath = target;
1027
+ let fallbackNeeded = false;
952
1028
  try {
953
- const [rootReal, targetReal] = await Promise.all([canonicalRoot(), (0, node_fs_promises.realpath)(target)]);
1029
+ if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
1030
+ const [rootReal, targetReal] = await Promise.all([canonicalRootPromise, (0, node_fs_promises.realpath)(target)]);
954
1031
  if (!isContainedPath(targetReal, rootReal)) return next();
955
1032
  resolvedPath = targetReal;
956
1033
  } catch {
957
- return trySpaFallback();
1034
+ fallbackNeeded = true;
958
1035
  }
959
1036
  let directoryInfo;
960
- try {
1037
+ if (!fallbackNeeded) try {
961
1038
  directoryInfo = await (0, node_fs_promises.stat)(resolvedPath);
962
1039
  } catch {
963
- return trySpaFallback();
1040
+ fallbackNeeded = true;
964
1041
  }
965
- if (directoryInfo.isDirectory()) {
1042
+ if (directoryInfo?.isDirectory()) {
966
1043
  resolvedPath = (0, node_path.join)(resolvedPath, index);
967
1044
  try {
968
- const [rootReal, indexReal] = await Promise.all([canonicalRoot(), (0, node_fs_promises.realpath)(resolvedPath)]);
969
- if (!isContainedPath(indexReal, rootReal)) return trySpaFallback();
970
- resolvedPath = indexReal;
1045
+ if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
1046
+ const [rootReal, indexReal] = await Promise.all([canonicalRootPromise, (0, node_fs_promises.realpath)(resolvedPath)]);
1047
+ if (isContainedPath(indexReal, rootReal)) resolvedPath = indexReal;
1048
+ else fallbackNeeded = true;
971
1049
  } catch {
972
- return trySpaFallback();
1050
+ fallbackNeeded = true;
973
1051
  }
974
1052
  }
975
1053
  let handle;
976
- try {
1054
+ let info;
1055
+ if (!fallbackNeeded) try {
977
1056
  handle = await (0, node_fs_promises.open)(resolvedPath, "r");
978
1057
  } catch {
979
- return trySpaFallback();
1058
+ fallbackNeeded = true;
980
1059
  }
981
- let info;
982
- try {
1060
+ if (handle !== void 0) try {
983
1061
  info = await handle.stat();
984
1062
  } catch {
985
1063
  await handle.close();
986
- return trySpaFallback();
1064
+ handle = void 0;
1065
+ fallbackNeeded = true;
987
1066
  }
988
- if (!info.isFile()) {
1067
+ if (handle !== void 0 && info !== void 0 && !info.isFile()) {
989
1068
  await handle.close();
990
- return trySpaFallback();
1069
+ handle = void 0;
1070
+ info = void 0;
1071
+ fallbackNeeded = true;
1072
+ }
1073
+ if (fallbackNeeded) {
1074
+ const shellPath = resolveStaticFallbackPath(root, index, fallback?.exclude ?? "/api", context.method, context.url.pathname, request.headers.get("accept") ?? "");
1075
+ if (fallback === void 0 || shellPath === void 0) return next();
1076
+ let shellReal;
1077
+ try {
1078
+ if (canonicalRootPromise === void 0) canonicalRootPromise = (0, node_fs_promises.realpath)(root);
1079
+ const [rootReal, candidate] = await Promise.all([canonicalRootPromise, (0, node_fs_promises.realpath)(shellPath)]);
1080
+ if (!isContainedPath(candidate, rootReal)) return next();
1081
+ shellReal = candidate;
1082
+ } catch {
1083
+ return next();
1084
+ }
1085
+ let shellHandle;
1086
+ try {
1087
+ shellHandle = await (0, node_fs_promises.open)(shellReal, "r");
1088
+ } catch {
1089
+ return next();
1090
+ }
1091
+ try {
1092
+ const body = streamFile(shellHandle);
1093
+ return new Response(body, {
1094
+ status: 200,
1095
+ headers: new Headers({ "content-type": lookupContentType(shellReal) })
1096
+ });
1097
+ } catch (error) {
1098
+ await shellHandle.close().catch(() => {});
1099
+ throw error;
1100
+ }
991
1101
  }
1102
+ if (handle === void 0 || info === void 0) return next();
992
1103
  let streaming = false;
993
1104
  try {
994
1105
  const headers = new Headers({
@@ -1050,32 +1161,6 @@ function createStatic(options) {
1050
1161
  if (!streaming) await handle.close().catch(() => {});
1051
1162
  throw error;
1052
1163
  }
1053
- function trySpaFallback() {
1054
- if (fallback === void 0) return next();
1055
- if (context.method !== "GET") return next();
1056
- if ((0, node_path.extname)(context.url.pathname) !== "") return next();
1057
- const accept = request.headers.get("accept") ?? "";
1058
- if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
1059
- if (isUnderPath(context.url.pathname, fallback.exclude)) return next();
1060
- const shellPath = (0, node_path.join)(root, index);
1061
- return Promise.all([canonicalRoot(), (0, node_fs_promises.realpath)(shellPath)]).then(([rootReal, shellReal]) => {
1062
- if (!isContainedPath(shellReal, rootReal)) return next();
1063
- return (0, node_fs_promises.open)(shellReal, "r").then((shellHandle) => {
1064
- let shellStreaming = false;
1065
- try {
1066
- const body = streamFile(shellHandle);
1067
- shellStreaming = true;
1068
- return new Response(body, {
1069
- status: 200,
1070
- headers: new Headers({ "content-type": lookupContentType(shellReal) })
1071
- });
1072
- } catch (error) {
1073
- if (!shellStreaming) shellHandle.close().catch(() => {});
1074
- throw error;
1075
- }
1076
- });
1077
- }).catch(() => next());
1078
- }
1079
1164
  };
1080
1165
  }
1081
1166
  /**
@@ -1119,7 +1204,7 @@ function createMultipart(options = {}) {
1119
1204
  throw error;
1120
1205
  }
1121
1206
  if (body === void 0) return next();
1122
- context.state.multipart = body;
1207
+ Object.assign(context.state, { multipart: body });
1123
1208
  try {
1124
1209
  return await next();
1125
1210
  } catch (error) {
@@ -1164,14 +1249,12 @@ function createCompression(options) {
1164
1249
  const threshold = options?.threshold ?? _src_core.DEFAULT_COMPRESSION_THRESHOLD;
1165
1250
  const filter = options?.filter;
1166
1251
  const encodings = ["gzip", "deflate"];
1167
- const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1168
- const deflateAsync = (0, node_util.promisify)(node_zlib.deflate);
1169
1252
  return async (request, context, next) => {
1170
1253
  return (0, _src_core.compressResponse)(request, context, await next(), {
1171
1254
  threshold,
1172
- filter,
1255
+ ...filter !== void 0 ? { filter } : {},
1173
1256
  encodings,
1174
- compress: async (bytes, encoding) => encoding === "gzip" ? gzipAsync(bytes) : deflateAsync(bytes)
1257
+ compress: compressNodeBytes
1175
1258
  });
1176
1259
  };
1177
1260
  }
@@ -1190,6 +1273,7 @@ exports.MULTIPART_MAX_PREAMBLE = MULTIPART_MAX_PREAMBLE;
1190
1273
  exports.MULTIPART_REASON_STATUS = MULTIPART_REASON_STATUS;
1191
1274
  exports.MultipartError = MultipartError;
1192
1275
  exports.RESERVED_DEVICE_NAMES = RESERVED_DEVICE_NAMES;
1276
+ exports.compressNodeBytes = compressNodeBytes;
1193
1277
  exports.computeFileETag = computeFileETag;
1194
1278
  exports.createCompression = createCompression;
1195
1279
  exports.createMultipart = createMultipart;
@@ -1202,6 +1286,7 @@ exports.isMultipartError = isMultipartError;
1202
1286
  exports.isReservedDeviceName = isReservedDeviceName;
1203
1287
  exports.isUnderPath = isUnderPath;
1204
1288
  exports.lookupContentType = lookupContentType;
1289
+ exports.matchesBytes = matchesBytes;
1205
1290
  exports.moveUploadedFile = moveUploadedFile;
1206
1291
  exports.multipartBoundary = multipartBoundary;
1207
1292
  exports.parseMultipartRequest = parseMultipartRequest;
@@ -1209,6 +1294,7 @@ exports.parsePartHeaders = parsePartHeaders;
1209
1294
  exports.readUploadedFile = readUploadedFile;
1210
1295
  exports.resolveDefaultDirectory = resolveDefaultDirectory;
1211
1296
  exports.resolveMultipartLimits = resolveMultipartLimits;
1297
+ exports.resolveStaticFallbackPath = resolveStaticFallbackPath;
1212
1298
  exports.resolveStaticPath = resolveStaticPath;
1213
1299
  exports.streamFile = streamFile;
1214
1300
  exports.streamUploadedFile = streamUploadedFile;