@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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/src/core/index.cjs +1463 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1339 -0
- package/dist/src/core/index.d.ts +1339 -0
- package/dist/src/core/index.js +1399 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +1217 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +729 -0
- package/dist/src/server/index.d.ts +729 -0
- package/dist/src/server/index.js +1180 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +108 -0
|
@@ -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 { }
|