@znight/http 0.1.1 → 0.1.3

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 (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +83 -32
  3. package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
  4. package/dist/compiler.d.mts +1 -1
  5. package/dist/compiler.mjs +82 -13
  6. package/dist/compiler.mjs.map +1 -1
  7. package/dist/express.d.mts +1 -1
  8. package/dist/express.mjs +3 -2
  9. package/dist/express.mjs.map +1 -1
  10. package/dist/fastify.d.mts +1 -1
  11. package/dist/fastify.mjs +254 -42
  12. package/dist/fastify.mjs.map +1 -1
  13. package/dist/hono.d.mts +1 -1
  14. package/dist/hono.mjs +272 -67
  15. package/dist/hono.mjs.map +1 -1
  16. package/dist/index.d.mts +10 -2
  17. package/dist/index.mjs +930 -278
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
  20. package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  21. package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
  22. package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  23. package/dist/node.d.mts +1 -1
  24. package/dist/node.mjs +1 -1
  25. package/dist/utils-DIWdzVht.mjs +46 -0
  26. package/dist/utils-DIWdzVht.mjs.map +1 -0
  27. package/package.json +4 -4
  28. package/src/adapter.ts +6 -0
  29. package/src/adapters/express.ts +4 -1
  30. package/src/adapters/fastify.ts +329 -44
  31. package/src/adapters/hono.ts +341 -86
  32. package/src/adapters/node-io.ts +185 -82
  33. package/src/adapters/node.ts +4 -1
  34. package/src/body.ts +31 -2
  35. package/src/coerce.ts +31 -0
  36. package/src/compiler/generate.ts +127 -22
  37. package/src/context.ts +151 -78
  38. package/src/index.ts +1 -1
  39. package/src/pipeline.ts +809 -208
  40. package/src/plugin.ts +10 -4
  41. package/src/query.ts +7 -1
  42. package/src/response.ts +6 -8
  43. package/src/serialize.ts +83 -16
  44. package/src/utils.ts +25 -2
  45. package/src/validate.ts +28 -12
  46. package/dist/utils-DCbu3a4w.mjs +0 -27
  47. package/dist/utils-DCbu3a4w.mjs.map +0 -1
package/src/pipeline.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { Container, CoreLogger, Hooks, HttpRequestEvent, Token } from '@znight/core'
2
- import type { AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
1
+ import type { Container, CoreLogger, Hooks, HttpRequestEvent, OpenedScope, Token } from '@znight/core'
2
+ import type { AdapterHandler, AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
3
3
  import type { CookieSettings } from './cookie-settings.ts'
4
4
  import type { SerializedCookie } from './cookies.ts'
5
5
  import type { CorsPolicy } from './cors.ts'
@@ -9,6 +9,7 @@ import type { TrustPolicy } from './proxy.ts'
9
9
  import type { RouteTable } from './router.ts'
10
10
  import type { BodyPlan } from './uploads.ts'
11
11
 
12
+ import { Buffer } from 'node:buffer'
12
13
  import { displayName } from '@znight/core'
13
14
 
14
15
  import { isEventStreamType } from './adapters/shared.ts'
@@ -26,12 +27,13 @@ import {
26
27
  } from './exceptions.ts'
27
28
  import { isFileBody, resolveFile } from './files.ts'
28
29
  import { findInvalidHeader, isHeaderValue } from './headers.ts'
29
- import { decodePathValue, matchRoutePath, type RoutePath } from './path.ts'
30
- import { splitUrl } from './query.ts'
30
+ import { decodePathValue, matchRoutePath, type RoutePath, type RouteSegment, WILDCARD_PARAM } from './path.ts'
31
+ import { type RequestTarget, splitUrl } from './query.ts'
31
32
  import { HttpResponse, isHttpResponse } from './response.ts'
32
33
  import {
33
34
  addMissingHeaders,
34
35
  assignHeaders,
36
+ ContentType,
35
37
  type SerializedReply,
36
38
  type SerializeOptions,
37
39
  serializeError,
@@ -40,9 +42,10 @@ import {
40
42
  serializeSseReply,
41
43
  } from './serialize.ts'
42
44
  import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
45
+ import { isBodylessStatus } from './status.ts'
43
46
  import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
44
47
  import { HttpTokens } from './tokens.ts'
45
- import { emptyRecord, errorMessage } from './utils.ts'
48
+ import { emptyRecord, errorMessage, isBun, isThenable } from './utils.ts'
46
49
 
47
50
  /** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
48
51
  export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
@@ -123,15 +126,78 @@ type Outcome = SerializedReply | typeof RAW
123
126
  /** A reply whose `HttpResponse` cookies were serialised (see `#checkCookies`). */
124
127
  type CheckedReply = SerializedReply & { readonly cookieLines?: readonly SerializedCookie[] }
125
128
 
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
129
  const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
130
+
133
131
  const MAX_PATH_IN_MESSAGE = 200
134
132
 
133
+ /** The default request id generator: a random UUID (version 4, like `crypto.randomUUID()`). */
134
+ export function randomRequestId(): string {
135
+ return uuid()
136
+ }
137
+
138
+ /** Random bytes of the next UUIDs (Node.js), refilled every {@link UUID_BATCH} ids. */
139
+ const UUID_BATCH = 128
140
+ const uuidPool = new Uint8Array(16 * UUID_BATCH)
141
+ let uuidOffset = uuidPool.length
142
+ /** The text of the last UUID, its dashes in place. */
143
+ const uuidText = Buffer.alloc(36, 0x2d)
144
+ /** The two lower-case hexadecimal digits of each byte, as character codes. */
145
+ const HEX_CODES = new Uint8Array(512)
146
+ for (let byte = 0; byte < 256; byte++) {
147
+ const digits = byte.toString(16).padStart(2, '0')
148
+ HEX_CODES[byte * 2] = digits.charCodeAt(0)
149
+ HEX_CODES[byte * 2 + 1] = digits.charCodeAt(1)
150
+ }
151
+
152
+ function putHex(position: number, byte: number): void {
153
+ uuidText[position] = HEX_CODES[byte * 2] as number
154
+ uuidText[position + 1] = HEX_CODES[byte * 2 + 1] as number
155
+ }
156
+
157
+ /**
158
+ * A random version 4 UUID written from a pool of random bytes (`crypto.getRandomValues`), as Node.js
159
+ * does, but read as one flat string: `crypto.randomUUID()` returns a concatenation of 20 pieces that
160
+ * is flattened again when the header is written, which makes it about twice as slow.
161
+ */
162
+ function pooledUuid(): string {
163
+ if (uuidOffset === uuidPool.length) {
164
+ crypto.getRandomValues(uuidPool)
165
+ uuidOffset = 0
166
+ }
167
+ const pool = uuidPool
168
+ const at = uuidOffset
169
+ uuidOffset = at + 16
170
+ putHex(0, pool[at] as number)
171
+ putHex(2, pool[at + 1] as number)
172
+ putHex(4, pool[at + 2] as number)
173
+ putHex(6, pool[at + 3] as number)
174
+ putHex(9, pool[at + 4] as number)
175
+ putHex(11, pool[at + 5] as number)
176
+ putHex(14, ((pool[at + 6] as number) & 0x0f) | 0x40)
177
+ putHex(16, pool[at + 7] as number)
178
+ putHex(19, ((pool[at + 8] as number) & 0x3f) | 0x80)
179
+ putHex(21, pool[at + 9] as number)
180
+ putHex(24, pool[at + 10] as number)
181
+ putHex(26, pool[at + 11] as number)
182
+ putHex(28, pool[at + 12] as number)
183
+ putHex(30, pool[at + 13] as number)
184
+ putHex(32, pool[at + 14] as number)
185
+ putHex(34, pool[at + 15] as number)
186
+ return readUuidText()
187
+ }
188
+
189
+ /**
190
+ * The text of {@link uuidText}: Node.js `Buffer#latin1Slice` (what `toString('latin1')` calls after
191
+ * checking its arguments) when present.
192
+ */
193
+ const readUuidText: () => string =
194
+ typeof (uuidText as { latin1Slice?: unknown }).latin1Slice === 'function'
195
+ ? () => (uuidText as unknown as { latin1Slice(start: number, end: number): string }).latin1Slice(0, 36)
196
+ : () => uuidText.toString('latin1', 0, 36)
197
+
198
+ /** UUID generator: Bun's native `crypto.randomUUID()` is the fastest there. */
199
+ const uuid: () => string = isBun() ? () => crypto.randomUUID() : pooledUuid
200
+
135
201
  /**
136
202
  * The request pipeline (architecture §9.1):
137
203
  * `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
@@ -143,6 +209,26 @@ export class Pipeline {
143
209
  readonly #logger: CoreLogger
144
210
  readonly #options: PipelineOptions
145
211
  readonly #table: RouteTable<RoutePlan>
212
+ /** The `securityHeaders` option as entries (lower-case names, values validated at boot). */
213
+ readonly #securityHeaders: readonly (readonly [string, string])[]
214
+ /** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
215
+ readonly #validIds: boolean
216
+ /**
217
+ * The headers of a JSON reply of the fast lane, in their final order (content type and length,
218
+ * then the framework headers, see {@link #frameworkHeaders}): copied, then completed with the
219
+ * length and the request id.
220
+ */
221
+ readonly #jsonHeaders: Readonly<Record<string, HeaderValue>>
222
+ /** The request id header of {@link #jsonHeaders}, `undefined` when the reply has none. */
223
+ readonly #jsonIdHeader: string | undefined
224
+ /** The headers of a reply without body of the fast lane (the framework headers, see {@link #jsonHeaders}). */
225
+ readonly #emptyHeaders: Readonly<Record<string, HeaderValue>>
226
+ /** The request id header of {@link #emptyHeaders}, `undefined` when the reply has none. */
227
+ readonly #emptyIdHeader: string | undefined
228
+ /** Whether {@link #jsonHeaders} and {@link #emptyHeaders} hold every framework header (see {@link #fastRoute}). */
229
+ readonly #fastHeaders: boolean
230
+ /** The `cookies` option (secure defaults, no signing, when not set). */
231
+ readonly #cookies: CookieSettings
146
232
  #inflight = 0
147
233
  #idle: (() => void)[] = []
148
234
  /** Open event streams. */
@@ -162,6 +248,29 @@ export class Pipeline {
162
248
  this.#logger = logger
163
249
  this.#options = options
164
250
  this.#table = table
251
+ this.#validIds = options.generateRequestId === randomRequestId
252
+ this.#cookies = options.cookies ?? DEFAULT_COOKIE_SETTINGS
253
+ this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(
254
+ ([name, value]) => [name.toLowerCase(), value] as const,
255
+ )
256
+ // what #complete adds to the headers of serializeResult for a JSON value or no value (no CORS,
257
+ // not draining), with a placeholder where the request id goes
258
+ const placeholder = '-' as RequestId
259
+ const idHeader = options.requestIdHeader
260
+ // plain objects: a spread copies them several times faster than a record without prototype
261
+ const json: Record<string, HeaderValue> = { 'content-type': ContentType.json, 'content-length': '0' }
262
+ this.#frameworkHeaders(json, placeholder, undefined, false)
263
+ this.#jsonHeaders = json
264
+ this.#jsonIdHeader = idHeader !== undefined && json[idHeader] === placeholder ? idHeader : undefined
265
+ const empty: Record<string, HeaderValue> = {}
266
+ this.#frameworkHeaders(empty, placeholder, undefined, false)
267
+ this.#emptyHeaders = empty
268
+ this.#emptyIdHeader = idHeader !== undefined && empty[idHeader] === placeholder ? idHeader : undefined
269
+ // a header named like a property of Object.prototype (`__proto__`, `constructor`...) is not an own
270
+ // property of these plain objects: such options take the general pipeline
271
+ this.#fastHeaders =
272
+ (idHeader === undefined || !(idHeader in Object.prototype)) &&
273
+ this.#securityHeaders.every(([name]) => !(name in Object.prototype))
165
274
  }
166
275
 
167
276
  /** Number of requests being processed. */
@@ -188,34 +297,266 @@ export class Pipeline {
188
297
  return new Promise(resolve => this.#idle.push(resolve))
189
298
  }
190
299
 
300
+ /**
301
+ * The adapter handler of a route: {@link handle}, or a fast lane for the routes that need no
302
+ * guard, interceptor, event stream, raw response nor `@Header` when CORS is off (see
303
+ * {@link #fast}).
304
+ */
305
+ handler(plan: RoutePlan): AdapterHandler {
306
+ const route = this.#fastRoute(plan)
307
+ if (route === undefined) return request => this.handle(plan, request)
308
+ return request => this.#fast(route, request)
309
+ }
310
+
311
+ /** The fast lane of a route, `undefined` when the route needs the general pipeline. */
312
+ #fastRoute(plan: RoutePlan): FastRoute | undefined {
313
+ const definition = plan.definition
314
+ if (
315
+ !this.#fastHeaders ||
316
+ this.#options.cors !== undefined ||
317
+ plan.redirect === true ||
318
+ plan.guards.length > 0 ||
319
+ plan.interceptors.length > 0 ||
320
+ definition.sse !== undefined ||
321
+ definition.rawResponse === true ||
322
+ definition.headers !== undefined ||
323
+ definition.method === 'ALL'
324
+ ) {
325
+ return undefined
326
+ }
327
+ // the statuses of serializeResult: `@HttpCode` > 201 for POST routes > 200 (JSON) or 204 (none)
328
+ const post = definition.method === 'POST'
329
+ const json = definition.status ?? (post ? 201 : 200)
330
+ return {
331
+ plan,
332
+ target: { kind: 'route', plan },
333
+ method: definition.method,
334
+ // a HEAD reply has no body: JSON results of an explicit `@Head` route take the general pipeline
335
+ jsonStatus: definition.method === 'HEAD' || isBodylessStatus(json) ? undefined : json,
336
+ emptyStatus: definition.status ?? (post ? 201 : 204),
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The fast lane: what {@link handle} does for a route of {@link #fastRoute}, decided once instead
342
+ * of per request, without promises while the route stays synchronous. A request takes it when no
343
+ * `http:request` wrapper nor listener is registered, the server is not draining and its method
344
+ * is the route's (not a `HEAD` served by a `GET` route); a plain object or array returned by the
345
+ * handler is serialised as JSON, and `undefined` answered without body, with the headers in their
346
+ * final order (a template precomputed at boot).
347
+ * A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
348
+ * path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
349
+ * or any other result, headers or cookies set through the context — continues in the general
350
+ * pipeline, in the same request scope, with the same outcome.
351
+ */
352
+ #fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
353
+ const plan = route.plan
354
+ const hooks = this.#hooks
355
+ if (
356
+ this.draining ||
357
+ request.method !== route.method ||
358
+ hooks.hasWrappers('http:request') ||
359
+ hooks.hasListeners('http:request')
360
+ ) {
361
+ return this.handle(plan, request)
362
+ }
363
+ const url = request.url
364
+ const mark = url.indexOf('?')
365
+ const path = mark === -1 ? url : url.slice(0, mark)
366
+ const params = routeParams(plan.path, path)
367
+ // an unusual path (see #route): nothing started yet, the general pipeline handles it
368
+ if (params === undefined) return this.handle(plan, request)
369
+ this.#enter()
370
+ let ctx: RequestContext
371
+ try {
372
+ const query = mark === -1 ? '' : url.slice(mark + 1)
373
+ ctx = this.#context(request, this.#requestId(request), path, query, plan.info, params, plan.body)
374
+ } catch (error) {
375
+ this.#leave()
376
+ return Promise.reject(error)
377
+ }
378
+ const scope = this.#container.openScope([
379
+ [HttpTokens.Context, ctx],
380
+ [HttpTokens.RequestId, ctx.requestId],
381
+ ])
382
+ const done = scope.run(this.#fastRun, route, request, ctx)
383
+ if (done instanceof Exchange) {
384
+ ;(done.done as Promise<void>).then(
385
+ () => this.#end(done, scope, undefined),
386
+ (error: unknown) => this.#end(done, scope, error),
387
+ )
388
+ if (!hooks.hasListeners('http:request')) return done.replied
389
+ done.tracked = true
390
+ return this.#emitted(done)
391
+ }
392
+ const closing = scope.close()
393
+ if (closing === undefined) {
394
+ ctx.complete()
395
+ this.#leave()
396
+ } else {
397
+ const x = new Exchange(request, ctx, ctx.requestId, route.target)
398
+ x.answer(done)
399
+ closing.then(
400
+ () => this.#over(x),
401
+ (error: unknown) => this.#disposalFailed(x, error),
402
+ )
403
+ }
404
+ // a listener registered while the handler ran (listeners are checked once the scope callback paused)
405
+ if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
406
+ const event = requestEvent(request, ctx, performance.now())
407
+ event.status = done.status
408
+ return Promise.resolve(done).then(reply => {
409
+ hooks.emit('http:request', event)
410
+ return reply
411
+ })
412
+ }
413
+
414
+ /**
415
+ * The fast lane in the request scope: the reply when the handler answered synchronously with a
416
+ * plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
417
+ */
418
+ readonly #fastRun = (route: FastRoute, request: AdapterRequest, ctx: RequestContext): AdapterReply | Exchange => {
419
+ const plan = route.plan
420
+ if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT)
421
+ let result: unknown
422
+ let reply: AdapterReply | undefined
423
+ try {
424
+ result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
425
+ if (isThenable(result)) return this.#fastLater(route, request, ctx, result)
426
+ reply = this.#fastReply(route, ctx, result)
427
+ } catch (error) {
428
+ const x = new Exchange(request, ctx, ctx.requestId, route.target)
429
+ x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
430
+ return x
431
+ }
432
+ if (reply !== undefined) {
433
+ ctx.commit()
434
+ ctx.releaseBody()
435
+ return reply
436
+ }
437
+ const x = new Exchange(request, ctx, ctx.requestId, route.target)
438
+ x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
439
+ return x
440
+ }
441
+
442
+ /** The fast lane of a route with a body to read or a handler that returned a thenable. */
443
+ #fastLater(route: FastRoute, request: AdapterRequest, ctx: RequestContext, result: unknown): Exchange {
444
+ const x = new Exchange(request, ctx, ctx.requestId, route.target)
445
+ x.done = this.#fastAsync(x, route, result)
446
+ return x
447
+ }
448
+
449
+ /**
450
+ * The asynchronous part of the fast lane, in the request scope: the body read (`result` is
451
+ * {@link NO_RESULT}) and the handler run, or the thenable it returned awaited. Never rejects.
452
+ */
453
+ async #fastAsync(x: Exchange, route: FastRoute, pending: unknown): Promise<void> {
454
+ const ctx = x.ctx
455
+ const plan = route.plan
456
+ let result = pending
457
+ let reply: AdapterReply | undefined
458
+ try {
459
+ if (result === NO_RESULT) {
460
+ const controller = this.#resolve(plan.controller)
461
+ await ctx.parseBody()
462
+ result = plan.definition.invoke(controller, ctx)
463
+ }
464
+ if (isThenable(result)) result = await result
465
+ reply = this.#fastReply(route, ctx, result)
466
+ } catch (error) {
467
+ await this.#respond(x, () => this.#failed(error, ctx, x.event))
468
+ return
469
+ }
470
+ if (reply === undefined) {
471
+ const value = result
472
+ await this.#respond(x, () => this.#settle(ctx, plan, x.event, value))
473
+ return
474
+ }
475
+ ctx.commit()
476
+ ctx.releaseBody()
477
+ // a listener registered since the request started gets a complete event
478
+ if (x.tracked) x.event.status = reply.status
479
+ x.answer(reply)
480
+ }
481
+
482
+ /**
483
+ * The reply of a plain JSON value (see {@link isPlainJson}) or of `undefined`: what
484
+ * `serializeResult` and {@link #complete} produce for it, the headers in their final order.
485
+ * `undefined` when the result or the context needs the general pipeline. Throws like
486
+ * `JSON.stringify`.
487
+ */
488
+ #fastReply(route: FastRoute, ctx: RequestContext, result: unknown): AdapterReply | undefined {
489
+ const status = result === undefined ? route.emptyStatus : route.jsonStatus
490
+ // draining since the request started (`connection: close`, see #frameworkHeaders)
491
+ if (status === undefined || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return undefined
492
+ const requestId = ctx.requestId
493
+ // a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
494
+ if (!this.#validIds && !isHeaderValue(requestId)) return undefined
495
+ if (result === undefined) {
496
+ const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
497
+ const idHeader = this.#emptyIdHeader
498
+ if (idHeader !== undefined) headers[idHeader] = requestId
499
+ return { status, headers, body: null }
500
+ }
501
+ if (!isPlainJson(result)) return undefined
502
+ const json = JSON.stringify(result)
503
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
504
+ const headers: Record<string, HeaderValue> = { ...this.#jsonHeaders }
505
+ // encoded once, its length from the bytes (see serialize.ts encodeText)
506
+ const body = utf8(json)
507
+ headers['content-length'] = String(body.byteLength)
508
+ const idHeader = this.#jsonIdHeader
509
+ if (idHeader !== undefined) headers[idHeader] = requestId
510
+ return { status, headers, body, text: json }
511
+ }
512
+
513
+ /** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
514
+ #end(x: Exchange, scope: OpenedScope, error: unknown): void {
515
+ const closing = scope.close()
516
+ if (error !== undefined) {
517
+ // like runInScope: the scope closes, then the failure is reported (#respond never rejects)
518
+ const report = (): void => this.#disposalFailed(x, error)
519
+ if (closing === undefined) report()
520
+ else closing.then(report, report)
521
+ return
522
+ }
523
+ if (closing === undefined) this.#over(x)
524
+ else
525
+ closing.then(
526
+ () => this.#over(x),
527
+ (failure: unknown) => this.#disposalFailed(x, failure),
528
+ )
529
+ }
530
+
531
+ #disposalFailed(x: Exchange, error: unknown): void {
532
+ this.#over(x)
533
+ this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
534
+ }
535
+
191
536
  /**
192
537
  * Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
193
538
  * open) until a streamed or file response body has been fully read or cancelled by the adapter.
194
539
  */
195
- async handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
540
+ handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
196
541
  const startTime = performance.now()
197
- this.#enter()
198
- let served: Served | undefined
199
542
  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)
543
+ return this.#isPreflight(request) ? this.#preflight(request, startTime) : this.#route(plan, request, startTime)
544
+ } catch (error) {
545
+ return Promise.reject(error)
206
546
  }
207
547
  }
