@orkestrel/middleware 0.0.1

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.
@@ -0,0 +1,729 @@
1
+ import { FileHandle } from 'node:fs/promises';
2
+ import { MiddlewareHandler } from '@orkestrel/server';
3
+ import { MultipartBody } from '../core/index.ts';
4
+ import { MultipartFile } from '../core/index.ts';
5
+ import { MultipartState } from '../core/index.ts';
6
+
7
+ /**
8
+ * Compute a static file's weak ETag from its size and modification time.
9
+ *
10
+ * @param size - The file's byte size
11
+ * @param mtimeMs - The file's modification time in milliseconds
12
+ * @returns A weak entity-tag `W/"<size>-<floor(mtimeMs)>"`
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * computeFileETag(1024, 1700000000123.4) // 'W/"1024-1700000000123"'
17
+ * ```
18
+ */
19
+ export declare function computeFileETag(size: number, mtimeMs: number): string;
20
+
21
+ /**
22
+ * Compress response bodies via `node:zlib` — the node-bound sibling of the
23
+ * core face's `CompressionStream`-feature-detected `createCompression`,
24
+ * guaranteed available on any Node runtime rather than dependent on the
25
+ * WHATWG `CompressionStream` global (PROPOSAL §4.3, ruling J). Ships as a
26
+ * SEPARATE package entry point (`@orkestrel/middleware/server`) from the core
27
+ * face's `createCompression`, so the shared name is unambiguous per
28
+ * consumer import path (ruling H).
29
+ *
30
+ * @remarks
31
+ * Peer-type limitation (same one U1 recorded on the core face): the shipped
32
+ * `@orkestrel/server` `Encoding` union is `'gzip' | 'deflate' | 'identity'`
33
+ * — it does not include `'br'`, so this battery cannot honestly type or
34
+ * negotiate a guaranteed brotli coding despite `node:zlib` shipping
35
+ * `brotliCompress`. It guarantees `gzip`/`deflate` via `node:zlib` (never
36
+ * feature-detected — always available) and negotiates only those.
37
+ *
38
+ * @typeParam TState - The consumer's opaque per-request state type
39
+ * @param options - See {@link NodeCompressionOptions}
40
+ * @returns A `MiddlewareHandler<TState>`
41
+ * @throws {TypeError} When `options.threshold` is provided and is not a
42
+ * finite number, or `options.filter` is provided and is not a function
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { createCompression } from '@orkestrel/middleware/server'
47
+ *
48
+ * const compress = createCompression({ threshold: 512 })
49
+ * ```
50
+ */
51
+ export declare function createCompression<TState>(options?: NodeCompressionOptions): MiddlewareHandler<TState>;
52
+
53
+ /**
54
+ * Parse a streamed `multipart/form-data` request body and stash its
55
+ * {@link MultipartBody} on `context.state.multipart` — the node-bound
56
+ * streaming multipart battery (PROPOSAL §4.15, ruling C).
57
+ *
58
+ * @remarks
59
+ * A non-multipart request passes through untouched. Consumes `request.body`
60
+ * as a stream — `context.body()` must not be called for a request this
61
+ * battery has processed (the underlying stream is exhausted). Every
62
+ * {@link MultipartError} this battery's parser throws is re-thrown as an
63
+ * {@link HTTPError} carrying the same status/message, so `createBoundary`
64
+ * (or any HTTPError-aware renderer) maps it correctly without depending on
65
+ * this node face's error type. Fail-closed on the DOWNSTREAM handler too: if
66
+ * `next()` throws, every still-`'staged'` uploaded file is unlinked
67
+ * (best-effort) before the error is re-thrown, so an unhandled downstream
68
+ * failure never leaks temp files. A normal return leaves staged files
69
+ * untouched — the downstream handler owns moving/reading them.
70
+ *
71
+ * @typeParam TState - The consumer's state type, extending {@link MultipartState}
72
+ * @param options - See {@link MultipartOptions}
73
+ * @returns A `MiddlewareHandler<TState>`
74
+ * @throws {HTTPError} When the underlying parse throws a {@link MultipartError}
75
+ * (limit breach → 413, malformed structure → 400, rejected file type → 415)
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import { createMultipart } from '@orkestrel/middleware/server'
80
+ *
81
+ * const uploads = createMultipart({ allowed: ['image/png', 'image/jpeg'] })
82
+ * ```
83
+ */
84
+ export declare function createMultipart<TState extends MultipartState>(options?: MultipartOptions): MiddlewareHandler<TState>;
85
+
86
+ /**
87
+ * Serve static files from `options.root` over `node:fs` — the node-bound
88
+ * static-file battery (PROPOSAL §4.14).
89
+ *
90
+ * @remarks
91
+ * Containment is enforced on CANONICAL paths, not merely the lexically
92
+ * resolved one: `options.root` is canonicalized once (memoized) and every
93
+ * request's candidate path is re-canonicalized (`fs.realpath`) before it is
94
+ * served, so a symlink whose target escapes `root` is refused (falls through
95
+ * to `next()`) even though the lexical path resolved inside `root`. A
96
+ * symlink that resolves to a target still INSIDE `root` is unaffected and
97
+ * still serves normally. A dangling symlink (`realpath` throws `ENOENT`) or
98
+ * any other `realpath` failure is treated as a miss — this battery never
99
+ * throws or 500s on a symlink surprise. On a streamed response (a 200 or 206
100
+ * that carries a file body), the open `FileHandle` is owned by the
101
+ * `Response` body and is released only once that body is fully read or
102
+ * cancelled — Node HTTP servers do this automatically when sending the
103
+ * response, but a caller that holds an unread `Response` (e.g. in a test)
104
+ * must cancel its body to release the handle promptly.
105
+ *
106
+ * @typeParam TState - The consumer's opaque per-request state type
107
+ * @param options - See {@link StaticOptions}
108
+ * @returns A `MiddlewareHandler<TState>`
109
+ * @throws {TypeError} When `options.root` is not a non-empty string
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * import { createStatic } from '@orkestrel/middleware/server'
114
+ *
115
+ * const serveFiles = createStatic({ root: '/srv/public', fallback: true })
116
+ * ```
117
+ */
118
+ export declare function createStatic<TState>(options: StaticOptions): MiddlewareHandler<TState>;
119
+
120
+ /**
121
+ * Build a frozen {@link UploadedFileInterface} record.
122
+ *
123
+ * @param input - Every field of the record
124
+ * @returns A frozen {@link UploadedFileInterface}
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * createUploadedFile({ field: 'avatar', name: 'a.png', size: 1024, mime: 'image/png', validated: true, status: 'staged', path: '/tmp/x' })
129
+ * ```
130
+ */
131
+ export declare function createUploadedFile(input: UploadedFileInput): UploadedFileInterface;
132
+
133
+ /** The MIME type served when a file extension has no known mapping. */
134
+ export declare const DEFAULT_CONTENT_TYPE = "application/octet-stream";
135
+
136
+ /** `createMultipart`'s default per-field byte-size cap. */
137
+ export declare const DEFAULT_MULTIPART_FIELD = 65536;
138
+
139
+ /** `createMultipart`'s default maximum field-part count. */
140
+ export declare const DEFAULT_MULTIPART_FIELDS = 100;
141
+
142
+ /** `createMultipart`'s default per-file byte-size cap. */
143
+ export declare const DEFAULT_MULTIPART_FILE = 10485760;
144
+
145
+ /** `createMultipart`'s default maximum file-part count. */
146
+ export declare const DEFAULT_MULTIPART_FILES = 10;
147
+
148
+ /** `createMultipart`'s default combined request-body byte-size cap. */
149
+ export declare const DEFAULT_MULTIPART_TOTAL = 52428800;
150
+
151
+ /** `createStatic`'s `fallback: true` default excluded path prefix. */
152
+ export declare const DEFAULT_STATIC_FALLBACK_EXCLUDE = "/api";
153
+
154
+ /** `createStatic`'s default directory-index filename. */
155
+ export declare const DEFAULT_STATIC_INDEX = "index.html";
156
+
157
+ /**
158
+ * Sniff a MIME type from a file's leading bytes against a small magic-byte
159
+ * table (jpeg, png, gif87a/89a, webp, pdf, zip) — the SNIFF-AUTHORITATIVE
160
+ * signal `createMultipart`'s type validation rests on, never the declared
161
+ * `Content-Type`.
162
+ *
163
+ * @param head - The file's first bytes (16 is sufficient for every signature)
164
+ * @returns The detected MIME type, or `undefined` when no signature matches
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * detectMIME(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) // 'image/png'
169
+ * ```
170
+ */
171
+ export declare function detectMIME(head: Uint8Array): string | undefined;
172
+
173
+ /** File-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. */
174
+ export declare const EXTENSION_TYPES: Readonly<Record<string, string>>;
175
+
176
+ /**
177
+ * Whether `child` is `parent` itself or lies inside it on-disk — the
178
+ * FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
179
+ * output (never to a URL pathname — that is {@link isUnderPath}'s job).
180
+ *
181
+ * @remarks
182
+ * Argument order is `(child, parent)` — deliberately the OPPOSITE conceptual
183
+ * order from {@link isUnderPath}`(pathname, prefix)`, so a call site cannot
184
+ * casually swap one predicate in for the other. Built on `path.relative`,
185
+ * this is separator-correct on both POSIX (`/`) and win32 (`\`) — unlike a
186
+ * hardcoded `${parent}/` boundary check, which silently fails to match every
187
+ * realpath on Windows — and case-folds on win32 because `path.relative` does.
188
+ *
189
+ * @param child - The absolute on-disk path to test
190
+ * @param parent - The absolute on-disk directory it must lie under
191
+ * @returns `true` when `child` equals `parent` or resolves inside it
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * isContainedPath('/srv/public/a.html', '/srv/public') // true
196
+ * isContainedPath('/srv/other/a.html', '/srv/public') // false
197
+ * ```
198
+ */
199
+ export declare function isContainedPath(child: string, parent: string): boolean;
200
+
201
+ /**
202
+ * Whether a relative path (already resolved under a static root) has any
203
+ * segment starting with `.` — a dotfile or dot-directory.
204
+ *
205
+ * @param relativePath - A path relative to the static root
206
+ * @returns `true` when any segment starts with `.`
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * isDotfilePath('.env') // true
211
+ * isDotfilePath('a/.git/config') // true
212
+ * ```
213
+ */
214
+ export declare function isDotfilePath(relativePath: string): boolean;
215
+
216
+ /**
217
+ * Narrow an unknown caught value to a {@link MultipartError}.
218
+ *
219
+ * @remarks
220
+ * Structural, not `instanceof` — tests that `value` is a non-null object
221
+ * carrying the module-scope brand, a numeric `status`, and a `reason` in the
222
+ * parser's set of reason strings (`'limit' | 'malformed' | 'rejected'`).
223
+ * Total: never throws, returns `false` for any off-shape input.
224
+ *
225
+ * @param value - The value to test (typically a `catch` binding)
226
+ * @returns `true` when `value` is a {@link MultipartError}
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * import { isMultipartError } from '@orkestrel/middleware/server'
231
+ *
232
+ * try {
233
+ * await parse(request)
234
+ * } catch (error) {
235
+ * if (isMultipartError(error)) console.log(error.status, error.reason)
236
+ * }
237
+ * ```
238
+ */
239
+ export declare function isMultipartError(value: unknown): value is MultipartError;
240
+
241
+ /**
242
+ * Whether a path segment is a Windows reserved device name (CVE-2025-27210).
243
+ *
244
+ * @remarks
245
+ * Normalizes superscript digits (`¹²³` → `123`) first, strips trailing dots
246
+ * and spaces (Windows drops them), takes the STEM before the first `.`,
247
+ * upper-cases it, and tests it against {@link RESERVED_DEVICE_NAMES}
248
+ * (`CON PRN AUX NUL COM1-9 LPT1-9`) — an exact-stem match only, so
249
+ * `console.js` and `nullable.css` are never flagged.
250
+ *
251
+ * @param segment - One path segment (no separators)
252
+ * @returns `true` when `segment` names a reserved device
253
+ *
254
+ * @example
255
+ * ```ts
256
+ * isReservedDeviceName('NUL.json') // true
257
+ * isReservedDeviceName('nullable.css') // false
258
+ * isReservedDeviceName('CON¹') // true
259
+ * ```
260
+ */
261
+ export declare function isReservedDeviceName(segment: string): boolean;
262
+
263
+ /**
264
+ * Whether `pathname` is `prefix` itself or lies under it on a SEGMENT
265
+ * boundary — the shared under-path test `resolveStaticPath`'s prefix strip
266
+ * and `createStatic`'s SPA-fallback `exclude` both apply, so `exclude:
267
+ * '/api'` matches `/api` and `/api/x` but never `/apifoo`.
268
+ *
269
+ * @param pathname - The request pathname to test
270
+ * @param prefix - The path prefix to test against
271
+ * @returns `true` when `pathname` equals `prefix` or starts with `prefix` + `/`
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * isUnderPath('/api/x', '/api') // true
276
+ * isUnderPath('/apifoo', '/api') // false
277
+ * ```
278
+ */
279
+ export declare function isUnderPath(pathname: string, prefix: string): boolean;
280
+
281
+ /**
282
+ * Look up the MIME type for a static file path by its extension.
283
+ *
284
+ * @param pathname - The file's path (only its extension is read)
285
+ * @returns The mapped MIME type, or {@link DEFAULT_CONTENT_TYPE} when unknown
286
+ *
287
+ * @example
288
+ * ```ts
289
+ * lookupContentType('/a/b.css') // 'text/css; charset=utf-8'
290
+ * ```
291
+ */
292
+ export declare function lookupContentType(pathname: string): string;
293
+
294
+ /**
295
+ * Move a staged uploaded file to its final `destination`.
296
+ *
297
+ * @remarks
298
+ * Attempts a `rename` first; on a cross-device error (`EXDEV`) falls back to
299
+ * `copyFile` + `unlink`. Returns a new frozen record with `status: 'moved'`
300
+ * and `path: destination` — the input record is never mutated.
301
+ *
302
+ * @param file - The {@link UploadedFileInterface} record to move
303
+ * @param destination - The final on-disk path
304
+ * @returns A new {@link UploadedFileInterface} record reflecting the move
305
+ *
306
+ * @example
307
+ * ```ts
308
+ * const moved = await moveUploadedFile(file, '/var/uploads/final.png')
309
+ * ```
310
+ */
311
+ export declare function moveUploadedFile(file: UploadedFileInterface, destination: string): Promise<UploadedFileInterface>;
312
+
313
+ /** The maximum bytes a single multipart part's header block may occupy before it is malformed. */
314
+ export declare const MULTIPART_MAX_HEADER_BLOCK = 16384;
315
+
316
+ /** The maximum bytes scanned before the first multipart boundary is found before it is malformed. */
317
+ export declare const MULTIPART_MAX_PREAMBLE = 65536;
318
+
319
+ /** The HTTP status `createMultipart` renders for each {@link MultipartReason}. */
320
+ export declare const MULTIPART_REASON_STATUS: Readonly<Record<MultipartReason, number>>;
321
+
322
+ /**
323
+ * Extract the `boundary` parameter from a `Content-Type` header, or
324
+ * `undefined` when the request is not `multipart/form-data`.
325
+ *
326
+ * @param contentType - The request's `Content-Type` header value, if present
327
+ * @returns The multipart boundary token, or `undefined` for a non-multipart
328
+ * (or malformed/boundary-less) content type
329
+ *
330
+ * @example
331
+ * ```ts
332
+ * multipartBoundary('multipart/form-data; boundary=abc123') // 'abc123'
333
+ * multipartBoundary('application/json') // undefined
334
+ * ```
335
+ */
336
+ export declare function multipartBoundary(contentType: string | null): string | undefined;
337
+
338
+ /**
339
+ * An error `createMultipart` throws when a streamed multipart request fails
340
+ * a mid-stream limit, is structurally malformed, or has a file whose sniffed
341
+ * bytes are rejected by the configured `allowed` MIME list.
342
+ *
343
+ * @remarks
344
+ * Carries the HTTP `status` derived from `reason` (limit → 413, malformed →
345
+ * 400, rejected → 415) and an optional `context` record. Rendered by
346
+ * `createBoundary` like any other `HTTPError`-shaped throw. Narrow a caught
347
+ * value with {@link isMultipartError}.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * import { MultipartError } from '@orkestrel/middleware/server'
352
+ *
353
+ * throw new MultipartError('limit', 'too many files')
354
+ * ```
355
+ */
356
+ export declare class MultipartError extends Error {
357
+ readonly status: number;
358
+ readonly reason: MultipartReason;
359
+ readonly context?: Readonly<Record<string, unknown>>;
360
+ constructor(reason: MultipartReason, message: string, context?: Readonly<Record<string, unknown>>);
361
+ }
362
+
363
+ /**
364
+ * Per-category size/count caps `createMultipart` enforces MID-STREAM.
365
+ *
366
+ * @remarks
367
+ * - `file` — the maximum size in bytes of one uploaded file; defaults to
368
+ * {@link DEFAULT_MULTIPART_FILE}.
369
+ * - `files` — the maximum number of file parts; defaults to
370
+ * {@link DEFAULT_MULTIPART_FILES}.
371
+ * - `field` — the maximum size in bytes of one text field; defaults to
372
+ * {@link DEFAULT_MULTIPART_FIELD}.
373
+ * - `fields` — the maximum number of text field parts; defaults to
374
+ * {@link DEFAULT_MULTIPART_FIELDS}.
375
+ * - `total` — the maximum combined byte size of the whole request body;
376
+ * defaults to {@link DEFAULT_MULTIPART_TOTAL}.
377
+ */
378
+ export declare interface MultipartLimits {
379
+ readonly file?: number;
380
+ readonly files?: number;
381
+ readonly field?: number;
382
+ readonly fields?: number;
383
+ readonly total?: number;
384
+ }
385
+
386
+ /**
387
+ * Options for `createMultipart` — node `fs`/`os`/`crypto`-backed streaming
388
+ * multipart upload parsing.
389
+ *
390
+ * @param options - See fields below
391
+ * @remarks
392
+ * - `limits` — see {@link MultipartLimits}.
393
+ * - `allowed` — a MIME allow-list validated against SNIFFED (not merely
394
+ * declared) bytes; an empty array allows nothing. Omitted ⇒ no type
395
+ * rejection.
396
+ * - `directory` — the directory staged files are written to; defaults to
397
+ * `os.tmpdir()`.
398
+ */
399
+ export declare interface MultipartOptions {
400
+ readonly limits?: MultipartLimits;
401
+ readonly allowed?: readonly string[];
402
+ readonly directory?: string;
403
+ }
404
+
405
+ /**
406
+ * Why `createMultipart` rejected a request — the axis {@link MultipartError}
407
+ * maps onto its HTTP status: `'limit'` → 413, `'malformed'` → 400,
408
+ * `'rejected'` → 415.
409
+ */
410
+ export declare type MultipartReason = 'limit' | 'malformed' | 'rejected';
411
+
412
+ /**
413
+ * Options for the node face's `createCompression` — `node:zlib`-backed
414
+ * response compression.
415
+ *
416
+ * @param options - See fields below
417
+ * @remarks
418
+ * - `threshold` — the minimum buffered body size (bytes) worth compressing;
419
+ * defaults to {@link DEFAULT_COMPRESSION_THRESHOLD}.
420
+ * - `filter` — an additional predicate a response must pass before
421
+ * compression is attempted; defaults to always-allow. `encodings` is fixed
422
+ * to `['gzip', 'deflate']` and is not configurable (see the peer `Encoding`
423
+ * type limitation documented on `createCompression`).
424
+ */
425
+ export declare interface NodeCompressionOptions {
426
+ readonly threshold?: number;
427
+ readonly filter?: (request: Request, response: Response) => boolean;
428
+ }
429
+
430
+ /**
431
+ * Stream-parse a `multipart/form-data` request into its files and fields —
432
+ * the mid-stream state machine `createMultipart` drives (PROPOSAL §4.15).
433
+ *
434
+ * @remarks
435
+ * Reads `request.body` chunk by chunk via its `ReadableStream` reader —
436
+ * NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
437
+ * the instant it is exceeded (reading stops, every already-staged temp file
438
+ * is deleted, throws {@link MultipartError} with reason `'limit'`). Each file
439
+ * part streams to `join(directory, randomUUID())` — the client's declared
440
+ * filename is METADATA ONLY, never a path component. A field OR file part
441
+ * named `__proto__` / `constructor` / `prototype` is silently skipped and
442
+ * never keyed onto the returned {@link MultipartBody} (a skipped file's
443
+ * staged temp file is unlinked immediately, since it can never be
444
+ * referenced). A file part with an empty declared filename (`filename=""`)
445
+ * AND a zero-byte body — the browser convention for an unselected optional
446
+ * `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
447
+ * never counted against the `files` limit, and it never runs the `allowed`
448
+ * check. A malformed
449
+ * structure (missing/unterminated boundary, nameless part, an oversized
450
+ * header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
451
+ * the first boundary) throws with reason `'malformed'`. A file is accepted
452
+ * against the configured `allowed` MIME list iff its SNIFFED bytes detect a
453
+ * type present in the list — sniff-authoritative, independent of whether the
454
+ * declared `Content-Type` matches (that agreement is exposed separately as
455
+ * `validated`); otherwise throws with reason `'rejected'`. A
456
+ * request abort mid-upload triggers the same fail-closed cleanup as a limit
457
+ * breach. Returns `undefined` for a non-multipart request (untouched).
458
+ *
459
+ * @param request - The incoming multipart request
460
+ * @param options - See {@link MultipartOptions}
461
+ * @returns The parsed {@link MultipartBody}, or `undefined` when the request
462
+ * is not `multipart/form-data`
463
+ * @throws {MultipartError} On any limit breach, malformed structure, or
464
+ * rejected file type
465
+ *
466
+ * @example
467
+ * ```ts
468
+ * const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
469
+ * ```
470
+ */
471
+ export declare function parseMultipartRequest(request: Request, options?: MultipartOptions): Promise<MultipartBody | undefined>;
472
+
473
+ /**
474
+ * Parse one multipart part's raw header block into its `name` (from
475
+ * `Content-Disposition`), optional `filename`, and optional `Content-Type`.
476
+ *
477
+ * @param block - The raw header block for one multipart part (before the
478
+ * terminating blank line)
479
+ * @returns The parsed `name`, `filename`, and `contentType` (each
480
+ * `undefined` when absent)
481
+ *
482
+ * @example
483
+ * ```ts
484
+ * parsePartHeaders('Content-Disposition: form-data; name="title"')
485
+ * // { name: 'title', filename: undefined, contentType: undefined }
486
+ * ```
487
+ */
488
+ export declare function parsePartHeaders(block: string): PartHeaders;
489
+
490
+ /**
491
+ * One multipart part's parsed header block — `parsePartHeaders`'s return
492
+ * shape.
493
+ *
494
+ * @remarks
495
+ * - `name` — the `Content-Disposition` `name` parameter, or `undefined` when absent.
496
+ * - `filename` — the `Content-Disposition` `filename` parameter, or `undefined` when absent.
497
+ * - `contentType` — the part's declared `Content-Type` header value, or `undefined` when absent.
498
+ */
499
+ export declare interface PartHeaders {
500
+ readonly name: string | undefined;
501
+ readonly filename: string | undefined;
502
+ readonly contentType: string | undefined;
503
+ }
504
+
505
+ /**
506
+ * Read a staged/moved uploaded file's full contents into memory.
507
+ *
508
+ * @param file - The {@link UploadedFileInterface} record to read
509
+ * @returns The file's bytes
510
+ *
511
+ * @example
512
+ * ```ts
513
+ * const bytes = await readUploadedFile(file)
514
+ * ```
515
+ */
516
+ export declare function readUploadedFile(file: UploadedFileInterface): Promise<Uint8Array>;
517
+
518
+ /**
519
+ * Windows reserved device-name stems (CVE-2025-27210) — matched
520
+ * case-insensitively against the segment's stem (before its first `.`).
521
+ */
522
+ export declare const RESERVED_DEVICE_NAMES: ReadonlySet<string>;
523
+
524
+ /**
525
+ * Resolve `parseMultipartRequest`'s default staging directory when the
526
+ * caller did not configure one — a process-owned directory created ONCE
527
+ * (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
528
+ * locked to mode `0o700`.
529
+ *
530
+ * @returns The absolute path of the process-owned staging directory
531
+ *
532
+ * @example
533
+ * ```ts
534
+ * const directory = await resolveDefaultDirectory()
535
+ * ```
536
+ */
537
+ export declare function resolveDefaultDirectory(): Promise<string>;
538
+
539
+ /**
540
+ * Resolve `createMultipart`'s effective {@link MultipartLimits}, applying
541
+ * every documented default.
542
+ *
543
+ * @param limits - The caller's partial limits
544
+ * @returns The fully-resolved limits
545
+ */
546
+ export declare function resolveMultipartLimits(limits: MultipartLimits | undefined): Required<MultipartLimits>;
547
+
548
+ /**
549
+ * Resolve a request pathname to an on-disk path UNDER `root`, or `undefined`
550
+ * when it cannot — the traversal guard, EXACT algorithm and order (PROPOSAL
551
+ * §4.14): strip `prefix` on a segment boundary → `decodeURIComponent` (a
552
+ * malformed escape refuses, never throws) → reject a NUL byte → strip the
553
+ * leading path separator FIRST (so a leading `..` survives `normalize` as a
554
+ * genuine climbing segment) → `normalize` → refuse any Windows reserved-
555
+ * device-name segment ({@link isReservedDeviceName}) → `resolve` and require
556
+ * the result under `root`.
557
+ *
558
+ * @param root - The absolute root directory every result must resolve under
559
+ * @param prefix - An optional URL path prefix stripped on a segment boundary
560
+ * @param pathname - The raw request pathname
561
+ * @returns The resolved absolute path under `root`, or `undefined` when the
562
+ * request does not resolve (out of prefix, malformed escape, NUL byte,
563
+ * reserved device name, or an attempted escape from `root`)
564
+ *
565
+ * @example
566
+ * ```ts
567
+ * resolveStaticPath('/srv/public', '/api', '/api/../../etc/passwd') // undefined
568
+ * ```
569
+ */
570
+ export declare function resolveStaticPath(root: string, prefix: string | undefined, pathname: string): string | undefined;
571
+
572
+ /**
573
+ * Options for `createStatic` — node `fs`-backed static file serving.
574
+ *
575
+ * @param options - See fields below
576
+ * @remarks
577
+ * - `root` — the directory every request resolves under, resolved once at
578
+ * construction. REQUIRED.
579
+ * - `prefix` — a URL path prefix stripped (on a segment boundary) before
580
+ * resolving under `root`.
581
+ * - `index` — the filename served for a directory hit; defaults to
582
+ * {@link DEFAULT_STATIC_INDEX}.
583
+ * - `dotfiles` — the policy for a path with a dotfile segment: `'ignore'`
584
+ * (default, falls through to `next()`), `'deny'` (403), or `'allow'`
585
+ * (serves it).
586
+ * - `cache` — `Cache-Control: max-age=<cache>` in seconds, when set.
587
+ * - `etag` — whether to compute and honor a weak file `ETag`; defaults to `true`.
588
+ * - `fallback` — SPA fallback: `false` (default, off), `true` (on, excluding
589
+ * {@link DEFAULT_STATIC_FALLBACK_EXCLUDE}), or `{ exclude }` for a custom
590
+ * excluded prefix.
591
+ */
592
+ export declare interface StaticOptions {
593
+ readonly root: string;
594
+ readonly prefix?: string;
595
+ readonly index?: string;
596
+ readonly dotfiles?: 'ignore' | 'deny' | 'allow';
597
+ readonly cache?: number;
598
+ readonly etag?: boolean;
599
+ readonly fallback?: boolean | {
600
+ readonly exclude?: string;
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Adapt a `node:fs` read stream over `path` (or an already-open
606
+ * `FileHandle`) into a DOM-compatible `ReadableStream<Uint8Array>` — the
607
+ * single shared node↔web stream bridge every static-file and uploaded-file
608
+ * response body routes through.
609
+ *
610
+ * @remarks
611
+ * PULL-driven, not push-driven: the underlying node stream's async iterator
612
+ * is only advanced (`iterator.next()`) from inside `pull(controller)`, which
613
+ * the web `ReadableStream` invokes exactly when its internal queue has room
614
+ * for more data. Exactly one disk chunk is read and enqueued per `pull` —
615
+ * never more — so a slow or stalled consumer (a stalled HTTP connection)
616
+ * simply stops triggering `pull` calls and the source stops reading ahead;
617
+ * this is genuine consumer backpressure, not the "naturally backpressured"
618
+ * `for await`/`enqueue` pattern (which does not block on a slow consumer at
619
+ * all, since `enqueue` returns synchronously). The controller is closed on
620
+ * iterator completion and errored (never thrown into the process) on a
621
+ * mid-stream read failure. Cancelling the returned `ReadableStream` (e.g. the
622
+ * consumer aborts the response) calls the iterator's `return()`, which
623
+ * destroys the underlying node read stream so the file descriptor is
624
+ * released. When `path` is a `FileHandle`, `FileHandle.createReadStream`'s
625
+ * default `autoClose` closes the handle on every terminal path (end, error,
626
+ * or `destroy()` via the iterator's `return()`) — the caller never needs a
627
+ * separate `handle.close()` for a handle passed here.
628
+ *
629
+ * @param source - The absolute on-disk file path to stream, or an already-open
630
+ * `FileHandle` (e.g. one already `fstat`'d so the served bytes match the
631
+ * headers computed from that same `fstat`)
632
+ * @param range - An optional inclusive byte range (`start`/`end`, both
633
+ * 0-indexed and inclusive, matching `node:fs`'s `createReadStream` options)
634
+ * @returns A `ReadableStream<Uint8Array>` valid as a fetch `BodyInit`
635
+ *
636
+ * @example
637
+ * ```ts
638
+ * new Response(streamFile('/srv/public/index.html'))
639
+ * ```
640
+ */
641
+ export declare function streamFile(source: string | FileHandle, range?: {
642
+ readonly start: number;
643
+ readonly end: number;
644
+ }): ReadableStream<Uint8Array>;
645
+
646
+ /**
647
+ * Open a staged/moved uploaded file as a web `ReadableStream`.
648
+ *
649
+ * @param file - The {@link UploadedFileInterface} record to stream
650
+ * @returns A `ReadableStream<Uint8Array>` over the file's current on-disk path
651
+ *
652
+ * @example
653
+ * ```ts
654
+ * new Response(streamUploadedFile(file))
655
+ * ```
656
+ */
657
+ export declare function streamUploadedFile(file: UploadedFileInterface): ReadableStream<Uint8Array>;
658
+
659
+ /**
660
+ * Best-effort unlink every still-`'staged'` file in a parsed
661
+ * {@link MultipartBody} — the fail-closed cleanup `createMultipart` runs when
662
+ * its downstream handler throws, mirroring `parseMultipartRequest`'s own
663
+ * cleanup pattern (a missing file is already gone; failures are swallowed).
664
+ *
665
+ * @param body - The parsed multipart body to clean up
666
+ * @returns A promise that resolves once every staged file has been attempted
667
+ *
668
+ * @example
669
+ * ```ts
670
+ * await unlinkStagedFiles(body)
671
+ * ```
672
+ */
673
+ export declare function unlinkStagedFiles(body: MultipartBody): Promise<void>;
674
+
675
+ /**
676
+ * The full field set `createUploadedFile` needs to build an
677
+ * {@link UploadedFileInterface} record.
678
+ *
679
+ * @remarks
680
+ * - `field` — the multipart field name the file was submitted under.
681
+ * - `name` — the client-declared filename (metadata only).
682
+ * - `size` — the file's byte size.
683
+ * - `mime` — the sniffed MIME type.
684
+ * - `validated` — `true` when the sniffed type matches the declared `Content-Type`.
685
+ * - `status` — see {@link UploadStatus}.
686
+ * - `path` — the file's current on-disk path.
687
+ */
688
+ export declare interface UploadedFileInput {
689
+ readonly field: string;
690
+ readonly name: string;
691
+ readonly size: number;
692
+ readonly mime: string;
693
+ readonly validated: boolean;
694
+ readonly status: UploadStatus;
695
+ readonly path: string;
696
+ }
697
+
698
+ /**
699
+ * One uploaded file's post-parse record — the node-bound, richer sibling of
700
+ * the pure core's {@link MultipartFile} (identical fields, `status` narrowed
701
+ * to {@link UploadStatus}). Structurally assignable into {@link MultipartFile}
702
+ * so a `createMultipart`-built {@link MultipartBody} satisfies the shared
703
+ * core shape.
704
+ *
705
+ * @remarks
706
+ * - `field` — the multipart field name the file was submitted under.
707
+ * - `name` — the client-declared filename (METADATA ONLY — never used to
708
+ * build a filesystem path).
709
+ * - `size` — the file's byte size.
710
+ * - `mime` — the SNIFFED (magic-byte-detected) MIME type.
711
+ * - `validated` — `true` when the sniffed type matches the declared
712
+ * `Content-Type`.
713
+ * - `status` — see {@link UploadStatus}.
714
+ * - `path` — the file's current on-disk path.
715
+ */
716
+ export declare interface UploadedFileInterface extends Omit<MultipartFile, 'status'> {
717
+ readonly status: UploadStatus;
718
+ }
719
+
720
+ /**
721
+ * The lifecycle stage of one staged upload's temp file.
722
+ *
723
+ * @remarks
724
+ * `'staged'` — written to the configured temp directory under a random name,
725
+ * not yet moved. `'moved'` — relocated by `moveUploadedFile` to its final path.
726
+ */
727
+ export declare type UploadStatus = 'staged' | 'moved';
728
+
729
+ export { }