@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
package/src/context.ts ADDED
@@ -0,0 +1,533 @@
1
+ import type { Token } from '@znight/core'
2
+ import type { AdapterRequest } from './adapter.ts'
3
+ import type { CookieSettings } from './cookie-settings.ts'
4
+ import type { ClearCookieOptions, CookieOptions, ResponseCookie, SerializedCookie } from './cookies.ts'
5
+ import type { HeaderValue } from './exceptions.ts'
6
+ import type { MultipartResult } from './multipart.ts'
7
+ import type { BodyPlan } from './uploads.ts'
8
+
9
+ import { checkContentEncoding, hasBody, parseBody, parseMediaType } from './body.ts'
10
+ import { DEFAULT_COOKIE_SETTINGS, missingSecrets, serializeWith, verifySigned } from './cookie-settings.ts'
11
+ import { parseCookieHeader } from './cookies.ts'
12
+ import { UnsupportedMediaTypeException } from './exceptions.ts'
13
+ import { parseMultipart, readMultipart } from './multipart.ts'
14
+ import { parseQuery, type QueryRecord, splitUrl } from './query.ts'
15
+ import { emptyRecord } from './utils.ts'
16
+
17
+ declare const requestIdBrand: unique symbol
18
+
19
+ /** Why `ctx.signal` aborted: the client left, the server shuts down, or an SSE stream reached its `maxDuration`. */
20
+ export type RequestAbortKind = 'client' | 'shutdown' | 'expired'
21
+
22
+ /**
23
+ * The reason of an aborted `ctx.signal`. Named `AbortError`, what `fetch`, `node:timers/promises`
24
+ * and most libraries check.
25
+ */
26
+ export class RequestAbortedError extends Error {
27
+ override readonly name = 'AbortError'
28
+ /** `client`: disconnected; `shutdown`: the server stops; `expired`: an SSE stream reached its `maxDuration`. */
29
+ readonly kind: RequestAbortKind
30
+
31
+ constructor(kind: RequestAbortKind) {
32
+ super(ABORT_MESSAGES[kind])
33
+ this.kind = kind
34
+ }
35
+ }
36
+
37
+ const ABORT_MESSAGES: Readonly<Record<RequestAbortKind, string>> = {
38
+ client: 'The client disconnected',
39
+ shutdown: 'The server is shutting down',
40
+ expired: 'The event stream reached its maxDuration',
41
+ }
42
+
43
+ /**
44
+ * Identifier of the current request: the incoming `x-request-id` header (see the plugin option
45
+ * `requestIdHeader`) or a generated UUID. Injectable in request-scoped providers.
46
+ */
47
+ export type RequestId = string & { readonly [requestIdBrand]: true }
48
+
49
+ /** The route that matched a request. */
50
+ export interface HttpRouteInfo {
51
+ /** Route method (`GET`...; the concrete method for `@All` routes). */
52
+ readonly method: string
53
+ /** Route pattern including the plugin prefix (`/api/users/:id`). */
54
+ readonly path: string
55
+ /** Controller provider token. */
56
+ readonly controller: Token
57
+ /** Handler method name. */
58
+ readonly handler: string
59
+ /**
60
+ * Effective route metadata (`createRouteDecorator`): the controller's entries, except those of a
61
+ * group the route decides itself, then the route's entries (the nearest decision wins).
62
+ */
63
+ readonly meta: Readonly<Record<string, unknown>>
64
+ }
65
+
66
+ /**
67
+ * Adapter-neutral view of the current HTTP request, shared by guards, interceptors, exception
68
+ * mappers and handlers (`@Ctx()`), and injectable in request-scoped providers.
69
+ */
70
+ export interface HttpContext {
71
+ readonly requestId: RequestId
72
+ /** Upper-case method. */
73
+ readonly method: string
74
+ /** Raw request target (`/users/a%20b?page=2`). */
75
+ readonly url: string
76
+ /** Raw path, without query string (`/users/a%20b`). */
77
+ readonly path: string
78
+ /** Decoded route parameters (`*` holds the part matched by a trailing wildcard). */
79
+ readonly params: Readonly<Record<string, string>>
80
+ /** Decoded query string (repeated keys → arrays), parsed on first access. */
81
+ readonly query: Readonly<QueryRecord>
82
+ /** Request headers, lower-case names. */
83
+ readonly headers: Readonly<Record<string, string | undefined>>
84
+ /**
85
+ * Parsed body. Populated before the interceptors for routes declaring `@Body()` or upload
86
+ * parameters (and after {@link parseBody} resolved otherwise); `undefined` until then and for
87
+ * empty bodies. Forms (urlencoded and multipart) are records of their text fields.
88
+ */
89
+ readonly body: unknown
90
+ /**
91
+ * Files of a `multipart/form-data` body by field name, in body order (standard `File` objects,
92
+ * held in memory). Populated with {@link body}; empty for other bodies.
93
+ */
94
+ readonly files: Readonly<Record<string, readonly File[]>>
95
+ /** The matched route, `undefined` for unmatched requests (404/405). */
96
+ readonly route: HttpRouteInfo | undefined
97
+ /** Per-request storage for guards, interceptors and handlers (`ctx.state.set('user', user)`). */
98
+ readonly state: Map<string | symbol, unknown>
99
+ /** Native request of the adapter (`IncomingMessage`, `FastifyRequest`, Express `Request`, Web `Request`). */
100
+ readonly request: unknown
101
+ /** Native response of the adapter (`ServerResponse`, `FastifyReply`, Express `Response`, Hono `Context`). */
102
+ readonly response: unknown
103
+ /** Adapter name (`node`, `fastify`, `express`, `hono`). */
104
+ readonly adapter: string
105
+ /**
106
+ * Client address, when the adapter knows it. With the plugin option `trustProxy`, the address
107
+ * found in `x-forwarded-for` behind the trusted proxies; otherwise the adapter's address (the
108
+ * socket peer, or what Fastify / Express derive from their own proxy settings).
109
+ */
110
+ readonly ip: string | undefined
111
+ /**
112
+ * `https` when the connection uses TLS, or, behind a trusted proxy (`trustProxy`), when the
113
+ * leftmost `x-forwarded-proto` says so; `http` otherwise.
114
+ */
115
+ readonly protocol: 'http' | 'https'
116
+ /** Headers added to the response, whatever its outcome (errors included). */
117
+ readonly responseHeaders: Readonly<Record<string, HeaderValue>>
118
+ /** A request header (case-insensitive name). */
119
+ header(name: string): string | undefined
120
+ /** Sets a response header (case-insensitive name), sent with success and error responses. */
121
+ setHeader(name: string, value: HeaderValue): void
122
+ /** Appends a response header value (`set-cookie`...). */
123
+ appendHeader(name: string, value: string): void
124
+ /**
125
+ * Request cookies (`cookie` header), parsed on first access: decoded values, the first occurrence
126
+ * of a name wins. A prototype-less, frozen record.
127
+ */
128
+ readonly cookies: Readonly<Record<string, string>>
129
+ /**
130
+ * The verified value of a signed cookie (`@SignedCookie`), `undefined` when it is absent, tampered
131
+ * or signed with a key that is no longer in `cookies.secrets`. Verified once per name.
132
+ *
133
+ * @throws Error when the plugin option `cookies.secrets` is not set.
134
+ */
135
+ signedCookie(name: string): string | undefined
136
+ /**
137
+ * Sets a cookie on the response, whatever its outcome (errors, redirects included), with the
138
+ * secure defaults (`Path=/; Secure; HttpOnly; SameSite=Lax`, see the plugin option
139
+ * `cookies.defaults`). A later cookie with the same name, domain and path replaces this one; a
140
+ * cookie of the same key in an `HttpResponse` (`withCookie`) wins over it. Everything is validated
141
+ * now (name, value, attributes, prefixes, size), so the stack trace points to the faulty call.
142
+ *
143
+ * @throws TypeError / RangeError for an invalid cookie; Error once the response headers were sent
144
+ * (a running event stream).
145
+ */
146
+ setCookie(name: string, value: string, options?: CookieOptions): void
147
+ /**
148
+ * Clears a cookie (`Max-Age=0`, with the same domain and path, and the defaults: a `__Host-`
149
+ * cookie is cleared with valid attributes).
150
+ *
151
+ * @throws like {@link setCookie}.
152
+ */
153
+ clearCookie(name: string, options?: ClearCookieOptions): void
154
+ /** Cookies set on this response so far (`setCookie` / `clearCookie`), in order. */
155
+ readonly responseCookies: readonly ResponseCookie[]
156
+ /**
157
+ * Aborted when the client disconnects before the response was written, when the response body is
158
+ * cancelled, or when the server closes an event stream (shutdown, `maxDuration`); never after a
159
+ * normal completion. The reason is a {@link RequestAbortedError}. Pass it to the work the request
160
+ * starts (`fetch(url, { signal: ctx.signal })`, `setTimeout(ms, v, { signal })`). Created on first
161
+ * access.
162
+ */
163
+ readonly signal: AbortSignal
164
+ /**
165
+ * Reads and parses the body (once): JSON (`application/json`, `+json`), text (`text/*`), forms
166
+ * (`application/x-www-form-urlencoded` and `multipart/form-data` → record of the text fields,
167
+ * repeated names as arrays; files in {@link files}), raw bytes otherwise; `undefined` when empty.
168
+ * Fails with 400 (malformed), 413 (body or upload limits) or 415 (unsupported encoding, refused
169
+ * file type, non-multipart body on an upload route).
170
+ */
171
+ parseBody(): Promise<unknown>
172
+ /** The raw body bytes (once; shared with {@link parseBody}), e.g. to check a webhook signature. */
173
+ rawBody(): Promise<Uint8Array>
174
+ /**
175
+ * The request body as a Web stream, read as it arrives (nothing is buffered): the escape hatch
176
+ * for large uploads (`PUT /files/:name` piped to disk or object storage). The body limit of the
177
+ * route still applies (`@BodyLimit`, `Infinity` to disable): past it the stream errors with a
178
+ * `PayloadTooLargeException` (413). Exclusive with {@link rawBody} / {@link parseBody}: throws when
179
+ * the body was already read (routes with `@Body()` or upload parameters read it before the
180
+ * handler).
181
+ */
182
+ bodyStream(): ReadableStream<Uint8Array>
183
+ }
184
+
185
+ /** Implementation of {@link HttpContext} used by the pipeline (internal). */
186
+ export class RequestContext implements HttpContext {
187
+ readonly requestId: RequestId
188
+ readonly method: string
189
+ readonly url: string
190
+ readonly path: string
191
+ readonly params: Readonly<Record<string, string>>
192
+ readonly headers: Readonly<Record<string, string | undefined>>
193
+ readonly route: HttpRouteInfo | undefined
194
+ readonly request: unknown
195
+ readonly response: unknown
196
+ readonly adapter: string
197
+ readonly ip: string | undefined
198
+ readonly protocol: 'http' | 'https'
199
+ readonly #source: AdapterRequest
200
+ readonly #plan: BodyPlan
201
+ readonly #rawQuery: string
202
+ #query: QueryRecord | undefined
203
+ #state: Map<string | symbol, unknown> | undefined
204
+ #responseHeaders: Record<string, HeaderValue> | undefined
205
+ #body: unknown
206
+ #files: Readonly<Record<string, readonly File[]>> | undefined
207
+ #raw: Promise<Uint8Array> | undefined
208
+ #streamed = false
209
+ /** Stops the body stream opened for the request, unless it was read to its end or cancelled. */
210
+ #stopBody: (() => void) | undefined
211
+ #parsed: Promise<unknown> | undefined
212
+ readonly #cookieSettings: CookieSettings
213
+ #cookies: Readonly<Record<string, string>> | undefined
214
+ #signed: Map<string, string | undefined> | undefined
215
+ /** Cookies set on the response, by key (name, domain, path), with their `Set-Cookie` line. */
216
+ #jar: Map<string, { readonly cookie: ResponseCookie; readonly line: string }> | undefined
217
+ /** The response headers were handed to the adapter (cookies can no longer be set). */
218
+ #committed = false
219
+ /** The request is over: later aborts are ignored. */
220
+ #completed = false
221
+ #abortReason: RequestAbortedError | undefined
222
+ #abortController: AbortController | undefined
223
+
224
+ constructor(
225
+ source: AdapterRequest,
226
+ init: {
227
+ readonly requestId: RequestId
228
+ readonly adapter: string
229
+ readonly route: HttpRouteInfo | undefined
230
+ readonly params: Readonly<Record<string, string>>
231
+ readonly body: BodyPlan
232
+ /** Client address and protocol (`trustProxy`); default: the adapter's `ip` and `encrypted`. */
233
+ readonly client?: { readonly ip: string | undefined; readonly protocol: 'http' | 'https' }
234
+ /** Cookie defaults and signing key (plugin option `cookies`); default: secure defaults, no signing. */
235
+ readonly cookies?: CookieSettings
236
+ },
237
+ ) {
238
+ this.#source = source
239
+ this.#plan = init.body
240
+ this.#cookieSettings = init.cookies ?? DEFAULT_COOKIE_SETTINGS
241
+ const { path, query } = splitUrl(source.url)
242
+ this.#rawQuery = query
243
+ this.requestId = init.requestId
244
+ this.method = source.method
245
+ this.url = source.url
246
+ this.path = path
247
+ this.params = init.params
248
+ this.headers = source.headers
249
+ this.route = init.route
250
+ this.request = source.raw
251
+ this.response = source.rawResponse
252
+ this.adapter = init.adapter
253
+ const connection = source.encrypted === true ? 'https' : 'http'
254
+ this.ip = init.client === undefined ? source.ip : init.client.ip
255
+ this.protocol = init.client === undefined ? connection : init.client.protocol
256
+ }
257
+
258
+ get query(): Readonly<QueryRecord> {
259
+ this.#query ??= parseQuery(this.#rawQuery)
260
+ return this.#query
261
+ }
262
+
263
+ get state(): Map<string | symbol, unknown> {
264
+ this.#state ??= new Map()
265
+ return this.#state
266
+ }
267
+
268
+ get body(): unknown {
269
+ return this.#body
270
+ }
271
+
272
+ get files(): Readonly<Record<string, readonly File[]>> {
273
+ return this.#files ?? NO_FILES
274
+ }
275
+
276
+ /** Maximum body size of the route, in bytes. */
277
+ get bodyLimit(): number {
278
+ return this.#plan.limit
279
+ }
280
+
281
+ get responseHeaders(): Readonly<Record<string, HeaderValue>> {
282
+ return this.#responseHeaders ?? EMPTY_HEADERS
283
+ }
284
+
285
+ /** Whether {@link setHeader}/{@link appendHeader} were called. */
286
+ get hasResponseHeaders(): boolean {
287
+ return this.#responseHeaders !== undefined
288
+ }
289
+
290
+ header(name: string): string | undefined {
291
+ return this.headers[name.toLowerCase()]
292
+ }
293
+
294
+ setHeader(name: string, value: HeaderValue): void {
295
+ this.#responseHeaders ??= emptyRecord()
296
+ this.#responseHeaders[name.toLowerCase()] = typeof value === 'string' ? value : [...value]
297
+ }
298
+
299
+ appendHeader(name: string, value: string): void {
300
+ this.#responseHeaders ??= emptyRecord()
301
+ const key = name.toLowerCase()
302
+ const existing = this.#responseHeaders[key]
303
+ if (existing === undefined) this.#responseHeaders[key] = value
304
+ else this.#responseHeaders[key] = typeof existing === 'string' ? [existing, value] : [...existing, value]
305
+ }
306
+
307
+ get cookies(): Readonly<Record<string, string>> {
308
+ this.#cookies ??= parseCookieHeader(this.headers['cookie'])
309
+ return this.#cookies
310
+ }
311
+
312
+ signedCookie(name: string): string | undefined {
313
+ const signer = this.#cookieSettings.signer
314
+ if (signer === undefined) throw missingSecrets(`Cannot read the signed cookie ${JSON.stringify(String(name))}`)
315
+ this.#signed ??= new Map()
316
+ if (this.#signed.has(name)) return this.#signed.get(name)
317
+ const value = verifySigned(signer, name, this.cookies[name])
318
+ this.#signed.set(name, value)
319
+ return value
320
+ }
321
+
322
+ setCookie(name: string, value: string, options: CookieOptions = {}): void {
323
+ this.#addCookie({ name, value, options })
324
+ }
325
+
326
+ clearCookie(name: string, options: ClearCookieOptions = {}): void {
327
+ this.#addCookie({ name, value: undefined, options })
328
+ }
329
+
330
+ get responseCookies(): readonly ResponseCookie[] {
331
+ return this.#jar === undefined ? NO_COOKIES : [...this.#jar.values()].map(entry => entry.cookie)
332
+ }
333
+
334
+ /** The `Set-Cookie` lines of {@link setCookie} / {@link clearCookie}, with their keys, in order (internal). */
335
+ get cookieLines(): readonly SerializedCookie[] {
336
+ return this.#jar === undefined ? [] : [...this.#jar].map(([key, entry]) => ({ key, line: entry.line }))
337
+ }
338
+
339
+ #addCookie(cookie: ResponseCookie): void {
340
+ if (this.#committed) {
341
+ throw new Error(
342
+ `Cannot set cookie ${JSON.stringify(String(cookie.name))}: the response headers were already sent`,
343
+ )
344
+ }
345
+ if (cookie.options === null || typeof cookie.options !== 'object') {
346
+ throw new TypeError(`Invalid cookie ${JSON.stringify(String(cookie.name))}: options must be an object`)
347
+ }
348
+ const options = cookie.value === undefined ? withoutLifetime(cookie.options) : cookie.options
349
+ const frozen: ResponseCookie = Object.freeze({
350
+ name: cookie.name,
351
+ value: cookie.value,
352
+ options: Object.freeze({ ...options }),
353
+ })
354
+ const { key, line } = serializeWith(frozen, this.#cookieSettings)
355
+ this.#jar ??= new Map()
356
+ this.#jar.set(key, { cookie: frozen, line })
357
+ }
358
+
359
+ get signal(): AbortSignal {
360
+ if (this.#abortController === undefined) {
361
+ const controller = new AbortController()
362
+ this.#abortController = controller
363
+ if (this.#abortReason !== undefined) controller.abort(this.#abortReason)
364
+ else if (!this.#completed) this.#source.onAbort?.(() => this.abort('client'))
365
+ }
366
+ return this.#abortController.signal
367
+ }
368
+
369
+ /** Whether {@link signal} aborted (or will be created aborted). */
370
+ get aborted(): boolean {
371
+ return this.#abortReason !== undefined
372
+ }
373
+
374
+ /**
375
+ * Aborts {@link signal} with a {@link RequestAbortedError} of `kind` (internal): at most once, and
376
+ * never once the request is over ({@link complete}).
377
+ */
378
+ abort(kind: RequestAbortKind): void {
379
+ if (this.#abortReason !== undefined || this.#completed) return
380
+ const reason = new RequestAbortedError(kind)
381
+ this.#abortReason = reason
382
+ this.#abortController?.abort(reason)
383
+ }
384
+
385
+ /** The response headers are handed to the adapter: cookies can no longer be set (internal). */
386
+ commit(): void {
387
+ this.#committed = true
388
+ }
389
+
390
+ /** The request is over (response sent or abandoned): later aborts are ignored (internal). */
391
+ complete(): void {
392
+ this.#completed = true
393
+ }
394
+
395
+ rawBody(): Promise<Uint8Array> {
396
+ if (this.#raw === undefined) {
397
+ if (this.#streamed) return Promise.reject(alreadyStreamed())
398
+ this.#raw = this.#source.readBody(this.#plan.limit)
399
+ }
400
+ return this.#raw
401
+ }
402
+
403
+ bodyStream(): ReadableStream<Uint8Array> {
404
+ if (this.#raw !== undefined || this.#streamed) throw alreadyStreamed()
405
+ this.#streamed = true
406
+ return this.#openBodyStream()
407
+ }
408
+
409
+ /**
410
+ * Called by the pipeline once the request is over: a body stream that was neither read to its
411
+ * end nor cancelled (a handler that read the first chunk, then threw) is cancelled, which makes the
412
+ * adapter discard the rest of the body, so that the connection can serve the next request.
413
+ */
414
+ releaseBody(): void {
415
+ const stop = this.#stopBody
416
+ this.#stopBody = undefined
417
+ stop?.()
418
+ }
419
+
420
+ /** The body as a stream (see {@link releaseBody}). */
421
+ #openBodyStream(): ReadableStream<Uint8Array> {
422
+ const reader = openBodyStream(this.#source, this.#plan.limit).getReader()
423
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined
424
+ const settle = (): void => {
425
+ this.#stopBody = undefined
426
+ }
427
+ this.#stopBody = (): void => {
428
+ reader.cancel().catch(() => undefined)
429
+ try {
430
+ controller?.error(new Error('The request is over: its body can no longer be read'))
431
+ } catch {
432
+ // already closed or errored
433
+ }
434
+ }
435
+ return new ReadableStream<Uint8Array>(
436
+ {
437
+ start(current) {
438
+ controller = current
439
+ },
440
+ async pull(current) {
441
+ let result: Awaited<ReturnType<typeof reader.read>>
442
+ try {
443
+ result = await reader.read()
444
+ } catch (error) {
445
+ settle()
446
+ throw error
447
+ }
448
+ if (result.done) {
449
+ settle()
450
+ current.close()
451
+ return
452
+ }
453
+ current.enqueue(result.value)
454
+ },
455
+ cancel(reason) {
456
+ settle()
457
+ return reader.cancel(reason)
458
+ },
459
+ },
460
+ { highWaterMark: 0 },
461
+ )
462
+ }
463
+
464
+ parseBody(): Promise<unknown> {
465
+ this.#parsed ??= this.#parse()
466
+ return this.#parsed
467
+ }
468
+
469
+ async #parse(): Promise<unknown> {
470
+ const headers = this.headers
471
+ const contentType = headers['content-type']
472
+ const media = parseMediaType(contentType)
473
+ if (media?.type === 'multipart/form-data') {
474
+ checkContentEncoding(headers['content-encoding'])
475
+ const policy = this.#plan.multipart
476
+ let result: MultipartResult
477
+ if (this.#raw === undefined) {
478
+ if (this.#streamed) throw alreadyStreamed()
479
+ this.#streamed = true
480
+ result = await readMultipart(this.#openBodyStream(), contentType as string, policy)
481
+ } else {
482
+ result = parseMultipart(await this.#raw, contentType as string, policy)
483
+ }
484
+ this.#files = result.files
485
+ this.#body = result.fields
486
+ return result.fields
487
+ }
488
+ if (this.#plan.upload) {
489
+ // upload routes only accept multipart bodies (an empty body means "no file"); the framing
490
+ // headers decide, the adapter only for requests without them (Web `Request`s built in memory)
491
+ const framed = headers['content-length'] !== undefined || headers['transfer-encoding'] !== undefined
492
+ if (!(framed ? hasBody(headers) : this.#source.hasBody?.() === true)) return undefined
493
+ throw new UnsupportedMediaTypeException(
494
+ `Expected a multipart/form-data body${media === undefined ? '' : ` (received ${JSON.stringify(media.type)})`}`,
495
+ )
496
+ }
497
+ const bytes = await this.rawBody()
498
+ const body = await parseBody(bytes, contentType, headers['content-encoding'])
499
+ this.#body = body
500
+ return body
501
+ }
502
+ }
503
+
504
+ /** The body as a stream: the adapter's `streamBody`, or the buffered body when it has none. */
505
+ function openBodyStream(source: AdapterRequest, limit: number): ReadableStream<Uint8Array> {
506
+ if (typeof source.streamBody === 'function') return source.streamBody(limit)
507
+ return new ReadableStream<Uint8Array>(
508
+ {
509
+ async pull(controller) {
510
+ const bytes = await source.readBody(limit)
511
+ if (bytes.byteLength > 0) controller.enqueue(bytes)
512
+ controller.close()
513
+ },
514
+ },
515
+ { highWaterMark: 0 },
516
+ )
517
+ }
518
+
519
+ function alreadyStreamed(): Error {
520
+ return new Error(
521
+ 'The request body was already read: bodyStream() cannot be combined with rawBody(), parseBody(), @Body() or upload parameters',
522
+ )
523
+ }
524
+
525
+ /** Clearing cookies have no lifetime of their own (`Max-Age=0`) and are never signed. */
526
+ function withoutLifetime(options: ClearCookieOptions): ClearCookieOptions {
527
+ const { maxAge: _maxAge, expires: _expires, signed: _signed, ...rest } = options as CookieOptions
528
+ return rest
529
+ }
530
+
531
+ const EMPTY_HEADERS: Readonly<Record<string, HeaderValue>> = Object.freeze({})
532
+ const NO_COOKIES: readonly ResponseCookie[] = Object.freeze([])
533
+ const NO_FILES: Readonly<Record<string, readonly File[]>> = Object.freeze(emptyRecord<readonly File[]>())
@@ -0,0 +1,68 @@
1
+ /** Cookie settings of a plugin (defaults and signing key) and the operations using them (internal). */
2
+ import type { CookieSigner } from './cookie-signer.ts'
3
+
4
+ import {
5
+ type CookieDefaults,
6
+ DEFAULT_COOKIE_OPTIONS,
7
+ decodeCookieValue,
8
+ encodeCookieValue,
9
+ type ResponseCookie,
10
+ type SerializedCookie,
11
+ serializeResponseCookie,
12
+ } from './cookies.ts'
13
+
14
+ /** The resolved plugin option `cookies`. */
15
+ export interface CookieSettings {
16
+ /** Secure defaults merged with `cookies.defaults`. */
17
+ readonly defaults: CookieDefaults
18
+ /** `undefined` without `cookies.secrets` (signed cookies unavailable). */
19
+ readonly signer: CookieSigner | undefined
20
+ }
21
+
22
+ /** Settings without plugin options: the secure defaults, no signing. */
23
+ export const DEFAULT_COOKIE_SETTINGS: CookieSettings = Object.freeze({
24
+ defaults: DEFAULT_COOKIE_OPTIONS,
25
+ signer: undefined,
26
+ })
27
+
28
+ /** The error of a signed cookie used without `cookies.secrets`. */
29
+ export function missingSecrets(action: string): Error {
30
+ return new Error(
31
+ `${action}: the HttpPlugin option cookies.secrets is not set (HttpPlugin.configure({ cookies: { secrets: [...] } }))`,
32
+ )
33
+ }
34
+
35
+ /** Serialises a response cookie with the plugin settings (`signed: true` signs with the first secret). */
36
+ export function serializeWith(cookie: ResponseCookie, settings: CookieSettings): SerializedCookie {
37
+ const signer = settings.signer
38
+ if (cookie.value !== undefined && cookie.options.signed === true && signer === undefined) {
39
+ throw missingSecrets(`Cannot sign the cookie "${cookie.name}"`)
40
+ }
41
+ return serializeResponseCookie(cookie, settings.defaults, (name, encoded) =>
42
+ (signer as CookieSigner).sign(name, encoded),
43
+ )
44
+ }
45
+
46
+ /**
47
+ * The verified value of a signed request cookie (`value` as parsed, i.e. decoded), `undefined` when
48
+ * absent, tampered or signed with an unknown key. The value is re-encoded canonically before the
49
+ * MAC is checked (`encode(decode(w)) === w` for every value Znight signs).
50
+ */
51
+ export function verifySigned(signer: CookieSigner, name: string, value: string | undefined): string | undefined {
52
+ if (value === undefined) return undefined
53
+ const dot = value.lastIndexOf('.')
54
+ if (dot === -1) return undefined
55
+ let encoded: string
56
+ try {
57
+ encoded = encodeCookieValue(value.slice(0, dot))
58
+ } catch {
59
+ return undefined
60
+ }
61
+ const verified = signer.verify(name, `${encoded}${value.slice(dot)}`)
62
+ return verified === undefined ? undefined : decodeCookieValue(verified)
63
+ }
64
+
65
+ /** The signed wire value of `value` (encoded, then signed): what a client sends back. */
66
+ export function signValue(signer: CookieSigner, name: string, value: string): string {
67
+ return signer.sign(name, encodeCookieValue(value))
68
+ }