208
548
 
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)
549
+ #route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<AdapterReply> {
550
+ const url = splitUrl(request.url)
551
+ const raw = matchRoutePath(plan.path, url.path)
212
552
  // the native router matched a path the Znight grammar rejects (e.g. an empty `:param` in Fastify)
213
553
  // or a normalised path (Hono): route the raw path with the route table instead
214
554
  if (raw === undefined) return this.#fallback(request, undefined, startTime, !rerouted)
215
555
  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)
556
+ const canonical = sameOriginPath(url.path.slice(0, -1))
557
+ const location = url.query === '' ? canonical : `${canonical}?${url.query}`
558
+ const target: Target = { kind: 'redirect', location }
559
+ return this.#serve(request, url, undefined, emptyRecord(), target, startTime, plan.body)
219
560
  }
220
561
  const params = emptyRecord<string>()
221
562
  let malformed: string | undefined
@@ -228,30 +569,28 @@ export class Pipeline {
228
569
  malformed === undefined
229
570
  ? { kind: 'route', plan }
230
571
  : { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
231
- return this.#serve(request, plan.info, params, target, startTime, plan.body)
572
+ return this.#serve(request, url, plan.info, params, target, startTime, plan.body)
232
573
  }
233
574
 
234
575
  /** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
235
- async fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
576
+ fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
236
577
  const startTime = performance.now()
237
- this.#enter()
238
- let served: Served | undefined
239
578
  try {
240
- served = await this.#fallback(request, error, startTime)
241
- return served.reply
242
- } finally {
243
- this.#leaveWhen(served)
579
+ return this.#fallback(request, error, startTime)
580
+ } catch (failure) {
581
+ return Promise.reject(failure)
244
582
  }
245
583
  }
246
584
 
247
- #fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<Served> {
585
+ #fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<AdapterReply> {
248
586
  const body = this.#options.body
249
587
  if (error !== undefined) {
250
588
  const target: Target = { kind: 'error', error: frameworkError(error) }
251
- return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
589
+ return this.#serve(request, splitUrl(request.url), undefined, emptyRecord(), target, startTime, body)
252
590
  }
253
591
  if (this.#isPreflight(request)) return this.#preflight(request, startTime)
254
- const { path } = splitUrl(request.url)
592
+ const url = splitUrl(request.url)
593
+ const path = url.path
255
594
  if (reroute) {
256
595
  // routing happens on the raw path: serve a route the adapter's router missed (Hono routes the
257
596
  // WHATWG-normalised URL, where `/files/%2e%2e/x` became `/x`)
@@ -277,7 +616,7 @@ export class Pipeline {
277
616
  ),
278
617
  }
279
618
  }
280
- return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
619
+ return this.#serve(request, url, undefined, emptyRecord(), target, startTime, body)
281
620
  }
282
621
 
283
622
  /** A CORS preflight (with the `cors` option): answered without running any route. */
@@ -285,156 +624,206 @@ export class Pipeline {
285
624
  return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
286
625
  }
287
626
 
288
- #preflight(request: AdapterRequest, startTime: number): Promise<Served> {
289
- const allowed = this.#table.allowedMethods(splitUrl(request.url).path)
627
+ #preflight(request: AdapterRequest, startTime: number): Promise<AdapterReply> {
628
+ const url = splitUrl(request.url)
629
+ const allowed = this.#table.allowedMethods(url.path)
290
630
  const target: Target = { kind: 'preflight', allow: allowed.length === 0 ? undefined : allowHeader(allowed) }
291
- return this.#serve(request, undefined, emptyRecord(), target, startTime, this.#options.body)
631
+ return this.#serve(request, url, undefined, emptyRecord(), target, startTime, this.#options.body)
292
632
  }
