@znight/http 0.0.0-stage → 0.1.0

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,508 @@
1
+ /** Helpers shared by the adapters (internal). */
2
+ import type { Readable } from 'node:stream'
3
+ import type {
4
+ HttpServerAddress,
5
+ InjectRequest,
6
+ InjectResponse,
7
+ InjectStreamRequest,
8
+ InjectStreamResponse,
9
+ } from '../adapter.ts'
10
+
11
+ import { encodeCookieValue, parseSetCookies } from '../cookies.ts'
12
+ import { BadRequestException, PayloadTooLargeException } from '../exceptions.ts'
13
+ import { failedStream } from '../stream.ts'
14
+
15
+ /** An inject request normalised for the underlying tools. */
16
+ export interface NormalizedInject {
17
+ readonly method: string
18
+ readonly url: string
19
+ readonly headers: Record<string, string>
20
+ readonly payload: string | Uint8Array | undefined
21
+ }
22
+
23
+ /**
24
+ * Applies the defaults of {@link InjectRequest}: query merged in the URL, cookies appended to the
25
+ * `cookie` header (encoded values joined with `; `), body encoding and content type (`FormData` →
26
+ * `multipart/form-data`, `Blob` → its type).
27
+ */
28
+ export async function normalizeInject(request: InjectRequest): Promise<NormalizedInject> {
29
+ if (request === null || typeof request !== 'object') throw new TypeError('inject() expects a request object')
30
+ if (typeof request.url !== 'string' || !request.url.startsWith('/')) {
31
+ throw new TypeError(`inject() expects a url starting with "/" (received ${JSON.stringify(request.url)})`)
32
+ }
33
+ const method = (request.method ?? 'GET').toUpperCase()
34
+ let url = request.url
35
+ if (request.query !== undefined) {
36
+ const search = new URLSearchParams()
37
+ for (const [key, value] of Object.entries(request.query)) {
38
+ if (typeof value === 'string') search.append(key, value)
39
+ else for (const item of value) search.append(key, item)
40
+ }
41
+ const query = search.toString()
42
+ if (query !== '') url += `${url.includes('?') ? '&' : '?'}${query}`
43
+ }
44
+ const headers: Record<string, string> = {}
45
+ for (const [name, value] of Object.entries(request.headers ?? {})) headers[name.toLowerCase()] = value
46
+ if (request.signedCookies !== undefined) {
47
+ throw new TypeError('inject(): signedCookies are signed by HttpPlugin.inject() (adapters only accept cookies)')
48
+ }
49
+ const cookie = cookieHeader(request.cookies)
50
+ if (cookie !== '') headers['cookie'] = headers['cookie'] === undefined ? cookie : `${headers['cookie']}; ${cookie}`
51
+ const body = request.body
52
+ let payload: string | Uint8Array | undefined
53
+ if (body === undefined) {
54
+ payload = undefined
55
+ } else if (typeof body === 'string') {
56
+ payload = body
57
+ headers['content-type'] ??= 'text/plain; charset=utf-8'
58
+ } else if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
59
+ payload = body instanceof Uint8Array ? body : new Uint8Array(body)
60
+ headers['content-type'] ??= 'application/octet-stream'
61
+ } else if (body instanceof FormData) {
62
+ // the Fetch API encodes forms (the boundary of the generated content type must be used)
63
+ const encoded = new Response(body)
64
+ // read the generated content type (with its boundary) before consuming the body (Bun sets it lazily)
65
+ const type = encoded.headers.get('content-type')
66
+ payload = new Uint8Array(await encoded.arrayBuffer())
67
+ headers['content-type'] = type ?? encoded.headers.get('content-type') ?? 'multipart/form-data'
68
+ } else if (body instanceof Blob) {
69
+ payload = new Uint8Array(await body.arrayBuffer())
70
+ headers['content-type'] ??= body.type === '' ? 'application/octet-stream' : body.type
71
+ } else {
72
+ payload = JSON.stringify(body)
73
+ headers['content-type'] ??= 'application/json'
74
+ }
75
+ return { method, url, headers, payload }
76
+ }
77
+
78
+ /** The `cookie` header of inject cookies: `name=encoded` pairs joined with `; `. */
79
+ function cookieHeader(cookies: Readonly<Record<string, string>> | undefined): string {
80
+ if (cookies === undefined) return ''
81
+ if (cookies === null || typeof cookies !== 'object') throw new TypeError('inject(): cookies must be an object')
82
+ const pairs: string[] = []
83
+ for (const name of Object.keys(cookies)) {
84
+ if (!isRequestCookieName(name)) {
85
+ throw new TypeError(
86
+ `inject(): invalid cookie name ${JSON.stringify(name)} (empty, or with ";", "=", spaces or controls)`,
87
+ )
88
+ }
89
+ const value = cookies[name]
90
+ if (typeof value !== 'string') throw new TypeError(`inject(): the value of the cookie "${name}" must be a string`)
91
+ pairs.push(`${name}=${encodeCookieValue(value)}`)
92
+ }
93
+ return pairs.join('; ')
94
+ }
95
+
96
+ /** A name a `cookie` header can carry: no `;`, `=`, whitespace or controls, Latin-1. */
97
+ function isRequestCookieName(name: string): boolean {
98
+ if (name === '') return false
99
+ for (let index = 0; index < name.length; index++) {
100
+ const code = name.charCodeAt(index)
101
+ if (code <= 0x20 || code === 0x3b || code === 0x3d || code === 0x7f || code > 0xff) return false
102
+ }
103
+ return true
104
+ }
105
+
106
+ const DECODER = new TextDecoder()
107
+
108
+ /**
109
+ * Normalises response headers: lower-case names, repeated values joined with `, `, except
110
+ * `set-cookie` (one entry per cookie: an `Expires` attribute contains `, `).
111
+ */
112
+ export function injectHeaders(
113
+ headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
114
+ ): Record<string, string | string[]> {
115
+ const normalized: Record<string, string | string[]> = {}
116
+ for (const [name, value] of Object.entries(headers)) {
117
+ if (value === undefined) continue
118
+ const key = name.toLowerCase()
119
+ if (key === 'set-cookie') normalized[key] = typeof value === 'object' ? [...value] : [String(value)]
120
+ else normalized[key] = typeof value === 'object' ? value.join(', ') : String(value)
121
+ }
122
+ return normalized
123
+ }
124
+
125
+ /** Builds an {@link InjectResponse} (headers normalised by {@link injectHeaders}, cookies parsed). */
126
+ export function injectResponse(
127
+ status: number,
128
+ headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
129
+ bytes: Uint8Array,
130
+ ): InjectResponse {
131
+ const normalized = injectHeaders(headers)
132
+ const body = DECODER.decode(bytes)
133
+ return {
134
+ status,
135
+ headers: normalized,
136
+ body,
137
+ bytes,
138
+ cookies: parseSetCookies(normalized['set-cookie']),
139
+ json: <T = unknown>(): T => JSON.parse(body) as T,
140
+ }
141
+ }
142
+
143
+ /** Whether a `content-type` is `text/event-stream`. */
144
+ export function isEventStreamType(contentType: string | readonly string[] | undefined): boolean {
145
+ if (typeof contentType !== 'string') return false
146
+ const semicolon = contentType.indexOf(';')
147
+ return (semicolon === -1 ? contentType : contentType.slice(0, semicolon)).trim().toLowerCase() === 'text/event-stream'
148
+ }
149
+
150
+ /**
151
+ * `inject()` on top of `injectStream()`: reads the body to its end, except for an event stream
152
+ * (which would never end): its body is cancelled (the server sees a disconnect) and the call
153
+ * rejects with a hint.
154
+ */
155
+ export async function bufferInjectStream(
156
+ injectStream: (request: InjectStreamRequest) => Promise<InjectStreamResponse>,
157
+ request: InjectRequest,
158
+ ): Promise<InjectResponse> {
159
+ const response = await injectStream(request)
160
+ const method = (request.method ?? 'GET').toUpperCase()
161
+ if (method !== 'HEAD' && isEventStreamType(response.headers['content-type'])) {
162
+ await response.body.cancel().catch(() => undefined)
163
+ throw new TypeError(
164
+ `inject(): ${method} ${request.url} answered an event stream (text/event-stream): use injectSse() or injectStream()`,
165
+ )
166
+ }
167
+ const reader = response.body.getReader()
168
+ const chunks: Uint8Array[] = []
169
+ let size = 0
170
+ for (;;) {
171
+ const { done, value } = await reader.read()
172
+ if (done) break
173
+ size += value.byteLength
174
+ chunks.push(value)
175
+ }
176
+ const bytes = concat(chunks, size)
177
+ const body = DECODER.decode(bytes)
178
+ return {
179
+ status: response.status,
180
+ headers: response.headers,
181
+ body,
182
+ bytes,
183
+ cookies: response.cookies,
184
+ json: <T = unknown>(): T => JSON.parse(body) as T,
185
+ }
186
+ }
187
+
188
+ /** Builds an {@link InjectStreamResponse}. */
189
+ export function injectStreamResponse(
190
+ status: number,
191
+ headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
192
+ body: ReadableStream<Uint8Array>,
193
+ ): InjectStreamResponse {
194
+ const normalized = injectHeaders(headers)
195
+ return { status, headers: normalized, cookies: parseSetCookies(normalized['set-cookie']), body }
196
+ }
197
+
198
+ /** Builds an {@link HttpServerAddress} from a bound address. */
199
+ export function serverAddress(address: string, port: number, family?: string | number): HttpServerAddress {
200
+ const ipv6 = family === 'IPv6' || family === 6 || address.includes(':')
201
+ let client = address
202
+ if (address === '0.0.0.0') client = '127.0.0.1'
203
+ else if (address === '::') client = '::1'
204
+ const url = `http://${client.includes(':') ? `[${client}]` : client}:${port}`
205
+ return Object.freeze({
206
+ host: address,
207
+ port,
208
+ family: ipv6 ? 'IPv6' : 'IPv4',
209
+ url,
210
+ toString: () => url,
211
+ })
212
+ }
213
+
214
+ /** The 413 error of a body above `limit` bytes. */
215
+ export function payloadTooLarge(limit: number): PayloadTooLargeException {
216
+ return new PayloadTooLargeException(`Request body exceeds the limit of ${limit} bytes`, undefined, {
217
+ headers: { connection: 'close' },
218
+ })
219
+ }
220
+
221
+ /** Rejects early when `content-length` announces more than `limit` bytes. */
222
+ export function exceedsDeclaredLength(contentLength: string | undefined, limit: number): boolean {
223
+ if (contentLength === undefined) return false
224
+ const length = Number(contentLength)
225
+ return Number.isFinite(length) && length > limit
226
+ }
227
+
228
+ /** Bytes read (and discarded) from an abandoned Web request body before it is cancelled. */
229
+ const MAX_DISCARD_BYTES = 64 * 1024 * 1024
230
+
231
+ /**
232
+ * The body of a Web request as a pull-based stream enforcing `limit` (see
233
+ * `AdapterRequest.streamBody`). `body` is only read when needed (a Node.js-backed request body
234
+ * starts buffering once accessed). On overflow or cancellation the rest of the body is read and
235
+ * discarded in the background (up to 64 MiB, then cancelled), so the server can write the response
236
+ * and keep the connection usable instead of resetting it.
237
+ */
238
+ export function webBodyStream(
239
+ body: () => ReadableStream<Uint8Array> | null,
240
+ limit: number,
241
+ contentLength: string | undefined,
242
+ ): ReadableStream<Uint8Array> {
243
+ if (exceedsDeclaredLength(contentLength, limit)) return failedStream(payloadTooLarge(limit))
244
+ const source = body()
245
+ if (source === null) return new ReadableStream<Uint8Array>({ start: controller => controller.close() })
246
+ const reader = source.getReader()
247
+ let size = 0
248
+ let abandoned = false
249
+ const abandon = (): void => {
250
+ if (abandoned) return
251
+ abandoned = true
252
+ discardReader(reader)
253
+ }
254
+ return new ReadableStream<Uint8Array>(
255
+ {
256
+ async pull(controller) {
257
+ let result: Awaited<ReturnType<typeof reader.read>>
258
+ try {
259
+ result = await reader.read()
260
+ } catch (error) {
261
+ abandoned = true
262
+ throw new BadRequestException('Failed to read the request body', undefined, { cause: error })
263
+ }
264
+ if (result.done) {
265
+ abandoned = true
266
+ controller.close()
267
+ return
268
+ }
269
+ size += result.value.byteLength
270
+ if (size > limit) {
271
+ abandon()
272
+ throw payloadTooLarge(limit)
273
+ }
274
+ controller.enqueue(result.value)
275
+ },
276
+ cancel: abandon,
277
+ },
278
+ { highWaterMark: 0 },
279
+ )
280
+ }
281
+
282
+ /** The part of a stream reader used to discard a body. */
283
+ interface BodyReader {
284
+ read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>
285
+ cancel(): Promise<void>
286
+ }
287
+
288
+ /** Reads and drops the rest of a request body in the background, cancelling it past 64 MiB. */
289
+ function discardReader(reader: BodyReader): void {
290
+ let discarded = 0
291
+ const next = (): void => {
292
+ reader.read().then(
293
+ result => {
294
+ if (result.done) return
295
+ discarded += result.value?.byteLength ?? 0
296
+ if (discarded > MAX_DISCARD_BYTES) reader.cancel().catch(() => undefined)
297
+ else next()
298
+ },
299
+ () => undefined,
300
+ )
301
+ }
302
+ next()
303
+ }
304
+
305
+ /** Reads a Web request body fully, enforcing `limit` (see {@link webBodyStream}). */
306
+ export async function readWebStream(
307
+ body: () => ReadableStream<Uint8Array> | null,
308
+ limit: number,
309
+ contentLength: string | undefined,
310
+ ): Promise<Uint8Array> {
311
+ const reader = webBodyStream(body, limit, contentLength).getReader()
312
+ const chunks: Uint8Array[] = []
313
+ let size = 0
314
+ for (;;) {
315
+ const { done, value } = await reader.read()
316
+ if (done) break
317
+ size += value.byteLength
318
+ chunks.push(value)
319
+ }
320
+ return concat(chunks, size)
321
+ }
322
+
323
+ /** Concatenates chunks (no copy for a single chunk). */
324
+ export function concat(chunks: readonly Uint8Array[], size: number): Uint8Array {
325
+ if (chunks.length === 0) return new Uint8Array(0)
326
+ if (chunks.length === 1) return chunks[0] as Uint8Array
327
+ const result = new Uint8Array(size)
328
+ let offset = 0
329
+ for (const chunk of chunks) {
330
+ result.set(chunk, offset)
331
+ offset += chunk.byteLength
332
+ }
333
+ return result
334
+ }
335
+
336
+ /** The light-my-request response of a streaming inject (`payloadAsStream: true`). */
337
+ interface LightStreamResponse {
338
+ readonly statusCode: number
339
+ readonly headers: Record<string, string | number | string[] | undefined>
340
+ stream(): Readable
341
+ readonly raw: { readonly res: { destroy(error?: Error): void } }
342
+ }
343
+
344
+ /** Minimal typing of `light-my-request` (optional peer dependency). */
345
+ export interface LightMyRequest {
346
+ inject(dispatch: (req: never, res: never) => void, options: LightInjectOptions): PromiseLike<LightStreamResponse>
347
+ }
348
+
349
+ /** Options of a streaming light-my-request inject (also `fastify.inject()`). */
350
+ export interface LightInjectOptions {
351
+ method: string
352
+ url: string
353
+ headers: Record<string, string>
354
+ payload?: string | Buffer
355
+ remoteAddress?: string
356
+ payloadAsStream: true
357
+ }
358
+
359
+ let lightMyRequest: Promise<LightMyRequest> | undefined
360
+
361
+ /** Loads `light-my-request`, with an actionable error when it is not installed. */
362
+ export function loadLightMyRequest(adapter: string): Promise<LightMyRequest> {
363
+ lightMyRequest ??= import('light-my-request').then(
364
+ module => {
365
+ const loaded = module as unknown as { default?: LightMyRequest } & LightMyRequest
366
+ return loaded.default ?? loaded
367
+ },
368
+ (error: unknown) => {
369
+ lightMyRequest = undefined
370
+ throw new Error(
371
+ `inject() with the ${adapter} adapter requires the optional peer dependency "light-my-request" ` +
372
+ '(bun add -d light-my-request / npm i -D light-my-request)',
373
+ { cause: error },
374
+ )
375
+ },
376
+ )
377
+ return lightMyRequest
378
+ }
379
+
380
+ /**
381
+ * The light-my-request options of a normalised inject request (streaming mode). The signal is
382
+ * handled by {@link lightStreamResponse} (light-my-request would destroy its fake request with an
383
+ * unhandled error); an already aborted signal rejects at once, like `fetch()`.
384
+ */
385
+ export async function lightInjectOptions(request: InjectStreamRequest): Promise<LightInjectOptions> {
386
+ request.signal?.throwIfAborted()
387
+ const normalized = await normalizeInject(request)
388
+ return {
389
+ method: normalized.method,
390
+ url: normalized.url,
391
+ headers: normalized.headers,
392
+ remoteAddress: '127.0.0.1',
393
+ payloadAsStream: true,
394
+ ...(normalized.payload === undefined ? {} : { payload: toBuffer(normalized.payload) }),
395
+ }
396
+ }
397
+
398
+ /** Runs a request through a Node.js request listener with `light-my-request`, streaming the body. */
399
+ export async function injectNodeStream(
400
+ adapter: string,
401
+ dispatch: (req: never, res: never) => void,
402
+ request: InjectStreamRequest,
403
+ ): Promise<InjectStreamResponse> {
404
+ const options = await lightInjectOptions(request)
405
+ const { inject } = await loadLightMyRequest(adapter)
406
+ return lightStreamResponse(await inject(dispatch, options), request.signal)
407
+ }
408
+
409
+ /**
410
+ * An {@link InjectStreamResponse} of a light-my-request streaming response. Cancelling the body
411
+ * (or aborting `signal`) destroys the server response: the server sees a client disconnect.
412
+ */
413
+ export function lightStreamResponse(response: LightStreamResponse, signal?: AbortSignal): InjectStreamResponse {
414
+ const readable = response.stream()
415
+ const disconnect = (): void => {
416
+ response.raw.res.destroy()
417
+ readable.destroy()
418
+ }
419
+ let finished = false
420
+ const body = new ReadableStream<Uint8Array>(
421
+ {
422
+ start(controller) {
423
+ readable.on('data', (chunk: Buffer) => {
424
+ if (finished) return
425
+ controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
426
+ if ((controller.desiredSize ?? 0) <= 0) readable.pause()
427
+ })
428
+ readable.once('end', () => {
429
+ if (finished) return
430
+ finished = true
431
+ controller.close()
432
+ })
433
+ readable.once('error', error => {
434
+ if (finished) return
435
+ finished = true
436
+ controller.error(error)
437
+ })
438
+ },
439
+ pull() {
440
+ if (readable.isPaused()) readable.resume()
441
+ },
442
+ cancel() {
443
+ finished = true
444
+ disconnect()
445
+ },
446
+ },
447
+ { highWaterMark: 0 },
448
+ )
449
+ if (signal !== undefined) {
450
+ if (signal.aborted) disconnect()
451
+ else signal.addEventListener('abort', disconnect, { once: true })
452
+ }
453
+ return injectStreamResponse(response.statusCode, response.headers, body)
454
+ }
455
+
456
+ /** A `Buffer` view of bytes (tools such as light-my-request JSON-encode other objects). */
457
+ export function toBuffer(payload: string | Uint8Array): string | Buffer {
458
+ return typeof payload === 'string' ? payload : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
459
+ }
460
+
461
+ /**
462
+ * Runs a request through a Fetch handler (`(request: Request) => Promise<Response>`), streaming
463
+ * the body. Cancelling the body (or aborting `signal`) aborts the request signal and cancels the
464
+ * response body.
465
+ */
466
+ export async function injectFetchStream(
467
+ fetch: (request: Request) => Response | Promise<Response>,
468
+ request: InjectStreamRequest,
469
+ ): Promise<InjectStreamResponse> {
470
+ const signal = request.signal
471
+ signal?.throwIfAborted()
472
+ const normalized = await normalizeInject(request)
473
+ const abort = new AbortController()
474
+ signal?.addEventListener('abort', () => abort.abort(signal.reason), { once: true })
475
+ const init: RequestInit = { method: normalized.method, headers: normalized.headers, signal: abort.signal }
476
+ if (normalized.payload !== undefined && normalized.method !== 'GET' && normalized.method !== 'HEAD') {
477
+ init.body = normalized.payload
478
+ }
479
+ const response = await fetch(new Request(`http://localhost${normalized.url}`, init))
480
+ const headers: Record<string, string | readonly string[]> = {}
481
+ for (const [name, value] of response.headers) if (name !== 'set-cookie') headers[name] = value
482
+ const cookies = response.headers.getSetCookie()
483
+ if (cookies.length > 0) headers['set-cookie'] = cookies
484
+ const source = response.body
485
+ const reader = source?.getReader()
486
+ const body = new ReadableStream<Uint8Array>(
487
+ {
488
+ async pull(controller) {
489
+ if (reader === undefined) {
490
+ controller.close()
491
+ return
492
+ }
493
+ const result = await reader.read()
494
+ if (result.done) controller.close()
495
+ else controller.enqueue(result.value)
496
+ },
497
+ async cancel(reason) {
498
+ abort.abort(reason)
499
+ await reader?.cancel(reason).catch(() => undefined)
500
+ },
501
+ },
502
+ { highWaterMark: 0 },
503
+ )
504
+ abort.signal.addEventListener('abort', () => reader?.cancel(abort.signal.reason).catch(() => undefined), {
505
+ once: true,
506
+ })
507
+ return injectStreamResponse(response.status, headers, body)
508
+ }
package/src/body.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { BadRequestException, UnsupportedMediaTypeException } from './exceptions.ts'
2
+ import { parseMultipart, toFormData } from './multipart.ts'
3
+ import { parseQuery } from './query.ts'
4
+ import { errorMessage } from './utils.ts'
5
+
6
+ /** The part of `TextDecoder` used here (the global type is not declared without the DOM lib). */
7
+ interface Decoder {
8
+ readonly encoding: string
9
+ decode(input: Uint8Array): string
10
+ }
11
+
12
+ const UTF8: Decoder = new TextDecoder('utf-8', { fatal: true })
13
+
14
+ /** A parsed `content-type` header. */
15
+ export interface MediaType {
16
+ /** Lower-case `type/subtype` without parameters. */
17
+ readonly type: string
18
+ /** Lower-case `charset` parameter, when present. */
19
+ readonly charset: string | undefined
20
+ }
21
+
22
+ /** Parses a `content-type` header value (`application/json; charset=utf-8`). */
23
+ export function parseMediaType(header: string | undefined): MediaType | undefined {
24
+ if (header === undefined) return undefined
25
+ const separator = header.indexOf(';')
26
+ const type = (separator === -1 ? header : header.slice(0, separator)).trim().toLowerCase()
27
+ if (type === '') return undefined
28
+ let charset: string | undefined
29
+ if (separator !== -1) {
30
+ for (const parameter of header.slice(separator + 1).split(';')) {
31
+ const equals = parameter.indexOf('=')
32
+ if (equals === -1) continue
33
+ if (parameter.slice(0, equals).trim().toLowerCase() !== 'charset') continue
34
+ charset = parameter
35
+ .slice(equals + 1)
36
+ .trim()
37
+ .replace(/^"|"$/g, '')
38
+ .toLowerCase()
39
+ }
40
+ }
41
+ return { type, charset }
42
+ }
43
+
44
+ /** Whether `type` is a JSON media type (`application/json`, `application/problem+json`...). */
45
+ export function isJsonType(type: string): boolean {
46
+ return type === 'application/json' || type.endsWith('+json')
47
+ }
48
+
49
+ /** Rejects a `content-encoding` other than `identity` with 415 (decompression is not supported). */
50
+ export function checkContentEncoding(contentEncoding: string | undefined): void {
51
+ if (contentEncoding === undefined) return
52
+ const encoding = contentEncoding.trim().toLowerCase()
53
+ if (encoding !== '' && encoding !== 'identity') {
54
+ throw new UnsupportedMediaTypeException(`Unsupported content encoding "${encoding}"`)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Whether request headers announce a body (RFC 9112 §6.3: `transfer-encoding`, or a non-zero
60
+ * `content-length`).
61
+ */
62
+ export function hasBody(headers: Readonly<Record<string, string | undefined>>): boolean {
63
+ if (headers['transfer-encoding'] !== undefined) return true
64
+ const length = headers['content-length']
65
+ return length !== undefined && length.trim() !== '0' && length.trim() !== ''
66
+ }
67
+
68
+ /**
69
+ * Parses a buffered request body according to its `content-type` (low-level helper; the request
70
+ * context parses bodies itself, multipart bodies as they arrive):
71
+ *
72
+ * | Content type | Result |
73
+ * | ------------------------------------- | ------------------------------------ |
74
+ * | empty body | `undefined` |
75
+ * | `application/json`, `*\/*+json` | parsed JSON (400 when malformed) |
76
+ * | `text/*` | string (charset honoured) |
77
+ * | `application/x-www-form-urlencoded` | record (repeated keys → arrays) |
78
+ * | `multipart/form-data` | `FormData` (every field and file) |
79
+ * | anything else, or no content type | `Uint8Array` |
80
+ *
81
+ * A `content-encoding` other than `identity` is rejected with 415 (decompression is not supported).
82
+ */
83
+ export function parseBody(
84
+ bytes: Uint8Array,
85
+ contentType: string | undefined,
86
+ contentEncoding?: string | undefined,
87
+ ): Promise<unknown> {
88
+ try {
89
+ return Promise.resolve(parseBytes(bytes, contentType, contentEncoding))
90
+ } catch (error) {
91
+ return Promise.reject(error)
92
+ }
93
+ }
94
+
95
+ function parseBytes(bytes: Uint8Array, contentType: string | undefined, contentEncoding: string | undefined): unknown {
96
+ if (bytes.byteLength === 0) return undefined
97
+ checkContentEncoding(contentEncoding)
98
+ const media = parseMediaType(contentType)
99
+ if (media === undefined) return bytes
100
+ if (isJsonType(media.type)) {
101
+ if (media.charset !== undefined && media.charset !== 'utf-8' && media.charset !== 'utf8') {
102
+ throw new UnsupportedMediaTypeException(`Unsupported charset "${media.charset}" for JSON (use utf-8)`)
103
+ }
104
+ const text = decode(bytes, UTF8, 'JSON')
105
+ try {
106
+ return JSON.parse(text)
107
+ } catch (error) {
108
+ throw new BadRequestException(`Invalid JSON body: ${errorMessage(error)}`)
109
+ }
110
+ }
111
+ if (media.type.startsWith('text/')) return decode(bytes, decoderFor(media.charset), 'text')
112
+ if (media.type === 'application/x-www-form-urlencoded') return parseQuery(decode(bytes, UTF8, 'form'))
113
+ if (media.type === 'multipart/form-data') {
114
+ const policy = { rules: [], maxParts: Number.POSITIVE_INFINITY, maxFieldSize: Number.POSITIVE_INFINITY }
115
+ return toFormData(parseMultipart(bytes, contentType as string, policy))
116
+ }
117
+ return bytes
118
+ }
119
+
120
+ function decoderFor(charset: string | undefined): Decoder {
121
+ if (charset === undefined || charset === 'utf-8' || charset === 'utf8') return UTF8
122
+ try {
123
+ return new TextDecoder(charset as 'utf-8', { fatal: true })
124
+ } catch {
125
+ throw new UnsupportedMediaTypeException(`Unsupported charset "${charset}"`)
126
+ }
127
+ }
128
+
129
+ function decode(bytes: Uint8Array, decoder: Decoder, what: string): string {
130
+ try {
131
+ return decoder.decode(bytes)
132
+ } catch {
133
+ throw new BadRequestException(`Invalid ${what} body: malformed ${decoder.encoding} data`)
134
+ }
135
+ }