@znight/http 0.1.2 → 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.
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'
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, isThenable } 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 }
@@ -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()
133
136
  }
134
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
@@ -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,242 @@ 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 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
+
199
536
  /**
200
537
  * Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
201
538
  * open) until a streamed or file response body has been fully read or cancelled by the adapter.
@@ -304,163 +641,184 @@ export class Pipeline {
304
641
  body: BodyPlan,
305
642
  ): Promise<AdapterReply> {
306
643
  this.#enter()
307
- let requestId: RequestId
308
- let ctx: RequestContext
309
- let event: HttpRequestEvent
644
+ let x: Exchange
310
645
  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
- })
646
+ const requestId = this.#requestId(request)
647
+ const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
326
648
  // an event stream requested while draining is served (guards run) with its signal already
327
649
  // aborted: a well-behaved source ends at once and the client gets a jittered retry
328
650
  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
- }
651
+ x = new Exchange(request, ctx, requestId, target, startTime)
339
652
  } catch (error) {
340
653
  // failed before its scope opened (a request id generator that throws...)
341
654
  this.#leave()
342
655
  return Promise.reject(error)
343
656
  }
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
657
  // the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
376
658
  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
- })
659
+ .runInScope(
660
+ () => this.#respond(x, undefined),
661
+ [
662
+ [HttpTokens.Context, x.ctx],
663
+ [HttpTokens.RequestId, x.requestId],
664
+ ],
665
+ )
666
+ .then(
667
+ () => this.#over(x),
668
+ (error: unknown) => {
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 })
672
+ },
673
+ )
456
674
  // 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)
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)
460
682
  return reply
461
683
  })
462
684
  }
463
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
+ }
769
+ if (outcome === RAW) {
770
+ ctx.commit()
771
+ const status = x.request.rawStatus?.() ?? 200
772
+ event.status ??= status
773
+ x.answer({ status, headers: {}, body: null, raw: true })
774
+ return
775
+ }
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()
819
+ }
820
+ }
821
+
464
822
  /**
465
823
  * Adds the headers of the `http:request` wrappers, then the framework headers (see
466
824
  * {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
@@ -504,7 +862,7 @@ export class Pipeline {
504
862
  #checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
505
863
  const cookies = reply.cookies
506
864
  if (cookies === undefined || cookies.length === 0) return reply
507
- const settings = this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS
865
+ const settings = this.#cookies
508
866
  const lines: SerializedCookie[] = []
509
867
  try {
510
868
  for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
@@ -645,6 +1003,7 @@ export class Pipeline {
645
1003
 
646
1004
  /** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
647
1005
  async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
1006
+ let result: unknown
648
1007
  try {
649
1008
  const controller = this.#resolve(plan.controller)
650
1009
  for (const guard of plan.guards) {
@@ -654,46 +1013,80 @@ export class Pipeline {
654
1013
  if (plan.parseBody) await ctx.parseBody()
655
1014
  const definition = plan.definition
656
1015
  const interceptors = plan.interceptors
657
- let result: unknown
658
1016
  if (interceptors.length === 0) {
659
1017
  result = definition.invoke(controller, ctx)
660
- if (isThenable(result)) result = await result
661
1018
  } else {
662
1019
  const chain = async (index: number): Promise<unknown> => {
663
1020
  if (index === interceptors.length) return await definition.invoke(controller, ctx)
664
1021
  const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
665
1022
  return await interceptor.intercept(ctx, () => chain(index + 1))
666
1023
  }
667
- result = await chain(0)
1024
+ result = chain(0)
668
1025
  }
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,
676
- }
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
1026
  } catch (error) {
695
1027
  return await this.#failed(error, ctx, event)
696
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
1070
+ if (definition.rawResponse === true && !(result instanceof Response)) return RAW
1071
+ const options: SerializeOptions = {
1072
+ method: ctx.method,
1073
+ routeMethod: definition.method,
1074
+ status: definition.status,
1075
+ headers: definition.headers,
1076
+ context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
1077
+ }
1078
+ const sse = definition.sse
1079
+ if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
1080
+ // an @Sse route: the (awaited) result is the event source
1081
+ if (!isSseSource(result)) {
1082
+ throw new TypeError(
1083
+ `@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
1084
+ )
1085
+ }
1086
+ return this.#finish(event, serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options))
1087
+ }
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))
697
1090
  }
698
1091
 
699
1092
  /** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
@@ -868,6 +1261,151 @@ export class Pipeline {
868
1261
  }
869
1262
  }
870
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
+
871
1409
  /**
872
1410
  * The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
873
1411
  * `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a