293
633
 
294
- async #serve(
634
+ #serve(
295
635
  request: AdapterRequest,
636
+ url: RequestTarget,
296
637
  route: HttpRouteInfo | undefined,
297
638
  params: Record<string, string>,
298
639
  target: Target,
299
640
  startTime: number,
300
641
  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: {},
642
+ ): Promise<AdapterReply> {
643
+ this.#enter()
644
+ let x: Exchange
645
+ try {
646
+ const requestId = this.#requestId(request)
647
+ const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
648
+ // an event stream requested while draining is served (guards run) with its signal already
649
+ // aborted: a well-behaved source ends at once and the client gets a jittered retry
650
+ if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
651
+ x = new Exchange(request, ctx, requestId, target, startTime)
652
+ } catch (error) {
653
+ // failed before its scope opened (a request id generator that throws...)
654
+ this.#leave()
655
+ return Promise.reject(error)
327
656
  }
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
657
  // 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
- ])
658
+ this.#container
659
+ .runInScope(
660
+ () => this.#respond(x, undefined),
661
+ [
662
+ [HttpTokens.Context, x.ctx],
663
+ [HttpTokens.RequestId, x.requestId],
664
+ ],
665
+ )
402
666
  .then(
403
- () => {
404
- ctx.complete()
405
- },
667
+ () => this.#over(x),
406
668
  (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 })
