@znight/http 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +154 -35
  2. package/dist/compiler.d.mts +77 -2
  3. package/dist/compiler.mjs +259 -101
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/express.mjs +3 -2
  6. package/dist/express.mjs.map +1 -1
  7. package/dist/fastify.mjs +200 -23
  8. package/dist/fastify.mjs.map +1 -1
  9. package/dist/hono.mjs +174 -43
  10. package/dist/hono.mjs.map +1 -1
  11. package/dist/index.d.mts +36 -4
  12. package/dist/index.mjs +1005 -257
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
  15. package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  16. package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
  17. package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  18. package/dist/node.mjs +1 -1
  19. package/dist/utils-DIWdzVht.mjs +46 -0
  20. package/dist/utils-DIWdzVht.mjs.map +1 -0
  21. package/package.json +4 -4
  22. package/src/adapters/express.ts +4 -1
  23. package/src/adapters/fastify.ts +265 -25
  24. package/src/adapters/hono.ts +225 -58
  25. package/src/adapters/node-io.ts +128 -58
  26. package/src/adapters/node.ts +4 -1
  27. package/src/body.ts +31 -2
  28. package/src/coerce.ts +31 -0
  29. package/src/compiler/analyze.ts +3 -3
  30. package/src/compiler/generate.ts +127 -22
  31. package/src/compiler/index.ts +13 -0
  32. package/src/compiler/route-decorators.ts +45 -2
  33. package/src/compiler/route-groups.ts +216 -0
  34. package/src/compiler/validate.ts +15 -126
  35. package/src/context.ts +148 -78
  36. package/src/decorators.ts +19 -3
  37. package/src/index.ts +2 -1
  38. package/src/pipeline.ts +936 -177
  39. package/src/plugin-routes.ts +19 -0
  40. package/src/plugin.ts +7 -3
  41. package/src/response.ts +6 -8
  42. package/src/serialize.ts +48 -21
  43. package/src/utils.ts +25 -2
  44. package/src/validate.ts +28 -12
  45. package/dist/utils-DCbu3a4w.mjs +0 -27
  46. 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 { decodePathValue, matchRoutePath, type RoutePath, type RouteSegment, WILDCARD_PARAM } from './path.ts'
30
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'
43
- import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
45
+ import { isBodylessStatus } from './status.ts'
46
+ import { type ContextRunner, captureContext, discardStream, type TrackedStream, trackStream } from './stream.ts'
44
47
  import { HttpTokens } from './tokens.ts'
45
- import { emptyRecord, errorMessage, isThenable } from './utils.ts'
48
+ import { emptyRecord, errorMessage, fastRecord, 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 }
@@ -127,11 +130,74 @@ const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
127
130
 
128
131
  const MAX_PATH_IN_MESSAGE = 200
129
132
 
130
- /** The default request id generator: a random UUID. */
133
+ /** The default request id generator: a random UUID (version 4, like `crypto.randomUUID()`). */
131
134
  export function randomRequestId(): string {
132
- return crypto.randomUUID()
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()
133
187
  }
134
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
@@ -147,6 +213,22 @@ export class Pipeline {
147
213
  readonly #securityHeaders: readonly (readonly [string, string])[]
148
214
  /** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
149
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
150
232
  #inflight = 0
151
233
  #idle: (() => void)[] = []
152
234
  /** Open event streams. */
@@ -167,9 +249,28 @@ export class Pipeline {
167
249
  this.#options = options
168
250
  this.#table = table
169
251
  this.#validIds = options.generateRequestId === randomRequestId
252
+ this.#cookies = options.cookies ?? DEFAULT_COOKIE_SETTINGS
170
253
  this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(
171
254
  ([name, value]) => [name.toLowerCase(), value] as const,
172
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))
173
274
  }
174
275
 
175
276
  /** Number of requests being processed. */
