@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.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- 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
|
+
}
|