669
+ this.#over(x)
670
+ // #respond never rejects: only the disposal of request-scoped instances can fail
671
+ this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
410
672
  },
411
673
  )
412
- await Promise.race([ready, done])
674
+ // listeners run once the reply is ready, before the adapter gets it, out of the request scope
675
+ return this.#hooks.hasListeners('http:request') ? this.#emitted(x) : x.replied
676
+ }
677
+
678
+ /** The reply of a request once the `http:request` listeners saw its event. */
679
+ #emitted(x: Exchange): Promise<AdapterReply> {
680
+ return x.replied.then(reply => {
681
+ this.#hooks.emit('http:request', x.event)
682
+ return reply
683
+ })
684
+ }
685
+
686
+ /** The request context of a request (internal). */
687
+ #context(
688
+ request: AdapterRequest,
689
+ requestId: RequestId,
690
+ path: string,
691
+ query: string,
692
+ route: HttpRouteInfo | undefined,
693
+ params: Record<string, string>,
694
+ body: BodyPlan,
695
+ ): RequestContext {
696
+ const proxy = this.#options.trustProxy
697
+ return new RequestContext(
698
+ request,
699
+ requestId,
700
+ this.#options.adapter,
701
+ path,
702
+ query,
703
+ route,
704
+ params,
705
+ body,
706
+ this.#cookies,
707
+ proxy === undefined
708
+ ? undefined
709
+ : proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
710
+ )
711
+ }
712
+
713
+ /**
714
+ * Answers a request in its scope: its outcome (`process`, or the target of the exchange after the
715
+ * CORS decision and through the `http:request` wrappers), checked, completed and handed to the
716
+ * adapter; a streamed body is then tracked until it was sent or abandoned. Never rejects.
717
+ * `process` is given by the fast lane, which decided at the start of the request that no wrapper
718
+ * applies and that CORS is off.
719
+ */
720
+ async #respond(x: Exchange, process: (() => Outcome | Promise<Outcome>) | undefined): Promise<void> {
721
+ const ctx = x.ctx
722
+ const requestId = x.requestId
723
+ const event = x.event
724
+ let outcome: Outcome | undefined
725
+ let hookError: unknown
726
+ let failed = false
727
+ let run: ContextRunner | undefined
728
+ // only a wrapper can change a reply between its checks below and the adapter
729
+ let recheck = false
730
+ let work = process
731
+ if (work === undefined) {
732
+ if (this.#options.cors !== undefined) x.cors = await this.#corsDecision(x.request, requestId)
733
+ recheck = this.#hooks.hasWrappers('http:request')
734
+ const target = x.target
735
+ work = () => this.#process(ctx, target, event, x.cors)
736
+ }
737
+ try {
738
+ if (recheck) {
739
+ const inner = work
740
+ await this.#hooks.run('http:request', event, async () => {
741
+ outcome = this.#checked(await inner(), ctx, event)
742
+ // stream producers run in the context of the handler (scope, wrappers)
743
+ if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext()
744
+ return outcome
745
+ })
746
+ } else {
747
+ // what hooks.run() does without wrappers, in the same async context
748
+ outcome = this.#checked(await work(), ctx, event)
749
+ }
750
+ } catch (error) {
751
+ failed = true
752
+ hookError = error
753
+ }
754
+ if (outcome === undefined) {
755
+ // a wrapper failed before the request was handled, or did not call next(): answer anyway
756
+ const error = failed ? hookError : new Error('An "http:request" hook wrapper did not call next()')
757
+ event.error ??= error
758
+ // the same checks as a handled reply: an exception mapper's HttpResponse may carry cookies
759
+ const fallback = await this.#errorReply(error, ctx)
760
+ outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
761
+ event.status = outcome.status
762
+ recheck = true
763
+ } else if (failed) {
764
+ this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
765
+ err: hookError,
766
+ requestId,
767
+ })
768
+ }
413
769
  if (outcome === RAW) {
414
- const status = request.rawStatus?.() ?? 200
770
+ ctx.commit()
771
+ const status = x.request.rawStatus?.() ?? 200
415
772
  event.status ??= status
416
- this.#emit(event)
417
- return { reply: { status, headers: {}, body: null, raw: true }, done }
773
+ x.answer({ status, headers: {}, body: null, raw: true })
774
+ return
418
775
  }
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
776
+ const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck)
777
+ const body = serialized.body
778
+ if (!isStreamed(body)) {
779
+ const reply = adapterReply(serialized, body)
780
+ ctx.commit()
781
+ ctx.releaseBody()
782
+ x.answer(reply)
783
+ return
784
+ }
785
+ // files too: a file-backed Blob is read lazily, after the handler returned
786
+ const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
787
+ const session = serialized.sse
788
+ const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
789
+ if (session !== undefined) {
790
+ this.#streams.add(session)
791
+ if (this.draining) session.close('shutdown')
792
+ }
793
+ ctx.commit()
794
+ x.answer(reply)
795
+ const failure = await tracked.done
796
+ if (session !== undefined) this.#streams.delete(session)
797
+ if (failure !== undefined) {
798
+ this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
799
+ }
800
+ ctx.releaseBody()
801
+ }
802
+
803
+ /** The request is over (response body sent or abandoned, scope closed). */
804
+ #over(x: Exchange): void {
805
+ const ctx = x.ctx
806
+ ctx.complete()
807
+ try {
808
+ if (!x.answered) {
809
+ const failure = new Error('Request not handled')
810
+ const reply = serializeError(500, this.#internalBody(failure), ctx.method)
811
+ const completed = this.#complete(reply, ctx, x.event, x.requestId, x.cors, x.preflight, true)
812
+ x.event.status = 500
813
+ x.answer(completed)
814
+ }
815
+ } catch (error) {
816
+ x.fail(error)
817
+ } finally {
818
+ this.#leave()
430
819
  }
431
- this.#emit(event)
432
- return { reply, done }
433
820
  }
