@znight/http 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -0,0 +1,918 @@
1
+ import type { Container, CoreLogger, Hooks, HttpRequestEvent, Token } from '@znight/core'
2
+ import type { AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
3
+ import type { CookieSettings } from './cookie-settings.ts'
4
+ import type { SerializedCookie } from './cookies.ts'
5
+ import type { CorsPolicy } from './cors.ts'
6
+ import type { CanActivate, ExceptionMapper, HttpInterceptor } from './interfaces.ts'
7
+ import type { HttpRouteDefinition, HttpSseDefinition } from './metadata.ts'
8
+ import type { TrustPolicy } from './proxy.ts'
9
+ import type { RouteTable } from './router.ts'
10
+ import type { BodyPlan } from './uploads.ts'
11
+
12
+ import { displayName } from '@znight/core'
13
+
14
+ import { isEventStreamType } from './adapters/shared.ts'
15
+ import { type HttpRouteInfo, RequestContext, type RequestId } from './context.ts'
16
+ import { DEFAULT_COOKIE_SETTINGS, serializeWith } from './cookie-settings.ts'
17
+ import { corsReplyHeaders, isPreflight, mergeVary, preflightHeaders } from './cors.ts'
18
+ import {
19
+ BadRequestException,
20
+ ForbiddenException,
21
+ type HeaderValue,
22
+ HttpException,
23
+ isHttpException,
24
+ MethodNotAllowedException,
25
+ NotFoundException,
26
+ } from './exceptions.ts'
27
+ import { isFileBody, resolveFile } from './files.ts'
28
+ import { findInvalidHeader, isHeaderValue } from './headers.ts'
29
+ import { decodePathValue, matchRoutePath, type RoutePath } from './path.ts'
30
+ import { splitUrl } from './query.ts'
31
+ import { HttpResponse, isHttpResponse } from './response.ts'
32
+ import {
33
+ addMissingHeaders,
34
+ assignHeaders,
35
+ type SerializedReply,
36
+ type SerializeOptions,
37
+ serializeError,
38
+ serializeFileReply,
39
+ serializeResult,
40
+ serializeSseReply,
41
+ } from './serialize.ts'
42
+ import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
43
+ import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
44
+ import { HttpTokens } from './tokens.ts'
45
+ import { emptyRecord, errorMessage } from './utils.ts'
46
+
47
+ /** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
48
+ export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
49
+
50
+ /** Everything the pipeline needs to serve one registered route (internal). */
51
+ export interface RoutePlan {
52
+ readonly definition: HttpRouteDefinition
53
+ /** The registered path (a trailing-slash variant has an extra empty static segment). */
54
+ readonly path: RoutePath
55
+ readonly info: HttpRouteInfo
56
+ readonly controller: Resolvable<object>
57
+ readonly guards: readonly Resolvable<CanActivate>[]
58
+ readonly interceptors: readonly Resolvable<HttpInterceptor>[]
59
+ /** Parse the body before the interceptors (`@Body()` or upload parameters). */
60
+ readonly parseBody: boolean
61
+ /** Body limit and multipart rules of the route. */
62
+ readonly body: BodyPlan
63
+ /** Trailing-slash variant answered with a redirect to the path without its trailing slash. */
64
+ readonly redirect?: boolean
65
+ }
66
+
67
+ /** Pipeline settings (resolved plugin options). */
68
+ export interface PipelineOptions {
69
+ readonly adapter: string
70
+ /** Body plan of unmatched requests (plugin `bodyLimit`, generic multipart rules). */
71
+ readonly body: BodyPlan
72
+ readonly exposeErrors: boolean
73
+ readonly requestIdHeader: string | undefined
74
+ readonly generateRequestId: () => string
75
+ readonly mappers: readonly Resolvable<ExceptionMapper>[]
76
+ /** CORS policy (`cors` option), `undefined` when disabled. */
77
+ readonly cors?: CorsPolicy | undefined
78
+ /** Security headers added to every reply that does not set them (`securityHeaders` option). */
79
+ readonly securityHeaders?: Readonly<Record<string, string>>
80
+ /** `trustProxy` policy, `undefined` when proxies are not trusted. */
81
+ readonly trustProxy?: TrustPolicy | undefined
82
+ /** Cookie defaults and signing key (`cookies` option). Default: secure defaults, no signing. */
83
+ readonly cookies?: CookieSettings
84
+ /** Event stream defaults (`sse` option). */
85
+ readonly sse?: SseSettings
86
+ }
87
+
88
+ /** Resolved plugin option `sse`. */
89
+ export interface SseSettings {
90
+ /** Heartbeat interval in ms; `0`: none. */
91
+ readonly heartbeat: number
92
+ /** `retry` sent first, if any. */
93
+ readonly retry: number | undefined
94
+ /** Time a source gets to finish after a graceful close started (ms). */
95
+ readonly shutdownGraceMs: number
96
+ }
97
+
98
+ /** Default event stream settings. */
99
+ export const DEFAULT_SSE_SETTINGS: SseSettings = Object.freeze({
100
+ heartbeat: 8_000,
101
+ retry: undefined,
102
+ shutdownGraceMs: 1_000,
103
+ })
104
+
105
+ type Target =
106
+ | { readonly kind: 'route'; readonly plan: RoutePlan }
107
+ | { readonly kind: 'error'; readonly error: unknown }
108
+ | { readonly kind: 'options'; readonly allow: string }
109
+ | { readonly kind: 'redirect'; readonly location: string }
110
+ /** A CORS preflight; `allow` is `undefined` when no route has the path (404). */
111
+ | { readonly kind: 'preflight'; readonly allow: string | undefined }
112
+
113
+ /** The CORS decision of a request (an `origin` header and a CORS policy). */
114
+ interface CorsDecision {
115
+ readonly policy: CorsPolicy
116
+ readonly origin: string
117
+ readonly allowed: boolean
118
+ }
119
+
120
+ const RAW = Symbol('raw response')
121
+ type Outcome = SerializedReply | typeof RAW
122
+
123
+ /** A reply whose `HttpResponse` cookies were serialised (see `#checkCookies`). */
124
+ type CheckedReply = SerializedReply & { readonly cookieLines?: readonly SerializedCookie[] }
125
+
126
+ /** A reply, and a promise settled once the request is over (response body sent or abandoned, scope closed). */
127
+ interface Served {
128
+ readonly reply: AdapterReply
129
+ readonly done: Promise<void>
130
+ }
131
+
132
+ const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
133
+ const MAX_PATH_IN_MESSAGE = 200
134
+
135
+ /**
136
+ * The request pipeline (architecture §9.1):
137
+ * `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
138
+ * → invoke → serialise`, errors being mapped to responses inside `hooks.run`.
139
+ */
140
+ export class Pipeline {
141
+ readonly #container: Container
142
+ readonly #hooks: Hooks
143
+ readonly #logger: CoreLogger
144
+ readonly #options: PipelineOptions
145
+ readonly #table: RouteTable<RoutePlan>
146
+ #inflight = 0
147
+ #idle: (() => void)[] = []
148
+ /** Open event streams. */
149
+ readonly #streams = new Set<SseSession>()
150
+ /** Set while the server drains: responses ask clients to close their connection. */
151
+ draining = false
152
+
153
+ constructor(
154
+ container: Container,
155
+ hooks: Hooks,
156
+ logger: CoreLogger,
157
+ options: PipelineOptions,
158
+ table: RouteTable<RoutePlan>,
159
+ ) {
160
+ this.#container = container
161
+ this.#hooks = hooks
162
+ this.#logger = logger
163
+ this.#options = options
164
+ this.#table = table
165
+ }
166
+
167
+ /** Number of requests being processed. */
168
+ get inflight(): number {
169
+ return this.#inflight
170
+ }
171
+
172
+ /** Number of open event streams. */
173
+ get streams(): number {
174
+ return this.#streams.size
175
+ }
176
+
177
+ /**
178
+ * Starts the graceful close of every open event stream (`ctx.signal` aborts with `kind`, the source
179
+ * gets `shutdownGraceMs` to finish, then the client receives a jittered `retry`).
180
+ */
181
+ closeStreams(kind: 'shutdown' | 'expired'): void {
182
+ for (const session of [...this.#streams]) session.close(kind)
183
+ }
184
+
185
+ /** Resolves once no request is being processed. */
186
+ whenIdle(): Promise<void> {
187
+ if (this.#inflight === 0) return Promise.resolve()
188
+ return new Promise(resolve => this.#idle.push(resolve))
189
+ }
190
+
191
+ /**
192
+ * Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
193
+ * open) until a streamed or file response body has been fully read or cancelled by the adapter.
194
+ */
195
+ async handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
196
+ const startTime = performance.now()
197
+ this.#enter()
198
+ let served: Served | undefined
199
+ try {
200
+ served = this.#isPreflight(request)
201
+ ? await this.#preflight(request, startTime)
202
+ : await this.#route(plan, request, startTime)
203
+ return served.reply
204
+ } finally {
205
+ this.#leaveWhen(served)
206
+ }
207
+ }
208
+
209
+ #route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<Served> {
210
+ const { path, query } = splitUrl(request.url)
211
+ const raw = matchRoutePath(plan.path, path)
212
+ // the native router matched a path the Znight grammar rejects (e.g. an empty `:param` in Fastify)
213
+ // or a normalised path (Hono): route the raw path with the route table instead
214
+ if (raw === undefined) return this.#fallback(request, undefined, startTime, !rerouted)
215
+ if (plan.redirect === true) {
216
+ const canonical = sameOriginPath(path.slice(0, -1))
217
+ const location = query === '' ? canonical : `${canonical}?${query}`
218
+ return this.#serve(request, undefined, emptyRecord(), { kind: 'redirect', location }, startTime, plan.body)
219
+ }
220
+ const params = emptyRecord<string>()
221
+ let malformed: string | undefined
222
+ for (const name of Object.keys(raw)) {
223
+ const decoded = decodePathValue(raw[name] as string)
224
+ if (decoded === undefined) malformed ??= name
225
+ else params[name] = decoded
226
+ }
227
+ const target: Target =
228
+ malformed === undefined
229
+ ? { kind: 'route', plan }
230
+ : { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
231
+ return this.#serve(request, plan.info, params, target, startTime, plan.body)
232
+ }
233
+
234
+ /** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
235
+ async fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
236
+ const startTime = performance.now()
237
+ this.#enter()
238
+ let served: Served | undefined
239
+ try {
240
+ served = await this.#fallback(request, error, startTime)
241
+ return served.reply
242
+ } finally {
243
+ this.#leaveWhen(served)
244
+ }
245
+ }
246
+
247
+ #fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<Served> {
248
+ const body = this.#options.body
249
+ if (error !== undefined) {
250
+ const target: Target = { kind: 'error', error: frameworkError(error) }
251
+ return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
252
+ }
253
+ if (this.#isPreflight(request)) return this.#preflight(request, startTime)
254
+ const { path } = splitUrl(request.url)
255
+ if (reroute) {
256
+ // routing happens on the raw path: serve a route the adapter's router missed (Hono routes the
257
+ // WHATWG-normalised URL, where `/files/%2e%2e/x` became `/x`)
258
+ const plan = this.#table.find(request.method, path)
259
+ if (plan !== undefined) return this.#route(plan, request, startTime, true)
260
+ }
261
+ const allowed = this.#table.allowedMethods(path)
262
+ let target: Target
263
+ if (allowed.length === 0) {
264
+ target = { kind: 'error', error: new NotFoundException(`Cannot ${request.method} ${truncate(path)}`) }
265
+ } else if (request.method === 'OPTIONS') {
266
+ target = { kind: 'options', allow: allowHeader(allowed) }
267
+ } else {
268
+ // OPTIONS is answered automatically, hence supported (RFC 9110 §15.5.6: the same list as OPTIONS)
269
+ target = {
270
+ kind: 'error',
271
+ error: new MethodNotAllowedException(
272
+ `Method ${request.method} is not allowed on ${truncate(path)}`,
273
+ undefined,
274
+ {
275
+ headers: { allow: allowHeader(allowed) },
276
+ },
277
+ ),
278
+ }
279
+ }
280
+ return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
281
+ }
282
+
283
+ /** A CORS preflight (with the `cors` option): answered without running any route. */
284
+ #isPreflight(request: AdapterRequest): boolean {
285
+ return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
286
+ }
287
+
288
+ #preflight(request: AdapterRequest, startTime: number): Promise<Served> {
289
+ const allowed = this.#table.allowedMethods(splitUrl(request.url).path)
290
+ const target: Target = { kind: 'preflight', allow: allowed.length === 0 ? undefined : allowHeader(allowed) }
291
+ return this.#serve(request, undefined, emptyRecord(), target, startTime, this.#options.body)
292
+ }
293
+
294
+ async #serve(
295
+ request: AdapterRequest,
296
+ route: HttpRouteInfo | undefined,
297
+ params: Record<string, string>,
298
+ target: Target,
299
+ startTime: number,
300
+ body: BodyPlan,
301
+ ): Promise<Served> {
302
+ const requestId = this.#requestId(request)
303
+ const proxy = this.#options.trustProxy
304
+ const ctx = new RequestContext(request, {
305
+ requestId,
306
+ adapter: this.#options.adapter,
307
+ route,
308
+ params,
309
+ body,
310
+ cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
311
+ ...(proxy === undefined
312
+ ? {}
313
+ : { client: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true) }),
314
+ })
315
+ // an event stream requested while draining is served (guards run) with its signal already
316
+ // aborted: a well-behaved source ends at once and the client gets a jittered retry
317
+ if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
318
+ const event: HttpRequestEvent = {
319
+ method: request.method,
320
+ path: ctx.path,
321
+ route: route?.path,
322
+ requestId,
323
+ headers: request.headers,
324
+ context: ctx,
325
+ startTime,
326
+ responseHeaders: {},
327
+ }
328
+ let outcome: Outcome | undefined
329
+ let reply: AdapterReply | undefined
330
+ let cors: CorsDecision | undefined
331
+ const preflight = target.kind === 'preflight'
332
+ let replied: () => void = () => undefined
333
+ const ready = new Promise<void>(resolve => {
334
+ replied = resolve
335
+ })
336
+ // the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
337
+ const done = this.#container
338
+ .runInScope(async () => {
339
+ let hookError: unknown
340
+ let failed = false
341
+ let run: ContextRunner | undefined
342
+ if (this.#options.cors !== undefined) cors = await this.#corsDecision(request, requestId)
343
+ try {
344
+ await this.#hooks.run('http:request', event, async () => {
345
+ outcome = await this.#process(ctx, target, event, cors)
346
+ if (outcome !== RAW) outcome = this.#checkCookies(this.#checkHeaders(outcome, ctx, event), ctx, event)
347
+ run = captureContext() // stream producers run in the context of the handler (scope, hooks)
348
+ return outcome
349
+ })
350
+ } catch (error) {
351
+ failed = true
352
+ hookError = error
353
+ }
354
+ if (outcome === undefined) {
355
+ // a wrapper failed before the request was handled, or did not call next(): answer anyway
356
+ const error = failed ? hookError : new Error('An "http:request" hook wrapper did not call next()')
357
+ event.error ??= error
358
+ // the same checks as a handled reply: an exception mapper's HttpResponse may carry cookies
359
+ const fallback = await this.#errorReply(error, ctx)
360
+ outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
361
+ event.status = outcome.status
362
+ } else if (failed) {
363
+ this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
364
+ err: hookError,
365
+ requestId,
366
+ })
367
+ }
368
+ if (outcome === RAW) {
369
+ ctx.commit()
370
+ replied()
371
+ return
372
+ }
373
+ const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight)
374
+ const body = serialized.body
375
+ if (!(body instanceof ReadableStream || body instanceof Blob)) {
376
+ reply = adapterReply(serialized, body)
377
+ ctx.commit()
378
+ replied()
379
+ ctx.releaseBody()
380
+ return
381
+ }
382
+ // files too: a file-backed Blob is read lazily, after the handler returned
383
+ const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
384
+ const session = serialized.sse
385
+ reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
386
+ if (session !== undefined) {
387
+ this.#streams.add(session)
388
+ if (this.draining) session.close('shutdown')
389
+ }
390
+ ctx.commit()
391
+ replied()
392
+ const failure = await tracked.done
393
+ if (session !== undefined) this.#streams.delete(session)
394
+ if (failure !== undefined) {
395
+ this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
396
+ }
397
+ ctx.releaseBody()
398
+ }, [
399
+ [HttpTokens.Context, ctx],
400
+ [HttpTokens.RequestId, requestId],
401
+ ])
402
+ .then(
403
+ () => {
404
+ ctx.complete()
405
+ },
406
+ (error: unknown) => {
407
+ ctx.complete()
408
+ // the callback above never throws: only the disposal of request-scoped instances can fail
409
+ this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
410
+ },
411
+ )
412
+ await Promise.race([ready, done])
413
+ if (outcome === RAW) {
414
+ const status = request.rawStatus?.() ?? 200
415
+ event.status ??= status
416
+ this.#emit(event)
417
+ return { reply: { status, headers: {}, body: null, raw: true }, done }
418
+ }
419
+ if (reply === undefined) {
420
+ const failure = new Error('Request not handled')
421
+ reply = this.#complete(
422
+ serializeError(500, this.#internalBody(failure), ctx.method),
423
+ ctx,
424
+ event,
425
+ requestId,
426
+ cors,
427
+ preflight,
428
+ )
429
+ event.status = 500
430
+ }
431
+ this.#emit(event)
432
+ return { reply, done }
433
+ }
434
+
435
+ /**
436
+ * Adds the headers of the `http:request` wrappers, then the framework headers (see
437
+ * {@link #frameworkHeaders}), then checks the final headers (see {@link #checkHeaders}).
438
+ */
439
+ #complete(
440
+ reply: CheckedReply,
441
+ ctx: RequestContext,
442
+ event: HttpRequestEvent,
443
+ requestId: RequestId,
444
+ cors: CorsDecision | undefined,
445
+ preflight: boolean,
446
+ ): SerializedReply {
447
+ addMissingHeaders(reply.headers, event.responseHeaders)
448
+ applyCookies(reply, ctx)
449
+ this.#frameworkHeaders(reply.headers, requestId, cors, preflight)
450
+ const checked = this.#checkHeaders(reply, ctx, event)
451
+ if (checked !== reply) {
452
+ applyCookies(checked, ctx)
453
+ this.#frameworkHeaders(checked.headers, requestId, cors, preflight)
454
+ }
455
+ return checked
456
+ }
457
+
458
+ /**
459
+ * Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
460
+ * signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
461
+ * `SameSite=None` without `secure`, signing without secrets, the size after signing) turns the
462
+ * reply into a plain 500, logged as `Invalid cookie "x": …` (like an invalid header).
463
+ */
464
+ #checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
465
+ const cookies = reply.cookies
466
+ if (cookies === undefined || cookies.length === 0) return reply
467
+ const settings = this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS
468
+ const lines: SerializedCookie[] = []
469
+ try {
470
+ for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
471
+ } catch (error) {
472
+ if (reply.body instanceof ReadableStream) discardStream(reply.body)
473
+ event.error = error
474
+ event.status = 500
475
+ this.#log(error, ctx)
476
+ return serializeError(500, this.#internalBody(error), ctx.method)
477
+ }
478
+ return { ...reply, cookieLines: lines }
479
+ }
480
+
481
+ /**
482
+ * Headers the plugin adds to every reply when nothing set them: the request id, the CORS headers
483
+ * of an allowed origin (`vary: origin` whatever the origin) or, with a wildcard policy, of every
484
+ * reply (`access-control-allow-origin: *` does not depend on the request, so a cached reply serves
485
+ * CORS and non-CORS requests alike), the security headers; and `connection: close` while
486
+ * draining.
487
+ */
488
+ #frameworkHeaders(
489
+ headers: Record<string, HeaderValue>,
490
+ requestId: RequestId,
491
+ cors: CorsDecision | undefined,
492
+ preflight: boolean,
493
+ ): void {
494
+ const header = this.#options.requestIdHeader
495
+ if (header !== undefined && headers[header] === undefined && isHeaderValue(requestId)) headers[header] = requestId
496
+ const policy = this.#options.cors
497
+ if (policy !== undefined) {
498
+ // a preflight answer carries its own CORS headers; other replies get them for allowed origins,
499
+ // or always with a wildcard policy (with or without an origin header: no vary needed)
500
+ if (!preflight) {
501
+ if (policy.wildcard) addMissingHeaders(headers, corsReplyHeaders(policy, '*'))
502
+ else if (cors?.allowed === true) addMissingHeaders(headers, corsReplyHeaders(policy, cors.origin))
503
+ }
504
+ if (policy.varyOrigin) mergeVary(headers, 'origin')
505
+ }
506
+ const security = this.#options.securityHeaders
507
+ if (security !== undefined) addMissingHeaders(headers, security)
508
+ if (this.draining) headers['connection'] = 'close'
509
+ }
510
+
511
+ /**
512
+ * The CORS decision of a request carrying an `origin` header, made once before the hooks run. An
513
+ * origin function that throws or rejects denies the origin (logged).
514
+ */
515
+ async #corsDecision(request: AdapterRequest, requestId: RequestId): Promise<CorsDecision | undefined> {
516
+ const policy = this.#options.cors
517
+ const origin = request.headers['origin']
518
+ if (policy === undefined || origin === undefined) return undefined
519
+ let allowed = false
520
+ try {
521
+ // an origin that cannot be echoed in a response header is never allowed
522
+ allowed = isHeaderValue(origin) && (await policy.allows(origin)) === true
523
+ } catch (error) {
524
+ this.#logger.error('The CORS origin check failed: the origin is denied', { err: error, requestId, origin })
525
+ }
526
+ return { policy, origin, allowed }
527
+ }
528
+
529
+ /**
530
+ * A reply with a header no adapter can send (`ctx.setHeader('x', 'a\r\nb')`, a redirect to a
531
+ * client-provided URL with CR/LF, a non Latin-1 value...) becomes a plain 500, identical on every
532
+ * adapter: its body is discarded (a stream is cancelled) and the error logged.
533
+ */
534
+ #checkHeaders(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): SerializedReply {
535
+ const name = findInvalidHeader(reply.headers)
536
+ if (name === undefined) return reply
537
+ if (reply.body instanceof ReadableStream) discardStream(reply.body)
538
+ const error = new TypeError(`Invalid response header ${JSON.stringify(truncate(name))}: invalid name or value`)
539
+ event.error = error
540
+ event.status = 500
541
+ this.#log(error, ctx)
542
+ return serializeError(500, this.#internalBody(error), ctx.method)
543
+ }
544
+
545
+ async #process(
546
+ ctx: RequestContext,
547
+ target: Target,
548
+ event: HttpRequestEvent,
549
+ cors: CorsDecision | undefined,
550
+ ): Promise<Outcome> {
551
+ try {
552
+ switch (target.kind) {
553
+ case 'error':
554
+ throw target.error
555
+ case 'preflight':
556
+ // never runs guards, interceptors or handlers (@Options and @All routes included)
557
+ if (target.allow === undefined) throw new NotFoundException(`Cannot OPTIONS ${truncate(ctx.path)}`)
558
+ if (cors === undefined || !cors.allowed) throw new ForbiddenException('CORS origin not allowed')
559
+ return this.#finish(event, {
560
+ status: 204,
561
+ headers: withContextHeaders(preflightHeaders(cors.policy, cors.origin, ctx.headers, target.allow), ctx),
562
+ body: null,
563
+ })
564
+ case 'options':
565
+ return this.#finish(event, {
566
+ status: 204,
567
+ headers: withContextHeaders({ allow: target.allow }, ctx),
568
+ body: null,
569
+ })
570
+ case 'redirect':
571
+ return this.#finish(
572
+ event,
573
+ serializeResult(undefined, {
574
+ method: ctx.method,
575
+ routeMethod: ctx.method,
576
+ status: 308,
577
+ headers: { location: target.location },
578
+ }),
579
+ )
580
+ default:
581
+ return await this.#invoke(ctx, target.plan, event)
582
+ }
583
+ } catch (error) {
584
+ event.error = error
585
+ const reply = await this.#errorReply(error, ctx)
586
+ event.status = reply.status
587
+ return reply
588
+ }
589
+ }
590
+
591
+ async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
592
+ const controller = this.#resolve(plan.controller)
593
+ for (const guard of plan.guards) {
594
+ const allowed = await this.#resolve(guard).canActivate(ctx)
595
+ if (allowed !== true) throw new ForbiddenException('Forbidden resource')
596
+ }
597
+ if (plan.parseBody) await ctx.parseBody()
598
+ const definition = plan.definition
599
+ const interceptors = plan.interceptors
600
+ let result: unknown
601
+ if (interceptors.length === 0) {
602
+ result = await definition.invoke(controller, ctx)
603
+ } else {
604
+ const chain = async (index: number): Promise<unknown> => {
605
+ if (index === interceptors.length) return await definition.invoke(controller, ctx)
606
+ const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
607
+ return await interceptor.intercept(ctx, () => chain(index + 1))
608
+ }
609
+ result = await chain(0)
610
+ }
611
+ if (definition.rawResponse === true && !(result instanceof Response)) return RAW
612
+ const options: SerializeOptions = {
613
+ method: ctx.method,
614
+ routeMethod: definition.method,
615
+ status: definition.status,
616
+ headers: definition.headers,
617
+ context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
618
+ }
619
+ const sse = definition.sse
620
+ let reply: SerializedReply
621
+ if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
622
+ // an @Sse route: the (awaited) result is the event source
623
+ if (!isSseSource(result)) {
624
+ throw new TypeError(
625
+ `@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
626
+ )
627
+ }
628
+ reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
629
+ } else {
630
+ reply = await this.#serialize(result, ctx, options, sse)
631
+ }
632
+ return this.#finish(event, reply)
633
+ }
634
+
635
+ /** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
636
+ async #serialize(
637
+ result: unknown,
638
+ ctx: RequestContext,
639
+ options: SerializeOptions,
640
+ route?: HttpSseDefinition,
641
+ ): Promise<SerializedReply> {
642
+ if (isHttpResponse(result)) {
643
+ if (isFileBody(result.body)) return serializeFileReply(await resolveFile(result.body, ctx), result, options)
644
+ if (isSseBody(result.body)) {
645
+ const body = result.body
646
+ // HttpResponse.sse() options win over the @Sse options of the route
647
+ const merged = { ...(route === undefined ? {} : sseOptions(route)), ...definedOptions(body.options) }
648
+ return serializeSseReply(this.#openStream(body.source, merged, ctx), result, options)
649
+ }
650
+ }
651
+ return serializeResult(result, options)
652
+ }
653
+
654
+ /** An event stream reading `source` in the request (route options over the plugin `sse` option). */
655
+ #openStream(source: SseSource, options: SseOptions, ctx: RequestContext): SseSession {
656
+ const settings = this.#options.sse ?? DEFAULT_SSE_SETTINGS
657
+ const heartbeat = options.heartbeat === undefined ? settings.heartbeat : options.heartbeat || 0
658
+ const retry = options.retry ?? settings.retry
659
+ const logger = this.#logger
660
+ return new SseSession(
661
+ source,
662
+ {
663
+ heartbeat,
664
+ retry,
665
+ maxDuration: options.maxDuration,
666
+ graceMs: settings.shutdownGraceMs,
667
+ },
668
+ {
669
+ abort: kind => ctx.abort(kind),
670
+ error: (message, error) => logger.error(message, { err: error, requestId: ctx.requestId, path: ctx.path }),
671
+ warn: message => logger.warn(message, { requestId: ctx.requestId, path: ctx.path }),
672
+ },
673
+ )
674
+ }
675
+
676
+ #finish(event: HttpRequestEvent, reply: SerializedReply): SerializedReply {
677
+ event.status = reply.status
678
+ return reply
679
+ }
680
+
681
+ /** Maps an error to a reply: exception mappers first, then the default rendering. Never throws. */
682
+ async #errorReply(error: unknown, ctx: RequestContext): Promise<SerializedReply> {
683
+ for (const mapper of this.#options.mappers) {
684
+ let mapped: unknown
685
+ try {
686
+ mapped = await this.#resolve(mapper).map(error, ctx)
687
+ } catch (mapperError) {
688
+ this.#logger.error('An exception mapper failed', { err: mapperError, requestId: ctx.requestId })
689
+ continue
690
+ }
691
+ if (mapped === undefined) continue
692
+ try {
693
+ if (isHttpResponse(mapped) && mapped.status === undefined) {
694
+ // an error response without explicit status keeps the status of the error
695
+ mapped = new HttpResponse(mapped.body, {
696
+ status: isHttpException(error) ? error.status : 500,
697
+ headers: mapped.headers,
698
+ cookies: (mapped as { cookies?: HttpResponse['cookies'] }).cookies ?? [],
699
+ })
700
+ }
701
+ if (mapped instanceof Response || isHttpResponse(mapped)) {
702
+ return await this.#serialize(mapped, ctx, {
703
+ method: ctx.method,
704
+ routeMethod: ctx.method,
705
+ status: undefined,
706
+ headers: undefined,
707
+ context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
708
+ })
709
+ }
710
+ if (isHttpException(mapped)) return this.#renderException(mapped, ctx, error)
711
+ } catch (renderError) {
712
+ this.#logger.error('Failed to render the result of an exception mapper', {
713
+ err: renderError,
714
+ requestId: ctx.requestId,
715
+ })
716
+ continue
717
+ }
718
+ this.#logger.warn(
719
+ 'An exception mapper returned an unsupported value (expected HttpException, HttpResponse, Response or undefined)',
720
+ {
721
+ requestId: ctx.requestId,
722
+ },
723
+ )
724
+ }
725
+ return this.#renderException(error, ctx, error)
726
+ }
727
+
728
+ #renderException(error: unknown, ctx: RequestContext, original: unknown): SerializedReply {
729
+ let status: number
730
+ let body: unknown
731
+ let extra: Readonly<Record<string, HeaderValue>> | undefined
732
+ if (isHttpException(error)) {
733
+ status = error.status
734
+ try {
735
+ body = error.toBody()
736
+ } catch (bodyError) {
737
+ this.#log(bodyError, ctx)
738
+ status = 500
739
+ body = this.#internalBody(bodyError)
740
+ }
741
+ extra = error.headers
742
+ if (status >= 500) this.#log(original, ctx)
743
+ } else {
744
+ status = 500
745
+ body = this.#internalBody(error)
746
+ this.#log(error, ctx)
747
+ }
748
+ let reply: SerializedReply
749
+ try {
750
+ reply = serializeError(status, body, ctx.method)
751
+ } catch (serializeFailure) {
752
+ this.#log(serializeFailure, ctx)
753
+ reply = serializeError(500, this.#internalBody(serializeFailure), ctx.method)
754
+ extra = undefined
755
+ }
756
+ if (ctx.hasResponseHeaders) assignHeaders(reply.headers, ctx.responseHeaders)
757
+ if (extra !== undefined) assignHeaders(reply.headers, extra)
758
+ return reply
759
+ }
760
+
761
+ #internalBody(error: unknown): object {
762
+ return this.#options.exposeErrors
763
+ ? { statusCode: 500, error: 'Internal Server Error', message: errorMessage(error) }
764
+ : { statusCode: 500, error: 'Internal Server Error' }
765
+ }
766
+
767
+ #log(error: unknown, ctx: RequestContext): void {
768
+ const route = ctx.route
769
+ this.#logger.error(`Request failed: ${ctx.method} ${route?.path ?? truncate(ctx.path)}`, {
770
+ err: error,
771
+ requestId: ctx.requestId,
772
+ method: ctx.method,
773
+ path: ctx.path,
774
+ ...(route === undefined
775
+ ? {}
776
+ : { route: route.path, handler: `${displayName(route.controller)}.${route.handler}` }),
777
+ })
778
+ }
779
+
780
+ #resolve<T>(resolvable: Resolvable<T>): T {
781
+ return 'instance' in resolvable ? resolvable.instance : (this.#container.get(resolvable.token) as T)
782
+ }
783
+
784
+ #requestId(request: AdapterRequest): RequestId {
785
+ const header = this.#options.requestIdHeader
786
+ if (header !== undefined) {
787
+ const incoming = request.headers[header]
788
+ if (incoming !== undefined && REQUEST_ID.test(incoming)) return incoming as RequestId
789
+ }
790
+ return this.#options.generateRequestId() as RequestId
791
+ }
792
+
793
+ #emit(event: HttpRequestEvent): void {
794
+ if (this.#hooks.hasListeners('http:request')) this.#hooks.emit('http:request', event)
795
+ }
796
+
797
+ #enter(): void {
798
+ this.#inflight++
799
+ }
800
+
801
+ /** Leaves once the request is over (immediately when it failed before being served). */
802
+ #leaveWhen(served: Served | undefined): void {
803
+ if (served === undefined) this.#leave()
804
+ else void served.done.then(() => this.#leave())
805
+ }
806
+
807
+ #leave(): void {
808
+ this.#inflight--
809
+ if (this.#inflight === 0 && this.#idle.length > 0) {
810
+ const waiting = this.#idle
811
+ this.#idle = []
812
+ for (const resolve of waiting) resolve()
813
+ }
814
+ }
815
+ }
816
+
817
+ /**
818
+ * The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
819
+ * `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a
820
+ * proxied `fetch()` response, `HttpResponse.stream()`): the adapter sends its headers at once, even
821
+ * before a quiet stream's first byte, and lifts idle timeouts.
822
+ */
823
+ function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob): AdapterReply {
824
+ const reply: AdapterReply = { status: serialized.status, headers: serialized.headers, body }
825
+ if (blob !== undefined) return { ...reply, blob }
826
+ const eventStream =
827
+ serialized.sse !== undefined ||
828
+ (body instanceof ReadableStream && isEventStreamType(serialized.headers['content-type']))
829
+ return eventStream ? { ...reply, eventStream: true } : reply
830
+ }
831
+
832
+ /**
833
+ * Appends the cookies to `set-cookie`, after the raw values (`@Header`, `ctx.setHeader()`, the
834
+ * `HttpResponse` headers, a Web `Response`): the cookies of the context (`ctx.setCookie()`), then
835
+ * those of the `HttpResponse`, a cookie of the same key (name, domain, path) replacing the earlier
836
+ * one in place. One line per cookie.
837
+ */
838
+ function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
839
+ const own = reply.cookieLines ?? []
840
+ const context = ctx.cookieLines
841
+ if (own.length === 0 && context.length === 0) return
842
+ const merged = new Map<string, string>()
843
+ for (const { key, line } of context) merged.set(key, line)
844
+ for (const { key, line } of own) merged.set(key, line)
845
+ const existing = reply.headers['set-cookie']
846
+ let raw: string[] = []
847
+ if (typeof existing === 'string') raw = [existing]
848
+ else if (existing !== undefined) raw = [...existing]
849
+ reply.headers['set-cookie'] = [...raw, ...merged.values()]
850
+ }
851
+
852
+ /** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
853
+ function sseOptions(definition: HttpSseDefinition): SseOptions {
854
+ return {
855
+ ...(definition.heartbeat === undefined
856
+ ? {}
857
+ : { heartbeat: definition.heartbeat === 0 ? false : definition.heartbeat }),
858
+ ...(definition.retry === undefined ? {} : { retry: definition.retry }),
859
+ ...(definition.maxDuration === undefined ? {} : { maxDuration: definition.maxDuration }),
860
+ }
861
+ }
862
+
863
+ function definedOptions(options: SseOptions): SseOptions {
864
+ const result: Record<string, unknown> = {}
865
+ for (const [key, value] of Object.entries(options)) if (value !== undefined) result[key] = value
866
+ return result as SseOptions
867
+ }
868
+
869
+ function describeResult(value: unknown): string {
870
+ if (value === null) return 'null'
871
+ if (value === undefined) return 'undefined'
872
+ if (typeof value === 'string') return 'a string'
873
+ if (typeof value === 'object') return `an instance of ${(value as object).constructor?.name ?? 'Object'}`
874
+ return typeof value
875
+ }
876
+
877
+ function withContextHeaders(headers: Record<string, HeaderValue>, ctx: RequestContext): Record<string, HeaderValue> {
878
+ const result = emptyRecord<HeaderValue>()
879
+ if (ctx.hasResponseHeaders) assignHeaders(result, ctx.responseHeaders)
880
+ assignHeaders(result, headers)
881
+ return result
882
+ }
883
+
884
+ /** Converts an error raised by the server framework (bad URL...) to an `HttpException`. */
885
+ function frameworkError(error: unknown): unknown {
886
+ if (isHttpException(error)) return error
887
+ const status =
888
+ (error as { statusCode?: unknown; status?: unknown } | null)?.statusCode ??
889
+ (error as { status?: unknown } | null)?.status
890
+ if (typeof status === 'number' && status >= 400 && status < 500) {
891
+ return new HttpException(status, errorMessage(error), undefined, { cause: error })
892
+ }
893
+ return error
894
+ }
895
+
896
+ /** `Allow` of a path: its methods and `OPTIONS`, answered automatically when no route declares it. */
897
+ function allowHeader(allowed: readonly string[]): string {
898
+ return (allowed.includes('OPTIONS') ? allowed : [...allowed, 'OPTIONS']).join(', ')
899
+ }
900
+
901
+ /**
902
+ * A raw request path usable as a same-origin `Location`: backslashes, controls and spaces are
903
+ * percent-encoded and leading slashes collapsed, since browsers read `/\evil.com` or `//evil.com`
904
+ * as another host (an open redirect through `trailingSlash: 'redirect'`).
905
+ */
906
+ function sameOriginPath(path: string): string {
907
+ let encoded = ''
908
+ for (const char of path) {
909
+ const code = char.charCodeAt(0)
910
+ encoded +=
911
+ code <= 0x20 || code === 0x7f || char === '\\' ? `%${code.toString(16).toUpperCase().padStart(2, '0')}` : char
912
+ }
913
+ return encoded.replace(/^\/{2,}/, '/')
914
+ }
915
+
916
+ function truncate(path: string): string {
917
+ return path.length > MAX_PATH_IN_MESSAGE ? `${path.slice(0, MAX_PATH_IN_MESSAGE)}…` : path
918
+ }