@@ -196,6 +297,411 @@ export class Pipeline {
196
297
  return new Promise(resolve => this.#idle.push(resolve))
197
298
  }
198
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 the
343
+ * server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
344
+ * plain object or array returned by the handler is serialised as JSON, and `undefined` answered
345
+ * without body, with the headers in their final order (a template precomputed at boot).
346
+ * A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
347
+ * path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
348
+ * or any other result, headers or cookies set through the context — continues in the general
349
+ * pipeline, in the same request scope, with the same outcome.
350
+ * With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
351
+ * get the event once the reply is ready, as in the general pipeline.
352
+ */
353
+ #fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
354
+ const plan = route.plan
355
+ if (this.draining || request.method !== route.method) return this.handle(plan, request)
356
+ const hooks = this.#hooks
357
+ const wrapped = hooks.hasWrappers('http:request')
358
+ // a hooked request starts now (see handle): its event is created with the exchange
359
+ const startTime = wrapped || hooks.hasListeners('http:request') ? performance.now() : undefined
360
+ const url = request.url
361
+ const mark = url.indexOf('?')
362
+ const path = mark === -1 ? url : url.slice(0, mark)
363
+ const params = routeParams(plan.path, path)
364
+ // an unusual path (see #route): nothing started yet, the general pipeline handles it
365
+ if (params === undefined) return this.handle(plan, request)
366
+ this.#enter()
367
+ let ctx: RequestContext
368
+ try {
369
+ const query = mark === -1 ? '' : url.slice(mark + 1)
370
+ ctx = this.#context(request, this.#requestId(request), path, query, plan.info, params, plan.body)
371
+ } catch (error) {
372
+ this.#leave()
373
+ return Promise.reject(error)
374
+ }
375
+ const scope = this.#container.openScope([
376
+ [HttpTokens.Context, ctx],
377
+ [HttpTokens.RequestId, ctx.requestId],
378
+ ])
379
+ if (wrapped) {
380
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
381
+ x.done = scope.run(this.#fastWrapped, x, route)
382
+ return this.#fastExchange(x, scope)
383
+ }
384
+ const done = scope.run(this.#fastRun, route, request, ctx, startTime)
385
+ if (done instanceof Exchange) return this.#fastExchange(done, scope)
386
+ const closing = scope.close()
387
+ if (closing === undefined) {
388
+ ctx.complete()
389
+ this.#leave()
390
+ } else {
391
+ const x = new Exchange(request, ctx, ctx.requestId, route.target)
392
+ x.answer(done)
393
+ closing.then(
394
+ () => this.#over(x),
395
+ (error: unknown) => this.#disposalFailed(x, error),
396
+ )
397
+ }
398
+ // listeners are checked once the scope callback paused (one may have been registered by the handler)
399
+ if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
400
+ const event = requestEvent(request, ctx, startTime ?? performance.now())
401
+ event.status = done.status
402
+ return Promise.resolve(done).then(reply => {
403
+ hooks.emit('http:request', event)
404
+ return reply
405
+ })
406
+ }
407
+
408
+ /**
409
+ * The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
410
+ * it settled (see {@link #end}); listeners get the event once the reply is ready.
411
+ */
412
+ #fastExchange(x: Exchange, scope: OpenedScope): Promise<AdapterReply> {
413
+ ;(x.done as Promise<void>).then(
414
+ () => this.#end(x, scope, undefined),
415
+ (error: unknown) => this.#end(x, scope, error),
416
+ )
417
+ if (!this.#hooks.hasListeners('http:request')) return x.replied
418
+ x.tracked = true
419
+ return this.#emitted(x)
420
+ }
421
+
422
+ /**
423
+ * The fast lane in the request scope: the reply when the handler answered synchronously with a
424
+ * plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
425
+ * `startTime`: the request has `http:request` listeners, its event starts with the exchange.
426
+ */
427
+ readonly #fastRun = (
428
+ route: FastRoute,
429
+ request: AdapterRequest,
430
+ ctx: RequestContext,
431
+ startTime: number | undefined,
432
+ ): AdapterReply | Exchange => {
433
+ const plan = route.plan
434
+ if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime)
435
+ let result: unknown
436
+ let reply: AdapterReply | undefined
437
+ try {
438
+ result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
439
+ if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
440
+ reply = this.#fastReply(route, ctx, result)
441
+ } catch (error) {
442
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
443
+ x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
444
+ return x
445
+ }
446
+ if (reply !== undefined) {
447
+ ctx.commit()
448
+ ctx.releaseBody()
449
+ return reply
450
+ }
451
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
452
+ x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
453
+ return x
454
+ }
455
+
456
+ /** The fast lane of a route with a body to read or a handler that returned a thenable. */
457
+ #fastLater(
458
+ route: FastRoute,
459
+ request: AdapterRequest,
460
+ ctx: RequestContext,
461
+ result: unknown,
462
+ startTime: number | undefined,
463
+ ): Exchange {
464
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
465
+ x.done = this.#fastAsync(x, route, result)
466
+ return x
467
+ }
468
+
469
+ /**
470
+ * The asynchronous part of the fast lane, in the request scope: the body read (`result` is
471
+ * {@link NO_RESULT}) and the handler run, or the thenable it returned awaited. Never rejects.
472
+ */
473
+ async #fastAsync(x: Exchange, route: FastRoute, pending: unknown): Promise<void> {
474
+ const ctx = x.ctx
475
+ const plan = route.plan
476
+ let result = pending
477
+ let reply: AdapterReply | undefined
478
+ try {
479
+ if (result === NO_RESULT) {
480
+ const controller = this.#resolve(plan.controller)
481
+ await ctx.parseBody()
482
+ result = plan.definition.invoke(controller, ctx)
483
+ }
484
+ if (isThenable(result)) result = await result
485
+ reply = this.#fastReply(route, ctx, result)
486
+ } catch (error) {
487
+ await this.#respond(x, () => this.#failed(error, ctx, x.event))
488
+ return
489
+ }
490
+ if (reply === undefined) {
491
+ const value = result
492
+ await this.#respond(x, () => this.#settle(ctx, plan, x.event, value))
493
+ return
494
+ }
495
+ ctx.commit()
496
+ ctx.releaseBody()
497
+ // a listener registered since the request started gets a complete event
498
+ if (x.tracked) x.event.status = reply.status
499
+ x.answer(reply)
500
+ }
501
+
502
+ /**
503
+ * The reply of a plain JSON value (see {@link isPlainJson}) or of `undefined`: what
504
+ * `serializeResult` and {@link #complete} produce for it, the headers in their final order.
505
+ * `undefined` when the result or the context needs the general pipeline. Throws like
506
+ * `JSON.stringify`.
507
+ */
508
+ #fastReply(route: FastRoute, ctx: RequestContext, result: unknown): AdapterReply | undefined {
509
+ const status = result === undefined ? route.emptyStatus : route.jsonStatus
510
+ // draining since the request started (`connection: close`, see #frameworkHeaders)
511
+ if (status === undefined || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return undefined
512
+ const requestId = ctx.requestId
513
+ // a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
514
+ if (!this.#validIds && !isHeaderValue(requestId)) return undefined
515
+ if (result === undefined) return this.#templateReply(status, null, undefined, requestId)
516
+ if (!isPlainJson(result)) return undefined
517
+ const json = JSON.stringify(result)
518
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
519
+ // encoded once, its length from the bytes (see serialize.ts encodeText)
520
+ return this.#templateReply(status, utf8(json), json, requestId)
521
+ }
522
+
523
+ /**
524
+ * The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
525
+ * the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
526
+ * can be sent).
527
+ */
528
+ #templateReply(
529
+ status: number,
530
+ body: Uint8Array | null,
531
+ text: string | undefined,
532
+ requestId: RequestId,
533
+ ): AdapterReply {
534
+ if (text === undefined) {
535
+ const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
536
+ const idHeader = this.#emptyIdHeader
537
+ if (idHeader !== undefined) headers[idHeader] = requestId
538
+ return { status, headers, body: null }
539
+ }
540
+ const headers: Record<string, HeaderValue> = { ...this.#jsonHeaders }
541
+ headers['content-length'] = String((body as Uint8Array).byteLength)
542
+ const idHeader = this.#jsonIdHeader
543
+ if (idHeader !== undefined) headers[idHeader] = requestId
544
+ return { status, headers, body, text }
545
+ }
546
+
547
+ /**
548
+ * The fast lane through the `http:request` wrappers (registered when the request started), in the
549
+ * request scope: what {@link #respond} does for the route, the handler run inside `next()` like
550
+ * {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
551
+ * the reply completed from the template of the fast lane when the wrappers added no response header
552
+ * (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
553
+ */
554
+ readonly #fastWrapped = (x: Exchange, route: FastRoute): Promise<void> =>
555
+ this.#hooks
556
+ .run('http:request', x.event, () => this.#fastOperation(x, route))
557
+ .then(
558
+ () => this.#fastDeliver(x, false, undefined),
559
+ (error: unknown) => this.#fastDeliver(x, true, error),
560
+ )
561
+
562
+ /** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
563
+ #fastOperation(x: Exchange, route: FastRoute): Promise<Outcome> {
564
+ const ctx = x.ctx
565
+ const plan = route.plan
566
+ if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
567
+ let result: unknown
568
+ try {
569
+ result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
570
+ } catch (error) {
571
+ return this.#fastChecked(x, this.#failed(error, ctx, x.event))
572
+ }
573
+ if (isThenable(result)) return this.#fastOperationLater(x, route, result)
574
+ return this.#fastChecked(x, this.#fastOutcome(x, route, result))
575
+ }
576
+
577
+ /** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
578
+ async #fastOperationLater(x: Exchange, route: FastRoute, pending: unknown): Promise<Outcome> {
579
+ const ctx = x.ctx
580
+ const plan = route.plan
581
+ let result = pending
582
+ try {
583
+ if (result === NO_RESULT) {
584
+ const controller = this.#resolve(plan.controller)
585
+ await ctx.parseBody()
586
+ result = plan.definition.invoke(controller, ctx)
587
+ }
588
+ if (isThenable(result)) result = await result
589
+ } catch (error) {
590
+ return await this.#fastChecked(x, this.#failed(error, ctx, x.event))
591
+ }
592
+ return await this.#fastChecked(x, this.#fastOutcome(x, route, result))
593
+ }
594
+
595
+ /**
596
+ * The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
597
+ * the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
598
+ * pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
599
+ */
600
+ #fastOutcome(x: Exchange, route: FastRoute, result: unknown): Outcome | Promise<Outcome> {
601
+ const ctx = x.ctx
602
+ const event = x.event
603
+ const status = result === undefined ? route.emptyStatus : route.jsonStatus
604
+ if (status === undefined || ctx.hasResponseHeaders || (result !== undefined && !isPlainJson(result))) {
605
+ return this.#settle(ctx, route.plan, event, result)
606
+ }
607
+ let reply: SerializedReply
608
+ if (result === undefined) {
609
+ reply = { status, headers: fastRecord<HeaderValue>(), body: null }
610
+ } else {
611
+ let json: string | undefined
612
+ try {
613
+ json = JSON.stringify(result)
614
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
615
+ } catch (error) {
616
+ return this.#failed(error, ctx, event)
617
+ }
618
+ const headers = fastRecord<HeaderValue>()
619
+ headers['content-type'] = ContentType.json
620
+ const body = utf8(json)
621
+ headers['content-length'] = String(body.byteLength)
622
+ reply = { status, headers, body, text: json, ownHeaders: true }
623
+ }
624
+ event.status = status
625
+ x.fast = true
626
+ return reply
627
+ }
628
+
629
+ /**
630
+ * What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
631
+ * headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
632
+ */
633
+ #fastChecked(x: Exchange, pending: Outcome | Promise<Outcome>): Promise<Outcome> {
634
+ if (pending instanceof Promise) return pending.then(outcome => this.#fastKeep(x, outcome))
635
+ return Promise.resolve(this.#fastKeep(x, pending))
636
+ }
637
+
638
+ #fastKeep(x: Exchange, outcome: Outcome): Outcome {
639
+ // a reply of #fastOutcome is valid by construction (what #checked returns for it)
640
+ const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event)
641
+ if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext()
642
+ x.outcome = checked
643
+ return checked
644
+ }
645
+
646
+ /**
647
+ * Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
648
+ * headers of the template when nothing was added to it (no response header from the wrappers, no
649
+ * cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
650
+ * anything else is completed by {@link #deliver}. Never rejects.
651
+ */
652
+ #fastDeliver(x: Exchange, failed: boolean, hookError: unknown): Promise<void> | undefined {
653
+ const outcome = x.outcome
654
+ const ctx = x.ctx
655
+ if (outcome === undefined) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED)
656
+ if (failed) this.#wrapperFailed(x, hookError)
657
+ else if (
658
+ x.fast &&
659
+ outcome !== RAW &&
660
+ !hasOwnKeys(x.event.responseHeaders) &&
661
+ !ctx.hasCookies &&
662
+ !this.draining &&
663
+ (this.#validIds || isHeaderValue(x.requestId))
664
+ ) {
665
+ const reply = this.#templateReply(outcome.status, outcome.body as Uint8Array | null, outcome.text, x.requestId)
666
+ ctx.commit()
667
+ ctx.releaseBody()
668
+ x.answer(reply)
669
+ return undefined
670
+ }
671
+ const streaming = this.#send(x, outcome, true, x.run)
672
+ if (streaming === undefined) return undefined
673
+ return streaming.tracked.done.then(failure => this.#streamed(x, streaming, failure))
674
+ }
675
+
676
+ /** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
677
+ async #fastUnhandled(x: Exchange, failure: unknown): Promise<void> {
678
+ const streaming = this.#send(x, await this.#unhandled(x, failure), true, undefined)
679
+ if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
680
+ }
681
+
682
+ /** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
683
+ #end(x: Exchange, scope: OpenedScope, error: unknown): void {
684
+ const closing = scope.close()
685
+ if (error !== undefined) {
686
+ // like runInScope: the scope closes, then the failure is reported (#respond never rejects)
687
+ const report = (): void => this.#disposalFailed(x, error)
688
+ if (closing === undefined) report()
689
+ else closing.then(report, report)
690
+ return
691
+ }
692
+ if (closing === undefined) this.#over(x)
693
+ else
694
+ closing.then(
695
+ () => this.#over(x),
696
+ (failure: unknown) => this.#disposalFailed(x, failure),
697
+ )
698
+ }
699
+
700
+ #disposalFailed(x: Exchange, error: unknown): void {
701
+ this.#over(x)
702
+ this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
703
+ }
704
+
199
705
  /**
200
706
  * Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
201
707
  * open) until a streamed or file response body has been fully read or cancelled by the adapter.
@@ -304,163 +810,215 @@ export class Pipeline {
304
810
  body: BodyPlan,
305
811
  ): Promise<AdapterReply> {
306
812
  this.#enter()
307
- let requestId: RequestId
308
- let ctx: RequestContext
309
- let event: HttpRequestEvent
813
+ let x: Exchange
310
814
  try {
311
- requestId = this.#requestId(request)
312
- const proxy = this.#options.trustProxy
313
- ctx = new RequestContext(request, {
314
- requestId,
315
- adapter: this.#options.adapter,
316
- url,
317
- route,
318
- params,
319
- body,
320
- cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
321
- client:
322
- proxy === undefined
323
- ? undefined
324
- : proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
325
- })
815
+ const requestId = this.#requestId(request)
816
+ const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
326
817
  // an event stream requested while draining is served (guards run) with its signal already
327
818
  // aborted: a well-behaved source ends at once and the client gets a jittered retry
328
819
  if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
329
- event = {
330
- method: request.method,
331
- path: ctx.path,
332
- route: route?.path,
333
- requestId,
334
- headers: request.headers,
335
- context: ctx,
336
- startTime,
337
- responseHeaders: {},
338
- }
820
+ x = new Exchange(request, ctx, requestId, target, startTime)
339
821
  } catch (error) {
340
822
  // failed before its scope opened (a request id generator that throws...)
341
823
  this.#leave()
342
824
  return Promise.reject(error)
343
825
  }
344
- let outcome: Outcome | undefined
345
- let cors: CorsDecision | undefined
346
- const preflight = target.kind === 'preflight'
347
- let resolve: (reply: AdapterReply) => void = () => undefined
348
- let reject: (error: unknown) => void = () => undefined
349
- const replied = new Promise<AdapterReply>((onReply, onError) => {
350
- resolve = onReply
351
- reject = onError
352
- })
353
- let answered = false
354
- const answer = (reply: AdapterReply): void => {
355
- answered = true
356
- resolve(reply)
357
- }
358
- /** The request is over (response body sent or abandoned, scope closed). */
359
- const over = (): void => {
360
- ctx.complete()
361
- try {
362
- if (!answered) {
363
- const failure = new Error('Request not handled')
364
- const reply = serializeError(500, this.#internalBody(failure), ctx.method)
365
- const completed = this.#complete(reply, ctx, event, requestId, cors, preflight, true)
366
- event.status = 500
367
- answer(completed)
368
- }
369
- } catch (error) {
370
- reject(error)
371
- } finally {
372
- this.#leave()
373
- }
374
- }
375
826
  // the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
376
827
  this.#container
377
- .runInScope(async () => {
378
- let hookError: unknown
379
- let failed = false
380
- let run: ContextRunner | undefined
381
- if (this.#options.cors !== undefined) cors = await this.#corsDecision(request, requestId)
382
- // only a wrapper can change a reply between its checks below and the adapter
383
- let recheck = this.#hooks.hasWrappers('http:request')
384
- try {
385
- if (recheck) {
386
- await this.#hooks.run('http:request', event, async () => {
387
- outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event)
388
- // stream producers run in the context of the handler (scope, wrappers)
389
- if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext()
390
- return outcome
391
- })
392
- } else {
393
- // what hooks.run() does without wrappers, in the same async context
394
- outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event)
395
- }
396
- } catch (error) {
397
- failed = true
398
- hookError = error
399
- }
400
- if (outcome === undefined) {
401
- // a wrapper failed before the request was handled, or did not call next(): answer anyway
402
- const error = failed ? hookError : new Error('An "http:request" hook wrapper did not call next()')
403
- event.error ??= error
404
- // the same checks as a handled reply: an exception mapper's HttpResponse may carry cookies
405
- const fallback = await this.#errorReply(error, ctx)
406
- outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
407
- event.status = outcome.status
408
- recheck = true
409
- } else if (failed) {
410
- this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
411
- err: hookError,
412
- requestId,
413
- })
414
- }
415
- if (outcome === RAW) {
416
- ctx.commit()
417
- const status = request.rawStatus?.() ?? 200
418
- event.status ??= status
419
- answer({ status, headers: {}, body: null, raw: true })
420
- return
421
- }
422
- const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight, recheck)
423
- const body = serialized.body
424
- if (!isStreamed(body)) {
425
- const reply = adapterReply(serialized, body)
426
- ctx.commit()
427
- ctx.releaseBody()
428
- answer(reply)
429
- return
430
- }
431
- // files too: a file-backed Blob is read lazily, after the handler returned
432
- const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
433
- const session = serialized.sse
434
- const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
435
- if (session !== undefined) {
436
- this.#streams.add(session)
437
- if (this.draining) session.close('shutdown')
438
- }
439
- ctx.commit()
440
- answer(reply)
441
- const failure = await tracked.done
442
- if (session !== undefined) this.#streams.delete(session)
443
- if (failure !== undefined) {
444
- this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
445
- }
446
- ctx.releaseBody()
447
- }, [
448
- [HttpTokens.Context, ctx],
449
- [HttpTokens.RequestId, requestId],
450
- ])
451
- .then(over, (error: unknown) => {
452
- over()
453
- // the callback above never throws: only the disposal of request-scoped instances can fail
454
- this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
455
- })
828
+ .runInScope(
829
+ () => this.#respond(x, undefined),
830
+ [
831
+ [HttpTokens.Context, x.ctx],
832
+ [HttpTokens.RequestId, x.requestId],
833
+ ],
834
+ )
835
+ .then(
836
+ () => this.#over(x),
837
+ (error: unknown) => {
838
+ this.#over(x)
839
+ // #respond never rejects: only the disposal of request-scoped instances can fail
840
+ this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
841
+ },
842
+ )
456
843
  // listeners run once the reply is ready, before the adapter gets it, out of the request scope
