@znight/http 0.0.0-stage → 0.1.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Streaming `multipart/form-data` parser (RFC 7578, RFC 2046), shared by every adapter so that
3
+ * uploads behave the same on Node.js and Bun.
4
+ *
5
+ * The Web-standard `Request.formData()` is not used: it buffers the whole body before parsing, has
6
+ * no per-part limits, and its results differ between runtimes (default part type, `filename*`,
7
+ * escaped quotes, LF line endings, parts without name). This parser reads the body as it arrives
8
+ * and enforces the upload rules of the route (expected fields, file count, size and media type) on
9
+ * the fly, so an invalid upload is rejected as soon as the offending part shows up. Files are
10
+ * buffered in memory as standard `File` objects, within the limits.
11
+ *
12
+ * Status codes: malformed bodies, unexpected file fields and several files for a single-file field
13
+ * → 400; a file, a field, too many files or too many parts → 413; a refused media type → 415. Errors
14
+ * raised while the body is being read carry `connection: close` (the rest of the body is discarded).
15
+ */
16
+ import type { HttpExceptionOptions } from './exceptions.ts'
17
+ import type { QueryRecord } from './query.ts'
18
+
19
+ import { Buffer } from 'node:buffer'
20
+
21
+ import { BadRequestException, issue, PayloadTooLargeException, UnsupportedMediaTypeException } from './exceptions.ts'
22
+ import { matchesMediaType, mediaTypeEssence, OCTET_STREAM } from './mime.ts'
23
+ import { formatByteSize } from './size.ts'
24
+ import { emptyRecord } from './utils.ts'
25
+
26
+ /** Constraints of the files received by one upload parameter (`@UploadedFile` / `@UploadedFiles`). */
27
+ export interface FileRule {
28
+ /** Field name; `undefined`: the files of every field. */
29
+ readonly field: string | undefined
30
+ /** `@UploadedFiles` (an array) rather than `@UploadedFile` (a single file). */
31
+ readonly multiple: boolean
32
+ /** Maximum size of each file, in bytes. */
33
+ readonly maxSize: number
34
+ /** Maximum number of files (1 for a single file). */
35
+ readonly maxFiles: number
36
+ /** Accepted media types (`image/png`, `image/*`); `undefined`: any. */
37
+ readonly mimeTypes: readonly string[] | undefined
38
+ }
39
+
40
+ /** How a multipart body is read. */
41
+ export interface MultipartPolicy {
42
+ /**
43
+ * Upload rules of the route (files are only accepted in their fields: a file in another field is
44
+ * rejected with 400, unless a rule has no field). Without rules, every file is accepted without
45
+ * limit (but the body limit).
46
+ */
47
+ readonly rules: readonly FileRule[]
48
+ /** Maximum number of parts (text fields and files). */
49
+ readonly maxParts: number
50
+ /** Maximum size of a text field, in bytes. */
51
+ readonly maxFieldSize: number
52
+ }
53
+
54
+ /** A parsed multipart body. */
55
+ export interface MultipartResult {
56
+ /** Text fields (repeated names → arrays), without prototype. */
57
+ readonly fields: QueryRecord
58
+ /** Files by field name, without prototype. */
59
+ readonly files: Record<string, File[]>
60
+ /** Every file, in body order. */
61
+ readonly list: readonly File[]
62
+ /** Every entry, in body order. */
63
+ readonly entries: readonly (readonly [string, string | File])[]
64
+ }
65
+
66
+ /** Default maximum size of the headers of one part. */
67
+ const MAX_HEADER_SIZE = 16 * 1024
68
+ /** Maximum transport padding (spaces) after a boundary delimiter. */
69
+ const MAX_PADDING = 256
70
+ /** Errors raised while the body is still being read ask the client to close the connection. */
71
+ const CLOSE: HttpExceptionOptions = Object.freeze({ headers: Object.freeze({ connection: 'close' }) })
72
+
73
+ const CR = 13
74
+ const LF = 10
75
+ const DASH = 45
76
+ const SPACE = 32
77
+ const TAB = 9
78
+ const HEADER_END = Buffer.from('\r\n\r\n')
79
+ const EMPTY: Buffer = Buffer.alloc(0)
80
+ const DECODER = new TextDecoder()
81
+
82
+ const PRINTABLE = /^[\u0020-\u007e]*$/
83
+
84
+ /**
85
+ * A received file. Its `type` is the media type declared by the client, lower-cased (empty when it
86
+ * contains characters outside printable ASCII), identical on every runtime (Bun's `File` adds
87
+ * `;charset=utf-8` to some text types).
88
+ */
89
+ function createFile(chunks: Uint8Array[], name: string, type: string): File {
90
+ const file = new File(chunks, name, { type })
91
+ const normalized = PRINTABLE.test(type) ? type.toLowerCase() : ''
92
+ if (file.type !== normalized) Object.defineProperty(file, 'type', { value: normalized, configurable: true })
93
+ return file
94
+ }
95
+
96
+ /** Description of a part, from its headers. */
97
+ export interface PartInfo {
98
+ readonly name: string
99
+ /** `undefined` for text fields. */
100
+ readonly filename: string | undefined
101
+ readonly contentType: string | undefined
102
+ }
103
+
104
+ /** Receives the content of one part. */
105
+ export interface PartSink {
106
+ data(chunk: Uint8Array): void
107
+ end(): void
108
+ }
109
+
110
+ /** Receives the parts found by {@link MultipartParser}. */
111
+ export interface PartHandler {
112
+ start(part: PartInfo): PartSink
113
+ }
114
+
115
+ type State = 'preamble' | 'delimiter' | 'headers' | 'body' | 'done'
116
+
117
+ /**
118
+ * Incremental parser: feed it with {@link write} as chunks arrive, then call {@link end}. Errors
119
+ * (malformed body, limits enforced by the handler) are thrown synchronously from `write`/`end`.
120
+ */
121
+ export class MultipartParser {
122
+ readonly #delimiter: Buffer
123
+ readonly #handler: PartHandler
124
+ readonly #maxHeaderSize: number
125
+ /** The body is conceptually prefixed with CRLF so that every delimiter is `CRLF--boundary`. */
126
+ #buffer: Buffer = Buffer.from('\r\n')
127
+ #state: State = 'preamble'
128
+ #sink: PartSink | undefined
129
+ /** Bytes of the header block already searched for its end (avoids rescanning on every chunk). */
130
+ #scanned = 0
131
+
132
+ constructor(boundary: string, handler: PartHandler, maxHeaderSize: number = MAX_HEADER_SIZE) {
133
+ this.#delimiter = Buffer.from(`\r\n--${boundary}`, 'latin1')
134
+ this.#handler = handler
135
+ this.#maxHeaderSize = maxHeaderSize
136
+ }
137
+
138
+ /** `true` once the closing delimiter was read (what follows is the epilogue, ignored). */
139
+ get done(): boolean {
140
+ return this.#state === 'done'
141
+ }
142
+
143
+ write(chunk: Uint8Array): void {
144
+ if (this.#state === 'done' || chunk.byteLength === 0) return
145
+ const view = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
146
+ this.#buffer = this.#buffer.length === 0 ? view : Buffer.concat([this.#buffer, view])
147
+ this.#process()
148
+ }
149
+
150
+ end(): void {
151
+ if (this.#state !== 'done') throw malformed('unexpected end of the body (missing closing boundary)')
152
+ }
153
+
154
+ #process(): void {
155
+ for (;;) {
156
+ switch (this.#state) {
157
+ case 'preamble': {
158
+ const index = this.#buffer.indexOf(this.#delimiter)
159
+ if (index === -1) {
160
+ this.#keepTail()
161
+ return
162
+ }
163
+ this.#buffer = this.#buffer.subarray(index + this.#delimiter.length)
164
+ this.#state = 'delimiter'
165
+ break
166
+ }
167
+ case 'delimiter':
168
+ if (!this.#afterDelimiter()) return
169
+ break
170
+ case 'headers':
171
+ if (!this.#headers()) return
172
+ break
173
+ case 'body':
174
+ if (!this.#body()) return
175
+ break
176
+ default:
177
+ return
178
+ }
179
+ }
180
+ }
181
+
182
+ /** After a delimiter: `--` (closing delimiter) or optional padding then CRLF (next part). */
183
+ #afterDelimiter(): boolean {
184
+ const buffer = this.#buffer
185
+ if (buffer.length < 2) return false
186
+ if (buffer[0] === DASH && buffer[1] === DASH) {
187
+ this.#state = 'done'
188
+ this.#buffer = EMPTY
189
+ return false
190
+ }
191
+ let index = 0
192
+ while (index < buffer.length && (buffer[index] === SPACE || buffer[index] === TAB)) index++
193
+ if (index > MAX_PADDING) throw malformed('invalid boundary delimiter')
194
+ if (index + 1 >= buffer.length) return false
195
+ if (buffer[index] !== CR || buffer[index + 1] !== LF) throw malformed('invalid boundary delimiter')
196
+ this.#buffer = buffer.subarray(index + 2)
197
+ this.#state = 'headers'
198
+ return true
199
+ }
200
+
201
+ #headers(): boolean {
202
+ const buffer = this.#buffer
203
+ if (buffer.length < 2) return false
204
+ let end: number
205
+ let next: number
206
+ if (buffer[0] === CR && buffer[1] === LF) {
207
+ end = 0
208
+ next = 2
209
+ } else {
210
+ end = buffer.indexOf(HEADER_END, Math.max(0, this.#scanned - (HEADER_END.length - 1)))
211
+ if (end === -1) {
212
+ if (buffer.length > this.#maxHeaderSize) throw malformed('the headers of a part are too large')
213
+ this.#scanned = buffer.length
214
+ return false
215
+ }
216
+ if (end > this.#maxHeaderSize) throw malformed('the headers of a part are too large')
217
+ next = end + 4
218
+ }
219
+ const part = parsePartHeaders(buffer.toString('utf8', 0, end))
220
+ this.#scanned = 0
221
+ this.#buffer = buffer.subarray(next)
222
+ this.#sink = this.#handler.start(part)
223
+ this.#state = 'body'
224
+ return true
225
+ }
226
+
227
+ #body(): boolean {
228
+ const buffer = this.#buffer
229
+ const sink = this.#sink as PartSink
230
+ const index = buffer.indexOf(this.#delimiter)
231
+ if (index === -1) {
232
+ // keep what may be the beginning of a delimiter
233
+ const safe = buffer.length - (this.#delimiter.length - 1)
234
+ if (safe > 0) {
235
+ sink.data(buffer.subarray(0, safe))
236
+ this.#buffer = buffer.subarray(safe)
237
+ }
238
+ return false
239
+ }
240
+ if (index > 0) sink.data(buffer.subarray(0, index))
241
+ this.#sink = undefined
242
+ sink.end()
243
+ this.#buffer = buffer.subarray(index + this.#delimiter.length)
244
+ this.#state = 'delimiter'
245
+ return true
246
+ }
247
+
248
+ #keepTail(): void {
249
+ const keep = this.#delimiter.length - 1
250
+ if (this.#buffer.length > keep) this.#buffer = this.#buffer.subarray(this.#buffer.length - keep)
251
+ }
252
+ }
253
+
254
+ // -------------------------------------------------------------------------------------------------
255
+ // Headers
256
+ // -------------------------------------------------------------------------------------------------
257
+
258
+ function parsePartHeaders(block: string): PartInfo {
259
+ let disposition: string | undefined
260
+ let contentType: string | undefined
261
+ let current: 'content-disposition' | 'content-type' | undefined
262
+ if (block !== '') {
263
+ for (const line of block.split('\r\n')) {
264
+ if (line.startsWith(' ') || line.startsWith('\t')) {
265
+ // obsolete line folding: continuation of the previous header
266
+ if (current === 'content-disposition') disposition = `${disposition ?? ''} ${line.trim()}`
267
+ else if (current === 'content-type') contentType = `${contentType ?? ''} ${line.trim()}`
268
+ continue
269
+ }
270
+ const colon = line.indexOf(':')
271
+ if (colon <= 0) throw malformed('invalid part header')
272
+ const name = line.slice(0, colon).trim().toLowerCase()
273
+ const value = line.slice(colon + 1).trim()
274
+ current = undefined
275
+ if (name === 'content-disposition' && disposition === undefined) {
276
+ disposition = value
277
+ current = name
278
+ } else if (name === 'content-type' && contentType === undefined) {
279
+ contentType = value
280
+ current = name
281
+ }
282
+ }
283
+ }
284
+ if (disposition === undefined) throw malformed('a part has no Content-Disposition header')
285
+ const { type, params } = parseParameters(disposition)
286
+ if (type !== 'form-data') throw malformed('a part is not "form-data"')
287
+ const name = params.get('name')
288
+ if (name === undefined) throw malformed('a part has no name')
289
+ const extended = params.get('filename*')
290
+ const plain = params.get('filename')
291
+ const filename = (extended === undefined ? undefined : decodeExtendedValue(extended)) ?? plain
292
+ return {
293
+ name: decodeFormName(name),
294
+ filename: filename === undefined ? undefined : decodeFormName(filename),
295
+ contentType: contentType === '' ? undefined : contentType,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Parses `value; key=token; key="quoted \" string"` (Content-Disposition, Content-Type). Keys are
301
+ * lower-cased; the first occurrence of a key wins.
302
+ */
303
+ export function parseParameters(value: string): { readonly type: string; readonly params: Map<string, string> } {
304
+ const params = new Map<string, string>()
305
+ let index = value.indexOf(';')
306
+ const type = (index === -1 ? value : value.slice(0, index)).trim().toLowerCase()
307
+ while (index !== -1 && index < value.length) {
308
+ index++ // skip ';'
309
+ while (index < value.length && isSpace(value.charCodeAt(index))) index++
310
+ if (index >= value.length) break
311
+ if (value[index] === ';') continue // empty parameter
312
+ const equals = value.indexOf('=', index)
313
+ if (equals === -1) throw malformed(`invalid parameter in ${JSON.stringify(truncate(value))}`)
314
+ const key = value.slice(index, equals).trim().toLowerCase()
315
+ index = equals + 1
316
+ while (index < value.length && isSpace(value.charCodeAt(index))) index++
317
+ let parameter: string
318
+ if (value[index] === '"') {
319
+ parameter = ''
320
+ index++
321
+ for (;;) {
322
+ if (index >= value.length) throw malformed('unterminated quoted string in a part header')
323
+ const char = value[index] as string
324
+ if (char === '"') {
325
+ index++
326
+ break
327
+ }
328
+ if (char === '\\' && (value[index + 1] === '"' || value[index + 1] === '\\')) {
329
+ parameter += value[index + 1]
330
+ index += 2
331
+ continue
332
+ }
333
+ parameter += char
334
+ index++
335
+ }
336
+ while (index < value.length && isSpace(value.charCodeAt(index))) index++
337
+ if (index < value.length && value[index] !== ';') throw malformed('invalid quoted string in a part header')
338
+ } else {
339
+ const next = value.indexOf(';', index)
340
+ parameter = (next === -1 ? value.slice(index) : value.slice(index, next)).trim()
341
+ index = next === -1 ? value.length : next
342
+ }
343
+ if (key !== '' && !params.has(key)) params.set(key, parameter)
344
+ if (index >= value.length) break
345
+ }
346
+ return { type, params }
347
+ }
348
+
349
+ function isSpace(code: number): boolean {
350
+ return code === SPACE || code === TAB
351
+ }
352
+
353
+ /** Names and filenames: browsers escape `"`, CR and LF as `%22`, `%0D` and `%0A` (HTML standard). */
354
+ function decodeFormName(value: string): string {
355
+ return value.includes('%')
356
+ ? value.replace(/%(22|0D|0A)/gi, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
357
+ : value
358
+ }
359
+
360
+ /** RFC 5987 `charset'language'percent-encoded` (`filename*`); `undefined` when it cannot be decoded. */
361
+ function decodeExtendedValue(value: string): string | undefined {
362
+ const match = /^([\w!#$%&+^`{}~-]+)'[\w-]*'(.*)$/.exec(value)
363
+ if (match === null) return undefined
364
+ const charset = (match[1] as string).toLowerCase()
365
+ const encoded = match[2] as string
366
+ try {
367
+ if (charset === 'utf-8') return decodeURIComponent(encoded)
368
+ if (charset === 'iso-8859-1') {
369
+ return encoded.replace(/%([0-9a-f]{2})/gi, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
370
+ }
371
+ } catch {
372
+ return undefined
373
+ }
374
+ return undefined
375
+ }
376
+
377
+ /** The `boundary` parameter of a `multipart/form-data` content type (400 when missing or invalid). */
378
+ export function multipartBoundary(contentType: string): string {
379
+ let boundary: string | undefined
380
+ try {
381
+ boundary = parseParameters(contentType).params.get('boundary')
382
+ } catch {
383
+ boundary = undefined
384
+ }
385
+ if (boundary === undefined || boundary === '') throw malformed('missing boundary in the content type')
386
+ // RFC 2046: 1 to 70 printable ASCII characters
387
+ if (boundary.length > 70 || [...boundary].some(char => char < ' ' || char > '~')) {
388
+ throw malformed('invalid boundary in the content type')
389
+ }
390
+ return boundary
391
+ }
392
+
393
+ // -------------------------------------------------------------------------------------------------
394
+ // Collecting fields and files
395
+ // -------------------------------------------------------------------------------------------------
396
+
397
+ /** Applies a {@link MultipartPolicy} to the parts and collects fields and files. */
398
+ class Collector implements PartHandler {
399
+ readonly fields: QueryRecord = emptyRecord()
400
+ readonly files: Record<string, File[]> = emptyRecord()
401
+ readonly list: File[] = []
402
+ readonly entries: (readonly [string, string | File])[] = []
403
+ readonly #policy: MultipartPolicy
404
+ readonly #named: ReadonlyMap<string, FileRule>
405
+ readonly #any: FileRule | undefined
406
+ readonly #counts = new Map<FileRule, number>()
407
+ #parts = 0
408
+
409
+ constructor(policy: MultipartPolicy) {
410
+ this.#policy = policy
411
+ const named = new Map<string, FileRule>()
412
+ let any: FileRule | undefined
413
+ for (const rule of policy.rules) {
414
+ if (rule.field === undefined) any = rule
415
+ else named.set(rule.field, rule)
416
+ }
417
+ this.#named = named
418
+ this.#any = any
419
+ }
420
+
421
+ start(part: PartInfo): PartSink {
422
+ this.#parts++
423
+ if (this.#parts > this.#policy.maxParts) {
424
+ throw new PayloadTooLargeException(
425
+ `Too many parts in the multipart body (at most ${this.#policy.maxParts})`,
426
+ { maxParts: this.#policy.maxParts },
427
+ CLOSE,
428
+ )
429
+ }
430
+ return part.filename === undefined ? this.#field(part.name) : this.#file(part, part.filename)
431
+ }
432
+
433
+ #field(name: string): PartSink {
434
+ const limit = this.#policy.maxFieldSize
435
+ const chunks: Uint8Array[] = []
436
+ let size = 0
437
+ return {
438
+ data: chunk => {
439
+ size += chunk.byteLength
440
+ if (size > limit) {
441
+ throw new PayloadTooLargeException(
442
+ `Field ${JSON.stringify(truncate(name))} exceeds the maximum size of ${formatByteSize(limit)}`,
443
+ { field: name, maxSize: limit },
444
+ CLOSE,
445
+ )
446
+ }
447
+ chunks.push(chunk)
448
+ },
449
+ end: () => {
450
+ const value = DECODER.decode(chunks.length === 1 ? (chunks[0] as Uint8Array) : Buffer.concat(chunks))
451
+ const existing = this.fields[name]
452
+ if (existing === undefined) this.fields[name] = value
453
+ else if (typeof existing === 'string') this.fields[name] = [existing, value]
454
+ else existing.push(value)
455
+ this.entries.push([name, value])
456
+ },
457
+ }
458
+ }
459
+
460
+ #file(part: PartInfo, filename: string): PartSink {
461
+ const type = part.contentType === undefined ? OCTET_STREAM : part.contentType
462
+ const name = part.name
463
+ let rules: readonly FileRule[] = []
464
+ let admitted = false
465
+ // an empty <input type="file"> is sent as an empty part without filename: it is not a file
466
+ const admit = (): void => {
467
+ admitted = true
468
+ rules = this.#admit(name, type)
469
+ }
470
+ if (filename !== '') admit()
471
+ const chunks: Uint8Array[] = []
472
+ let size = 0
473
+ return {
474
+ data: chunk => {
475
+ if (!admitted) admit()
476
+ size += chunk.byteLength
477
+ for (const rule of rules) {
478
+ if (size > rule.maxSize) {
479
+ throw new PayloadTooLargeException(
480
+ `File in field ${JSON.stringify(truncate(name))} exceeds the maximum size of ${formatByteSize(rule.maxSize)}`,
481
+ { field: name, maxSize: rule.maxSize },
482
+ CLOSE,
483
+ )
484
+ }
485
+ }
486
+ chunks.push(chunk)
487
+ },
488
+ end: () => {
489
+ if (!admitted) return
490
+ const file = createFile(chunks, filename, type)
491
+ const list = this.files[name]
492
+ if (list === undefined) this.files[name] = [file]
493
+ else list.push(file)
494
+ this.list.push(file)
495
+ this.entries.push([name, file])
496
+ },
497
+ }
498
+ }
499
+
500
+ /** Rules applying to a file of `field` (400 for an unexpected field), with count and type checks. */
501
+ #admit(field: string, type: string): readonly FileRule[] {
502
+ if (this.#policy.rules.length === 0) return []
503
+ const rules: FileRule[] = []
504
+ const named = this.#named.get(field)
505
+ if (named !== undefined) rules.push(named)
506
+ if (this.#any !== undefined) rules.push(this.#any)
507
+ if (rules.length === 0) {
508
+ const expected = [...this.#named.keys()]
509
+ throw new BadRequestException(
510
+ `Unexpected file field ${JSON.stringify(truncate(field))}`,
511
+ issue(field, `Unexpected file field (expected ${expected.join(', ')})`),
512
+ CLOSE,
513
+ )
514
+ }
515
+ for (const rule of rules) {
516
+ const count = (this.#counts.get(rule) ?? 0) + 1
517
+ this.#counts.set(rule, count)
518
+ if (count > rule.maxFiles) {
519
+ const where = rule.field === undefined ? '' : ` in field ${JSON.stringify(truncate(rule.field))}`
520
+ if (!rule.multiple) {
521
+ throw new BadRequestException(`Expected a single file${where}`, issue(field, 'Expected a single file'), CLOSE)
522
+ }
523
+ throw new PayloadTooLargeException(
524
+ `Too many files${where} (at most ${rule.maxFiles})`,
525
+ { field, maxFiles: rule.maxFiles },
526
+ CLOSE,
527
+ )
528
+ }
529
+ if (rule.mimeTypes !== undefined && !matchesMediaType(type, rule.mimeTypes)) {
530
+ throw new UnsupportedMediaTypeException(
531
+ `Unsupported file type ${JSON.stringify(truncate(mediaTypeEssence(type)))} in field ${JSON.stringify(truncate(field))} (accepted: ${rule.mimeTypes.join(', ')})`,
532
+ { field, type: mediaTypeEssence(type), accepted: rule.mimeTypes },
533
+ CLOSE,
534
+ )
535
+ }
536
+ }
537
+ return rules
538
+ }
539
+
540
+ result(): MultipartResult {
541
+ return { fields: this.fields, files: this.files, list: this.list, entries: this.entries }
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Reads a multipart body from `stream` with `policy`. The stream is cancelled on error (the rest of
547
+ * the body is discarded).
548
+ *
549
+ * @throws BadRequestException (400) for a malformed body, too many parts / files or an unexpected
550
+ * file field; PayloadTooLargeException (413) for a file or field above its limit;
551
+ * UnsupportedMediaTypeException (415) for a file of a refused type.
552
+ */
553
+ export async function readMultipart(
554
+ stream: ReadableStream<Uint8Array>,
555
+ contentType: string,
556
+ policy: MultipartPolicy,
557
+ ): Promise<MultipartResult> {
558
+ const reader = stream.getReader()
559
+ try {
560
+ const collector = new Collector(policy)
561
+ const parser = new MultipartParser(multipartBoundary(contentType), collector)
562
+ for (;;) {
563
+ const { done, value } = await reader.read()
564
+ if (done) break
565
+ parser.write(value)
566
+ }
567
+ parser.end()
568
+ return collector.result()
569
+ } catch (error) {
570
+ await reader.cancel(error).catch(() => undefined)
571
+ throw error
572
+ }
573
+ }
574
+
575
+ /** Parses a buffered multipart body (see {@link readMultipart}). */
576
+ export function parseMultipart(bytes: Uint8Array, contentType: string, policy: MultipartPolicy): MultipartResult {
577
+ const collector = new Collector(policy)
578
+ const parser = new MultipartParser(multipartBoundary(contentType), collector)
579
+ parser.write(bytes)
580
+ parser.end()
581
+ return collector.result()
582
+ }
583
+
584
+ /** Converts a parse result to a `FormData` (entries in body order). */
585
+ export function toFormData(result: MultipartResult): FormData {
586
+ const form = new FormData()
587
+ for (const [name, value] of result.entries) form.append(name, value)
588
+ return form
589
+ }
590
+
591
+ function malformed(reason: string): BadRequestException {
592
+ return new BadRequestException(`Invalid multipart body: ${reason}`, undefined, CLOSE)
593
+ }
594
+
595
+ function truncate(value: string): string {
596
+ return value.length > 100 ? `${value.slice(0, 100)}…` : value
597
+ }
@@ -0,0 +1,31 @@
1
+ /** Errors of invalid `HttpPlugin` options (internal). */
2
+ import { ErrorCode, ZnightError } from '@znight/core'
3
+
4
+ /** `ZNIGHT005: HttpPlugin: option "<name>" must be <expected> (received <value>)`. */
5
+ export function invalidOption(
6
+ name: string,
7
+ expected: string,
8
+ value: unknown,
9
+ cause?: unknown,
10
+ hints?: readonly string[],
11
+ ): ZnightError {
12
+ return new ZnightError(
13
+ ErrorCode.InvalidDecorator,
14
+ `HttpPlugin: option "${name}" must be ${expected} (received ${describeOption(value)})`,
15
+ {
16
+ ...(cause === undefined ? {} : { cause }),
17
+ ...(hints === undefined ? {} : { hints }),
18
+ },
19
+ )
20
+ }
21
+
22
+ /** A short description of an option value for messages. */
23
+ export function describeOption(value: unknown): string {
24
+ if (typeof value === 'function') return 'a function'
25
+ if (value instanceof RegExp) return String(value)
26
+ try {
27
+ return JSON.stringify(value) ?? String(value)
28
+ } catch {
29
+ return typeof value
30
+ }
31
+ }