434
821
 
435
822
  /**
436
823
  * Adds the headers of the `http:request` wrappers, then the framework headers (see
437
- * {@link #frameworkHeaders}), then checks the final headers (see {@link #checkHeaders}).
824
+ * {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
825
+ * {@link #checkHeaders}): the reply was checked before, and the cookies and framework headers are
826
+ * valid by construction.
438
827
  */
439
828
  #complete(
440
829
  reply: CheckedReply,
@@ -443,10 +832,12 @@ export class Pipeline {
443
832
  requestId: RequestId,
444
833
  cors: CorsDecision | undefined,
445
834
  preflight: boolean,
835
+ recheck: boolean,
446
836
  ): SerializedReply {
447
837
  addMissingHeaders(reply.headers, event.responseHeaders)
448
838
  applyCookies(reply, ctx)
449
839
  this.#frameworkHeaders(reply.headers, requestId, cors, preflight)
840
+ if (!recheck) return reply
450
841
  const checked = this.#checkHeaders(reply, ctx, event)
451
842
  if (checked !== reply) {
452
843
  applyCookies(checked, ctx)
@@ -455,6 +846,13 @@ export class Pipeline {
455
846
  return checked
456
847
  }
457
848
 
849
+ /** Checks the headers (unless only the serialiser set them), then the cookies of a reply. */
850
+ #checked(outcome: Outcome, ctx: RequestContext, event: HttpRequestEvent): Outcome {
851
+ if (outcome === RAW) return outcome
852
+ const checked = outcome.ownHeaders === true ? outcome : this.#checkHeaders(outcome, ctx, event)
853
+ return this.#checkCookies(checked, ctx, event)
854
+ }
855
+
458
856
  /**
459
857
  * Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
460
858
  * signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
@@ -464,7 +862,7 @@ export class Pipeline {
464
862
  #checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
465
863
  const cookies = reply.cookies
466
864
  if (cookies === undefined || cookies.length === 0) return reply
467
- const settings = this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS
865
+ const settings = this.#cookies
468
866
  const lines: SerializedCookie[] = []
469
867
  try {
470
868
  for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
@@ -492,7 +890,9 @@ export class Pipeline {
492
890
  preflight: boolean,
493
891
  ): void {
494
892
  const header = this.#options.requestIdHeader
495
- if (header !== undefined && headers[header] === undefined && isHeaderValue(requestId)) headers[header] = requestId
893
+ if (header !== undefined && headers[header] === undefined && (this.#validIds || isHeaderValue(requestId))) {
894
+ headers[header] = requestId
895
+ }
496
896
  const policy = this.#options.cors
497
897
  if (policy !== undefined) {
498
898
  // a preflight answer carries its own CORS headers; other replies get them for allowed origins,
@@ -503,8 +903,7 @@ export class Pipeline {
503
903
  }
504
904
  if (policy.varyOrigin) mergeVary(headers, 'origin')
505
905
  }
506
- const security = this.#options.securityHeaders
507
- if (security !== undefined) addMissingHeaders(headers, security)
906
+ for (const [name, value] of this.#securityHeaders) if (headers[name] === undefined) headers[name] = value
508
907
  if (this.draining) headers['connection'] = 'close'
509
908
  }
510
909
 
@@ -542,12 +941,23 @@ export class Pipeline {
542
941
  return serializeError(500, this.#internalBody(error), ctx.method)
543
942
  }
544
943
 
545
- async #process(
944
+ /** The outcome of a request: its route run, or a reply of the framework. Never rejects (errors are mapped). */
945
+ #process(
546
946
  ctx: RequestContext,
547
947
  target: Target,
548
948
  event: HttpRequestEvent,
549
949
  cors: CorsDecision | undefined,
550
950
  ): Promise<Outcome> {
951
+ return target.kind === 'route' ? this.#invoke(ctx, target.plan, event) : this.#answer(ctx, target, event, cors)
952
+ }
953
+
954
+ /** Answers a request without running a route. Never rejects. */
955
+ async #answer(
956
+ ctx: RequestContext,
957
+ target: Exclude<Target, { readonly kind: 'route' }>,
958
+ event: HttpRequestEvent,
959
+ cors: CorsDecision | undefined,
960
+ ): Promise<SerializedReply> {
551
961
  try {
552
962
  switch (target.kind) {
553
963
  case 'error':
@@ -577,37 +987,86 @@ export class Pipeline {
577
987
  headers: { location: target.location },
578
988
  }),
579
989
  )
580
- default:
581
- return await this.#invoke(ctx, target.plan, event)
582
990
  }