457
- if (!this.#hooks.hasListeners('http:request')) return replied
458
- return replied.then(reply => {
459
- this.#hooks.emit('http:request', event)
844
+ return this.#hooks.hasListeners('http:request') ? this.#emitted(x) : x.replied
845
+ }
846
+
847
+ /** The reply of a request once the `http:request` listeners saw its event. */
848
+ #emitted(x: Exchange): Promise<AdapterReply> {
849
+ return x.replied.then(reply => {
850
+ this.#hooks.emit('http:request', x.event)
460
851
  return reply
461
852
  })
462
853
  }
463
854
 
855
+ /** The request context of a request (internal). */
856
+ #context(
857
+ request: AdapterRequest,
858
+ requestId: RequestId,
859
+ path: string,
860
+ query: string,
861
+ route: HttpRouteInfo | undefined,
862
+ params: Record<string, string>,
863
+ body: BodyPlan,
864
+ ): RequestContext {
865
+ const proxy = this.#options.trustProxy
866
+ return new RequestContext(
867
+ request,
868
+ requestId,
869
+ this.#options.adapter,
870
+ path,
871
+ query,
872
+ route,
873
+ params,
874
+ body,
875
+ this.#cookies,
876
+ proxy === undefined
877
+ ? undefined
878
+ : proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
879
+ )
880
+ }
881
+
882
+ /**
883
+ * Answers a request in its scope: its outcome (`process`, or the target of the exchange after the
884
+ * CORS decision and through the `http:request` wrappers), checked, completed and handed to the
885
+ * adapter; a streamed body is then tracked until it was sent or abandoned. Never rejects.
886
+ * `process` is given by the fast lane, which decided at the start of the request that no wrapper
887
+ * applies and that CORS is off.
888
+ */
889
+ async #respond(x: Exchange, process: (() => Outcome | Promise<Outcome>) | undefined): Promise<void> {
890
+ const ctx = x.ctx
891
+ const requestId = x.requestId
892
+ const event = x.event
893
+ let outcome: Outcome | undefined
894
+ let hookError: unknown
895
+ let failed = false
896
+ let run: ContextRunner | undefined
897
+ // only a wrapper can change a reply between its checks below and the adapter
898
+ let recheck = false
899
+ let work = process
900
+ if (work === undefined) {
901
+ if (this.#options.cors !== undefined) x.cors = await this.#corsDecision(x.request, requestId)
902
+ recheck = this.#hooks.hasWrappers('http:request')
903
+ const target = x.target
904
+ work = () => this.#process(ctx, target, event, x.cors)
905
+ }
906
+ try {
907
+ if (recheck) {
908
+ const inner = work
909
+ await this.#hooks.run('http:request', event, async () => {
910
+ outcome = this.#checked(await inner(), ctx, event)
911
+ // stream producers run in the context of the handler (scope, wrappers)
912
+ if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext()
913
+ return outcome
914
+ })
915
+ } else {
916
+ // what hooks.run() does without wrappers, in the same async context
917
+ outcome = this.#checked(await work(), ctx, event)
918
+ }
919
+ } catch (error) {
920
+ failed = true
921
+ hookError = error
922
+ }
923
+ if (outcome === undefined) {
924
+ outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED)
925
+ recheck = true
926
+ } else if (failed) {
927
+ this.#wrapperFailed(x, hookError)
928
+ }
929
+ const streaming = this.#send(x, outcome, recheck, run)
930
+ if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
931
+ }
932
+
933
+ /**
934
+ * The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
935
+ * did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
936
+ * exception mapper's `HttpResponse` may carry cookies). Never rejects.
937
+ */
938
+ async #unhandled(x: Exchange, failure: unknown): Promise<CheckedReply> {
939
+ const ctx = x.ctx
940
+ const event = x.event
941
+ const error = failure === NOT_CALLED ? new Error('An "http:request" hook wrapper did not call next()') : failure
942
+ event.error ??= error
943
+ const fallback = await this.#errorReply(error, ctx)
944
+ const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
945
+ event.status = reply.status
946
+ return reply
947
+ }
948
+
949
+ #wrapperFailed(x: Exchange, error: unknown): void {
950
+ this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
951
+ err: error,
952
+ requestId: x.requestId,
953
+ })
954
+ }
955
+
956
+ /**
957
+ * Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
958
+ * Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
959
+ * captured), whose end {@link #streamed} records.
960
+ */
961
+ #send(x: Exchange, outcome: Outcome, recheck: boolean, run: ContextRunner | undefined): Streaming | undefined {
962
+ const ctx = x.ctx
963
+ const event = x.event
964
+ if (outcome === RAW) {
965
+ ctx.commit()
966
+ const status = x.request.rawStatus?.() ?? 200
967
+ event.status ??= status
968
+ x.answer({ status, headers: {}, body: null, raw: true })
969
+ return undefined
970
+ }
971
+ const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck)
972
+ const body = serialized.body
973
+ if (!isStreamed(body)) {
974
+ const reply = adapterReply(serialized, body)
975
+ ctx.commit()
976
+ ctx.releaseBody()
977
+ x.answer(reply)
978
+ return undefined
979
+ }
980
+ // files too: a file-backed Blob is read lazily, after the handler returned
981
+ const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
982
+ const session = serialized.sse
983
+ const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
984
+ if (session !== undefined) {
985
+ this.#streams.add(session)
986
+ if (this.draining) session.close('shutdown')
987
+ }
988
+ ctx.commit()
989
+ x.answer(reply)
990
+ return { tracked, session }
991
+ }
992
+
993
+ /** A streamed body was sent or abandoned (`failure`: the stream failed). */
994
+ #streamed(x: Exchange, streaming: Streaming, failure: unknown): void {
995
+ const ctx = x.ctx
996
+ if (streaming.session !== undefined) this.#streams.delete(streaming.session)
997
+ if (failure !== undefined) {
998
+ this.#logger.error('The response stream failed', { err: failure, requestId: x.requestId, path: ctx.path })
999
+ }
1000
+ ctx.releaseBody()
1001
+ }
1002
+
1003
+ /** The request is over (response body sent or abandoned, scope closed). */
1004
+ #over(x: Exchange): void {
1005
+ const ctx = x.ctx
1006
+ ctx.complete()
1007
+ try {
1008
+ if (!x.answered) {
1009
+ const failure = new Error('Request not handled')
1010
+ const reply = serializeError(500, this.#internalBody(failure), ctx.method)
1011
+ const completed = this.#complete(reply, ctx, x.event, x.requestId, x.cors, x.preflight, true)
1012
+ x.event.status = 500
1013
+ x.answer(completed)
1014
+ }
1015
+ } catch (error) {
1016
+ x.fail(error)
1017
+ } finally {
1018
+ this.#leave()
1019
+ }
1020
+ }
1021
+
464
1022
  /**
465
1023
  * Adds the headers of the `http:request` wrappers, then the framework headers (see
466
1024
  * {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
@@ -504,7 +1062,7 @@ export class Pipeline {
504
1062
  #checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
505
1063
  const cookies = reply.cookies
506
1064
  if (cookies === undefined || cookies.length === 0) return reply
507
- const settings = this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS
1065
+ const settings = this.#cookies
508
1066
  const lines: SerializedCookie[] = []
509
1067
  try {
510
1068
  for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
@@ -645,6 +1203,7 @@ export class Pipeline {
645
1203
 
646
1204
  /** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
647
1205
  async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
1206
+ let result: unknown
648
1207
  try {
649
1208
  const controller = this.#resolve(plan.controller)
650
1209
  for (const guard of plan.guards) {
@@ -654,46 +1213,80 @@ export class Pipeline {
654
1213
  if (plan.parseBody) await ctx.parseBody()
655
1214
  const definition = plan.definition
656
1215
  const interceptors = plan.interceptors
657
- let result: unknown
658
1216
  if (interceptors.length === 0) {
659
1217
  result = definition.invoke(controller, ctx)
660
- if (isThenable(result)) result = await result
661
1218
  } else {
662
1219
  const chain = async (index: number): Promise<unknown> => {
663
1220
  if (index === interceptors.length) return await definition.invoke(controller, ctx)
664
1221
  const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
665
1222
  return await interceptor.intercept(ctx, () => chain(index + 1))
666
1223
  }
667
- result = await chain(0)
668
- }
669
- if (definition.rawResponse === true && !(result instanceof Response)) return RAW
670
- const options: SerializeOptions = {
671
- method: ctx.method,
672
- routeMethod: definition.method,
673
- status: definition.status,
674
- headers: definition.headers,
675
- context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
1224
+ result = chain(0)
676
1225
  }
677
- const sse = definition.sse
678
- let reply: SerializedReply
679
- if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
680
- // an @Sse route: the (awaited) result is the event source
681
- if (!isSseSource(result)) {
682
- throw new TypeError(
683
- `@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
684
- )
685
- }
686
- reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
687
- } else {
688
- // only an HttpResponse may need an asynchronous step (a file to open)
689
- reply = isHttpResponse(result)
690
- ? await this.#serialize(result, ctx, options, sse)
691
- : serializeResult(result, options)
692
- }
693
- return this.#finish(event, reply)
694
1226
  } catch (error) {
695
1227
  return await this.#failed(error, ctx, event)
696
1228
  }
1229
+ const settled = this.#settle(ctx, plan, event, result)
1230
+ return settled instanceof Promise ? await settled : settled
1231
+ }
1232
+
1233
+ /**
1234
+ * The rest of {@link #invoke} once the handler (and its interceptors) returned `result`: awaited
1235
+ * when it is a thenable, then serialised. Never throws nor rejects (errors are mapped).
1236
+ */
1237
+ #settle(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
1238
+ if (isThenable(result)) return this.#settleLater(ctx, plan, event, result)
1239
+ try {
1240
+ const outcome = this.#outcome(ctx, plan, event, result)
1241
+ return outcome instanceof Promise ? outcome.catch(error => this.#failed(error, ctx, event)) : outcome
1242
+ } catch (error) {
1243
+ return this.#failed(error, ctx, event)
1244
+ }
1245
+ }
1246
+
1247
+ async #settleLater(
1248
+ ctx: RequestContext,
1249
+ plan: RoutePlan,
1250
+ event: HttpRequestEvent,
1251
+ pending: PromiseLike<unknown>,
1252
+ ): Promise<Outcome> {
1253
+ let result: unknown
1254
+ try {
1255
+ result = await pending
1256
+ } catch (error) {
1257
+ return await this.#failed(error, ctx, event)
1258
+ }
1259
+ const settled = this.#settle(ctx, plan, event, result)
1260
+ return settled instanceof Promise ? await settled : settled
1261
+ }
1262
+
1263
+ /**
1264
+ * The outcome of a handler result: `RAW` for a `@Res()` route, otherwise its reply (files resolved,
1265
+ * event streams opened: only an `HttpResponse` may need an asynchronous step). Throws (or rejects)
1266
+ * when the result cannot be served.
1267
+ */
1268
+ #outcome(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
1269
+ const definition = plan.definition
1270
+ if (definition.rawResponse === true && !(result instanceof Response)) return RAW
1271
+ const options: SerializeOptions = {
1272
+ method: ctx.method,
1273
+ routeMethod: definition.method,
1274
+ status: definition.status,
1275
+ headers: definition.headers,
1276
+ context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
1277
+ }
1278
+ const sse = definition.sse
1279
+ if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
1280
+ // an @Sse route: the (awaited) result is the event source
1281
+ if (!isSseSource(result)) {
1282
+ throw new TypeError(
1283
+ `@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
1284
+ )
1285
+ }
1286
+ return this.#finish(event, serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options))
1287
+ }
1288
+ if (!isHttpResponse(result)) return this.#finish(event, serializeResult(result, options))
1289
+ return this.#serialize(result, ctx, options, sse).then(reply => this.#finish(event, reply))
697
1290
  }
698
1291
 
699
1292
  /** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
@@ -868,6 +1461,172 @@ export class Pipeline {
868
1461
  }
869
1462
  }
870
1463
 
1464
+ /** A route served by the fast lane (see `Pipeline.handler`), decided at boot. */
1465
+ interface FastRoute {
1466
+ readonly plan: RoutePlan
1467
+ readonly target: Target
1468
+ /** The method of the route definition (requests of another method take the general pipeline). */
1469
+ readonly method: string
1470
+ /** The status of a JSON reply, `undefined` when it has no body (such replies take the general pipeline). */
1471
+ readonly jsonStatus: number | undefined
1472
+ /** The status of a reply without value (`undefined` returned). */
1473
+ readonly emptyStatus: number
1474
+ }
1475
+
1476
+ /** `#fastAsync` reads the body and runs the handler. */
1477
+ const NO_RESULT: unique symbol = Symbol('no result')
1478
+
1479
+ /** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
1480
+ const NOT_CALLED: unique symbol = Symbol('next() not called')
1481
+
1482
+ /** A streamed body handed to the adapter, until it was sent or abandoned (see `Pipeline.#send`). */
1483
+ interface Streaming {
1484
+ readonly tracked: TrackedStream
1485
+ readonly session: SseSession | undefined
1486
+ }
1487
+
1488
+ /** A request being answered (internal): its context, its target and the promise of its reply. */
1489
+ class Exchange {
1490
+ readonly request: AdapterRequest
1491
+ readonly ctx: RequestContext
1492
+ readonly requestId: RequestId
1493
+ readonly target: Target
1494
+ readonly preflight: boolean
1495
+ readonly replied: Promise<AdapterReply>
1496
+ cors: CorsDecision | undefined = undefined
1497
+ answered = false
1498
+ /** The `http:request` listeners will see the event (fast lane: keep its status). */
1499
+ tracked = false
1500
+ /** The asynchronous part of a fast-lane request (never rejects). */
1501
+ done: Promise<void> | undefined = undefined
1502
+ /** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
1503
+ outcome: Outcome | undefined = undefined
1504
+ /** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
1505
+ fast = false
1506
+ /** Fast lane with wrappers: the async context of the handler, for a streamed body. */
1507
+ run: ContextRunner | undefined = undefined
1508
+ readonly #resolve: (reply: AdapterReply) => void
1509
+ readonly #reject: (error: unknown) => void
1510
+ #event: HttpRequestEvent | undefined
1511
+
1512
+ /** `startTime`: the event is created now (general pipeline); otherwise when first needed (fast lane). */
1513
+ constructor(request: AdapterRequest, ctx: RequestContext, requestId: RequestId, target: Target, startTime?: number) {
1514
+ this.request = request
1515
+ this.ctx = ctx
1516
+ this.requestId = requestId
1517
+ this.target = target
1518
+ this.preflight = target.kind === 'preflight'
1519
+ let resolve: ((reply: AdapterReply) => void) | undefined
1520
+ let reject: ((error: unknown) => void) | undefined
1521
+ this.replied = new Promise<AdapterReply>((onReply, onError) => {
1522
+ resolve = onReply
1523
+ reject = onError
1524
+ })
1525
+ this.#resolve = resolve as (reply: AdapterReply) => void
1526
+ this.#reject = reject as (error: unknown) => void
1527
+ if (startTime !== undefined) this.#event = requestEvent(request, ctx, startTime)
1528
+ }
1529
+
1530
+ /**
1531
+ * The `http:request` event. The fast lane creates it when the general pipeline takes over: no
1532
+ * hook saw the request start, so its `startTime` is not observed.
1533
+ */
1534
+ get event(): HttpRequestEvent {
1535
+ this.#event ??= requestEvent(this.request, this.ctx, performance.now())
1536
+ return this.#event
1537
+ }
1538
+
1539
+ answer(reply: AdapterReply): void {
1540
+ this.answered = true
1541
+ this.#resolve(reply)
1542
+ }
1543
+
1544
+ fail(error: unknown): void {
1545
+ this.#reject(error)
1546
+ }
1547
+ }
1548
+
1549
+ /** The `http:request` event of a request. */
1550
+ function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: number): HttpRequestEvent {
1551
+ return {
1552
+ method: request.method,
1553
+ path: ctx.path,
1554
+ route: ctx.route?.path,
1555
+ requestId: ctx.requestId,
1556
+ headers: request.headers,
1557
+ context: ctx,
1558
+ startTime,
1559
+ responseHeaders: {},
1560
+ }
1561
+ }
1562
+
1563
+ /** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
1564
+ function hasOwnKeys(record: object): boolean {
1565
+ for (const key in record) if (Object.hasOwn(record, key)) return true
1566
+ return false
1567
+ }
1568
+
1569
+ /**
1570
+ * The decoded parameters of a raw request path matched against a route, in one pass: what
1571
+ * `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
1572
+ * `undefined` when the path does not match or a value is not valid percent-encoding: the general
1573
+ * pipeline then answers it (a reroute, a 400).
1574
+ */
1575
+ function routeParams(route: RoutePath, path: string): Record<string, string> | undefined {
1576
+ if (path.charCodeAt(0) !== 47 /* / */) return undefined
1577
+ const params = emptyRecord<string>()
1578
+ const segments = route.segments
1579
+ const last = segments.length - 1
1580
+ if (last === -1) return path === '/' ? params : undefined
1581
+ let position = 1
1582
+ for (let index = 0; index <= last; index++) {
1583
+ const segment = segments[index] as RouteSegment
1584
+ if (segment.kind === 'wildcard') {
1585
+ const rest = decodePathValue(path.slice(position))
1586
+ if (rest === undefined) return undefined
1587
+ params[WILDCARD_PARAM] = rest
1588
+ return params
1589
+ }
1590
+ const slash = path.indexOf('/', position)
1591
+ const end = slash === -1 ? path.length : slash
1592
+ if (segment.kind === 'static') {
1593
+ const value = segment.value
1594
+ if (end - position !== value.length || !path.startsWith(value, position)) return undefined
1595
+ } else {
1596
+ if (end === position) return undefined
1597
+ const decoded = decodePathValue(path.slice(position, end))
1598
+ if (decoded === undefined) return undefined
1599
+ params[segment.name] = decoded
1600
+ }
1601
+ if (index === last) return end === path.length ? params : undefined
1602
+ if (slash === -1) return undefined
1603
+ position = slash + 1
1604
+ }
1605
+ return params
1606
+ }
1607
+
1608
+ /**
1609
+ * A value the fast lane serialises as JSON: a plain object or array (its own prototype and
1610
+ * constructor), not duck-typed as a Node.js stream. Anything `serializeResult` could treat otherwise
1611
+ * (`HttpResponse`, `Response`, bytes, streams, `Blob`, strings, `undefined`...) is not.
1612
+ */
1613
+ function isPlainJson(value: unknown): value is object {
1614
+ if (value === null || typeof value !== 'object') return false
1615
+ const prototype: unknown = Object.getPrototypeOf(value)
1616
+ const ctor = (value as { constructor?: unknown }).constructor
1617
+ if (prototype === Object.prototype) {
1618
+ if (ctor !== Object) return false
1619
+ } else if (prototype !== Array.prototype || ctor !== Array) {
1620
+ return false
1621
+ }
1622
+ return typeof (value as { pipe?: unknown }).pipe !== 'function'
1623
+ }
1624
+
1625
+ const ENCODER = new TextEncoder()
1626
+
1627
+ /** UTF-8 encoding of a text body, like the serialiser (a pooled `Buffer` on Node.js, `TextEncoder` on Bun). */
1628
+ const utf8: (text: string) => Uint8Array = isBun() ? text => ENCODER.encode(text) : text => Buffer.from(text, 'utf8')
1629
+
871
1630
  /**
872
1631
  * The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
873
1632
  * `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a