583
991
  } catch (error) {
584
- event.error = error
585
- const reply = await this.#errorReply(error, ctx)
586
- event.status = reply.status
587
- return reply
992
+ return await this.#failed(error, ctx, event)
588
993
  }
589
994
  }
590
995
 
996
+ /** The reply of an error (see {@link #errorReply}), recorded on the event. */
997
+ async #failed(error: unknown, ctx: RequestContext, event: HttpRequestEvent): Promise<SerializedReply> {
998
+ event.error = error
999
+ const reply = await this.#errorReply(error, ctx)
1000
+ event.status = reply.status
1001
+ return reply
1002
+ }
1003
+
1004
+ /** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
591
1005
  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
1006
  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))
1007
+ try {
1008
+ const controller = this.#resolve(plan.controller)
1009
+ for (const guard of plan.guards) {
1010
+ const allowed = await this.#resolve(guard).canActivate(ctx)
1011
+ if (allowed !== true) throw new ForbiddenException('Forbidden resource')
608
1012
  }
609
- result = await chain(0)
1013
+ if (plan.parseBody) await ctx.parseBody()
1014
+ const definition = plan.definition
1015
+ const interceptors = plan.interceptors
1016
+ if (interceptors.length === 0) {
1017
+ result = definition.invoke(controller, ctx)
1018
+ } else {
1019
+ const chain = async (index: number): Promise<unknown> => {
1020
+ if (index === interceptors.length) return await definition.invoke(controller, ctx)
1021
+ const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
1022
+ return await interceptor.intercept(ctx, () => chain(index + 1))
1023
+ }
1024
+ result = chain(0)
1025
+ }
1026
+ } catch (error) {
1027
+ return await this.#failed(error, ctx, event)
610
1028
  }
1029
+ const settled = this.#settle(ctx, plan, event, result)
1030
+ return settled instanceof Promise ? await settled : settled
1031
+ }
1032
+
1033
+ /**
1034
+ * The rest of {@link #invoke} once the handler (and its interceptors) returned `result`: awaited
1035
+ * when it is a thenable, then serialised. Never throws nor rejects (errors are mapped).
1036
+ */
1037
+ #settle(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
1038
+ if (isThenable(result)) return this.#settleLater(ctx, plan, event, result)
1039
+ try {
1040
+ const outcome = this.#outcome(ctx, plan, event, result)
1041
+ return outcome instanceof Promise ? outcome.catch(error => this.#failed(error, ctx, event)) : outcome
1042
+ } catch (error) {
1043
+ return this.#failed(error, ctx, event)
1044
+ }
1045
+ }
1046
+
1047
+ async #settleLater(
1048
+ ctx: RequestContext,
1049
+ plan: RoutePlan,
1050
+ event: HttpRequestEvent,
1051
+ pending: PromiseLike<unknown>,
1052
+ ): Promise<Outcome> {
1053
+ let result: unknown
1054
+ try {
1055
+ result = await pending
1056
+ } catch (error) {
1057
+ return await this.#failed(error, ctx, event)
1058
+ }
1059
+ const settled = this.#settle(ctx, plan, event, result)
1060
+ return settled instanceof Promise ? await settled : settled
1061
+ }
1062
+
1063
+ /**
1064
+ * The outcome of a handler result: `RAW` for a `@Res()` route, otherwise its reply (files resolved,
1065
+ * event streams opened: only an `HttpResponse` may need an asynchronous step). Throws (or rejects)
1066
+ * when the result cannot be served.
1067
+ */
1068
+ #outcome(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
1069
+ const definition = plan.definition
611
1070
  if (definition.rawResponse === true && !(result instanceof Response)) return RAW
612
1071
  const options: SerializeOptions = {
613
1072
  method: ctx.method,
@@ -617,7 +1076,6 @@ export class Pipeline {
617
1076
  context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
618
1077
  }
619
1078
  const sse = definition.sse
620
- let reply: SerializedReply
621
1079
  if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
622
1080
  // an @Sse route: the (awaited) result is the event source
623
1081
  if (!isSseSource(result)) {
@@ -625,11 +1083,10 @@ export class Pipeline {
625
1083
  `@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
626
1084
  )
627
1085
  }
628
- reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
629
- } else {
630
- reply = await this.#serialize(result, ctx, options, sse)
1086
+ return this.#finish(event, serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options))
631
1087
  }
632
- return this.#finish(event, reply)
1088
+ if (!isHttpResponse(result)) return this.#finish(event, serializeResult(result, options))
1089
+ return this.#serialize(result, ctx, options, sse).then(reply => this.#finish(event, reply))
633
1090
  }
634
1091
 
635
1092
  /** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
@@ -790,20 +1247,10 @@ export class Pipeline {
790
1247
  return this.#options.generateRequestId() as RequestId
791
1248
  }
792
1249
 
793
- #emit(event: HttpRequestEvent): void {
794
- if (this.#hooks.hasListeners('http:request')) this.#hooks.emit('http:request', event)
795
- }
796
-
797
1250
  #enter(): void {
798
1251
  this.#inflight++
799
1252
  }
800
1253
 
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
1254
  #leave(): void {
808
1255
  this.#inflight--
809
1256
  if (this.#inflight === 0 && this.#idle.length > 0) {
@@ -814,6 +1261,151 @@ export class Pipeline {
814
1261
  }
815
1262
  }
816
1263
 
1264
+ /** A route served by the fast lane (see `Pipeline.handler`), decided at boot. */
1265
+ interface FastRoute {
1266
+ readonly plan: RoutePlan
1267
+ readonly target: Target
1268
+ /** The method of the route definition (requests of another method take the general pipeline). */
1269
+ readonly method: string
1270
+ /** The status of a JSON reply, `undefined` when it has no body (such replies take the general pipeline). */
1271
+ readonly jsonStatus: number | undefined
1272
+ /** The status of a reply without value (`undefined` returned). */
1273
+ readonly emptyStatus: number
1274
+ }
1275
+
1276
+ /** `#fastAsync` reads the body and runs the handler. */
1277
+ const NO_RESULT: unique symbol = Symbol('no result')
1278
+
1279
+ /** A request being answered (internal): its context, its target and the promise of its reply. */
1280
+ class Exchange {
1281
+ readonly request: AdapterRequest
1282
+ readonly ctx: RequestContext
1283
+ readonly requestId: RequestId
1284
+ readonly target: Target
1285
+ readonly preflight: boolean
1286
+ readonly replied: Promise<AdapterReply>
1287
+ cors: CorsDecision | undefined = undefined
1288
+ answered = false
1289
+ /** The `http:request` listeners will see the event (fast lane: keep its status). */
1290
+ tracked = false
1291
+ /** The asynchronous part of a fast-lane request (never rejects). */
1292
+ done: Promise<void> | undefined = undefined
1293
+ readonly #resolve: (reply: AdapterReply) => void
1294
+ readonly #reject: (error: unknown) => void
1295
+ #event: HttpRequestEvent | undefined
1296
+
1297
+ /** `startTime`: the event is created now (general pipeline); otherwise when first needed (fast lane). */
1298
+ constructor(request: AdapterRequest, ctx: RequestContext, requestId: RequestId, target: Target, startTime?: number) {
1299
+ this.request = request
1300
+ this.ctx = ctx
1301
+ this.requestId = requestId
1302
+ this.target = target
1303
+ this.preflight = target.kind === 'preflight'
1304
+ let resolve: ((reply: AdapterReply) => void) | undefined
1305
+ let reject: ((error: unknown) => void) | undefined
1306
+ this.replied = new Promise<AdapterReply>((onReply, onError) => {
1307
+ resolve = onReply
1308
+ reject = onError
1309
+ })
1310
+ this.#resolve = resolve as (reply: AdapterReply) => void
1311
+ this.#reject = reject as (error: unknown) => void
1312
+ if (startTime !== undefined) this.#event = requestEvent(request, ctx, startTime)
1313
+ }
1314
+
1315
+ /**
1316
+ * The `http:request` event. The fast lane creates it when the general pipeline takes over: no
1317
+ * hook saw the request start, so its `startTime` is not observed.
1318
+ */
1319
+ get event(): HttpRequestEvent {
1320
+ this.#event ??= requestEvent(this.request, this.ctx, performance.now())
1321
+ return this.#event
1322
+ }
1323
+
1324
+ answer(reply: AdapterReply): void {
1325
+ this.answered = true
1326
+ this.#resolve(reply)
1327
+ }
1328
+
1329
+ fail(error: unknown): void {
1330
+ this.#reject(error)
1331
+ }
1332
+ }
1333
+
1334
+ /** The `http:request` event of a request. */
1335
+ function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: number): HttpRequestEvent {
1336
+ return {
1337
+ method: request.method,
1338
+ path: ctx.path,
1339
+ route: ctx.route?.path,
1340
+ requestId: ctx.requestId,
1341
+ headers: request.headers,
1342
+ context: ctx,
1343
+ startTime,
1344
+ responseHeaders: {},
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * The decoded parameters of a raw request path matched against a route, in one pass: what
1350
+ * `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
1351
+ * `undefined` when the path does not match or a value is not valid percent-encoding: the general
1352
+ * pipeline then answers it (a reroute, a 400).
1353
+ */
1354
+ function routeParams(route: RoutePath, path: string): Record<string, string> | undefined {
1355
+ if (path.charCodeAt(0) !== 47 /* / */) return undefined
1356
+ const params = emptyRecord<string>()
1357
+ const segments = route.segments
1358
+ const last = segments.length - 1
1359
+ if (last === -1) return path === '/' ? params : undefined
1360
+ let position = 1
1361
+ for (let index = 0; index <= last; index++) {
1362
+ const segment = segments[index] as RouteSegment
1363
+ if (segment.kind === 'wildcard') {
1364
+ const rest = decodePathValue(path.slice(position))
1365
+ if (rest === undefined) return undefined
1366
+ params[WILDCARD_PARAM] = rest
1367
+ return params
1368
+ }
1369
+ const slash = path.indexOf('/', position)
1370
+ const end = slash === -1 ? path.length : slash
1371
+ if (segment.kind === 'static') {
1372
+ const value = segment.value
1373
+ if (end - position !== value.length || !path.startsWith(value, position)) return undefined
1374
+ } else {
1375
+ if (end === position) return undefined
1376
+ const decoded = decodePathValue(path.slice(position, end))
1377
+ if (decoded === undefined) return undefined
1378
+ params[segment.name] = decoded
1379
+ }
1380
+ if (index === last) return end === path.length ? params : undefined
1381
+ if (slash === -1) return undefined
1382
+ position = slash + 1
1383
+ }
1384
+ return params
1385
+ }
1386
+
1387
+ /**
1388
+ * A value the fast lane serialises as JSON: a plain object or array (its own prototype and
1389
+ * constructor), not duck-typed as a Node.js stream. Anything `serializeResult` could treat otherwise
1390
+ * (`HttpResponse`, `Response`, bytes, streams, `Blob`, strings, `undefined`...) is not.
1391
+ */
1392
+ function isPlainJson(value: unknown): value is object {
1393
+ if (value === null || typeof value !== 'object') return false
1394
+ const prototype: unknown = Object.getPrototypeOf(value)
1395
+ const ctor = (value as { constructor?: unknown }).constructor
1396
+ if (prototype === Object.prototype) {
1397
+ if (ctor !== Object) return false
1398
+ } else if (prototype !== Array.prototype || ctor !== Array) {
1399
+ return false
1400
+ }
1401
+ return typeof (value as { pipe?: unknown }).pipe !== 'function'
1402
+ }
1403
+
1404
+ const ENCODER = new TextEncoder()
1405
+
1406
+ /** UTF-8 encoding of a text body, like the serialiser (a pooled `Buffer` on Node.js, `TextEncoder` on Bun). */
1407
+ const utf8: (text: string) => Uint8Array = isBun() ? text => ENCODER.encode(text) : text => Buffer.from(text, 'utf8')
1408
+
817
1409
  /**
818
1410
  * The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
819
1411
  * `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a
@@ -821,6 +1413,8 @@ export class Pipeline {
821
1413
  * before a quiet stream's first byte, and lifts idle timeouts.
822
1414
  */
823
1415
  function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob): AdapterReply {
1416
+ const text = serialized.text
1417
+ if (text !== undefined) return { status: serialized.status, headers: serialized.headers, body, text }
824
1418
  const reply: AdapterReply = { status: serialized.status, headers: serialized.headers, body }
825
1419
  if (blob !== undefined) return { ...reply, blob }
826
1420
  const eventStream =
@@ -836,7 +1430,7 @@ function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob)
836
1430
  * one in place. One line per cookie.
837
1431
  */
838
1432
  function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
839
- const own = reply.cookieLines ?? []
1433
+ const own = reply.cookieLines ?? NO_COOKIE_LINES
840
1434
  const context = ctx.cookieLines
841
1435
  if (own.length === 0 && context.length === 0) return
842
1436
  const merged = new Map<string, string>()
@@ -849,6 +1443,13 @@ function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
849
1443
  reply.headers['set-cookie'] = [...raw, ...merged.values()]
850
1444
  }
851
1445
 
1446
+ const NO_COOKIE_LINES: readonly SerializedCookie[] = Object.freeze([])
1447
+
1448
+ /** Whether a reply body is streamed (a file is read through a stream too). */
1449
+ function isStreamed(body: ReplyBody): body is ReadableStream<Uint8Array> | Blob {
1450
+ return body instanceof ReadableStream || body instanceof Blob
1451
+ }
1452
+
852
1453
  /** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
853
1454
  function sseOptions(definition: HttpSseDefinition): SseOptions {
854
1455
  return {