@znight/http 0.1.0 → 0.1.2

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
@@ -27,7 +27,7 @@ import {
27
27
  import { isFileBody, resolveFile } from './files.ts'
28
28
  import { findInvalidHeader, isHeaderValue } from './headers.ts'
29
29
  import { decodePathValue, matchRoutePath, type RoutePath } from './path.ts'
30
- import { splitUrl } from './query.ts'
30
+ import { type RequestTarget, splitUrl } from './query.ts'
31
31
  import { HttpResponse, isHttpResponse } from './response.ts'
32
32
  import {
33
33
  addMissingHeaders,
@@ -42,7 +42,7 @@ import {
42
42
  import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
43
43
  import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
44
44
  import { HttpTokens } from './tokens.ts'
45
- import { emptyRecord, errorMessage } from './utils.ts'
45
+ import { emptyRecord, errorMessage, isThenable } from './utils.ts'
46
46
 
47
47
  /** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
48
48
  export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
@@ -123,15 +123,15 @@ type Outcome = SerializedReply | typeof RAW
123
123
  /** A reply whose `HttpResponse` cookies were serialised (see `#checkCookies`). */
124
124
  type CheckedReply = SerializedReply & { readonly cookieLines?: readonly SerializedCookie[] }
125
125
 
126
- /** A reply, and a promise settled once the request is over (response body sent or abandoned, scope closed). */
127
- interface Served {
128
- readonly reply: AdapterReply
129
- readonly done: Promise<void>
130
- }
131
-
132
126
  const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
127
+
133
128
  const MAX_PATH_IN_MESSAGE = 200
134
129
 
130
+ /** The default request id generator: a random UUID. */
131
+ export function randomRequestId(): string {
132
+ return crypto.randomUUID()
133
+ }
134
+
135
135
  /**
136
136
  * The request pipeline (architecture §9.1):
137
137
  * `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
@@ -143,6 +143,10 @@ export class Pipeline {
143
143
  readonly #logger: CoreLogger
144
144
  readonly #options: PipelineOptions
145
145
  readonly #table: RouteTable<RoutePlan>
146
+ /** The `securityHeaders` option as entries (lower-case names, values validated at boot). */
147
+ readonly #securityHeaders: readonly (readonly [string, string])[]
148
+ /** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
149
+ readonly #validIds: boolean
146
150
  #inflight = 0
147
151
  #idle: (() => void)[] = []
148
152
  /** Open event streams. */
@@ -162,6 +166,10 @@ export class Pipeline {
162
166
  this.#logger = logger
163
167
  this.#options = options
164
168
  this.#table = table
169
+ this.#validIds = options.generateRequestId === randomRequestId
170
+ this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(
171
+ ([name, value]) => [name.toLowerCase(), value] as const,
172
+ )
165
173
  }
166
174
 
167
175
  /** Number of requests being processed. */
@@ -192,30 +200,26 @@ export class Pipeline {
192
200
  * Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
193
201
  * open) until a streamed or file response body has been fully read or cancelled by the adapter.
194
202
  */
195
- async handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
203
+ handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
196
204
  const startTime = performance.now()
197
- this.#enter()
198
- let served: Served | undefined
199
205
  try {
200
- served = this.#isPreflight(request)
201
- ? await this.#preflight(request, startTime)
202
- : await this.#route(plan, request, startTime)
203
- return served.reply
204
- } finally {
205
- this.#leaveWhen(served)
206
+ return this.#isPreflight(request) ? this.#preflight(request, startTime) : this.#route(plan, request, startTime)
207
+ } catch (error) {
208
+ return Promise.reject(error)
206
209
  }
207
210
  }
208
211
 
209
- #route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<Served> {
210
- const { path, query } = splitUrl(request.url)
211
- const raw = matchRoutePath(plan.path, path)
212
+ #route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<AdapterReply> {
213
+ const url = splitUrl(request.url)
214
+ const raw = matchRoutePath(plan.path, url.path)
212
215
  // the native router matched a path the Znight grammar rejects (e.g. an empty `:param` in Fastify)
213
216
  // or a normalised path (Hono): route the raw path with the route table instead
214
217
  if (raw === undefined) return this.#fallback(request, undefined, startTime, !rerouted)
215
218
  if (plan.redirect === true) {
216
- const canonical = sameOriginPath(path.slice(0, -1))
217
- const location = query === '' ? canonical : `${canonical}?${query}`
218
- return this.#serve(request, undefined, emptyRecord(), { kind: 'redirect', location }, startTime, plan.body)
219
+ const canonical = sameOriginPath(url.path.slice(0, -1))
220
+ const location = url.query === '' ? canonical : `${canonical}?${url.query}`
221
+ const target: Target = { kind: 'redirect', location }
222
+ return this.#serve(request, url, undefined, emptyRecord(), target, startTime, plan.body)
219
223
  }
220
224
  const params = emptyRecord<string>()
221
225
  let malformed: string | undefined
@@ -228,30 +232,28 @@ export class Pipeline {
228
232
  malformed === undefined
229
233
  ? { kind: 'route', plan }
230
234
  : { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
231
- return this.#serve(request, plan.info, params, target, startTime, plan.body)
235
+ return this.#serve(request, url, plan.info, params, target, startTime, plan.body)
232
236
  }
233
237
 
234
238
  /** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
235
- async fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
239
+ fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
236
240
  const startTime = performance.now()
237
- this.#enter()
238
- let served: Served | undefined
239
241
  try {
240
- served = await this.#fallback(request, error, startTime)
241
- return served.reply
242
- } finally {
243
- this.#leaveWhen(served)
242
+ return this.#fallback(request, error, startTime)
243
+ } catch (failure) {
244
+ return Promise.reject(failure)
244
245
  }
245
246
  }
246
247
 
247
- #fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<Served> {
248
+ #fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<AdapterReply> {
248
249
  const body = this.#options.body
249
250
  if (error !== undefined) {
250
251
  const target: Target = { kind: 'error', error: frameworkError(error) }
251
- return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
252
+ return this.#serve(request, splitUrl(request.url), undefined, emptyRecord(), target, startTime, body)
252
253
  }
253
254
  if (this.#isPreflight(request)) return this.#preflight(request, startTime)
254
- const { path } = splitUrl(request.url)
255
+ const url = splitUrl(request.url)
256
+ const path = url.path
255
257
  if (reroute) {
256
258
  // routing happens on the raw path: serve a route the adapter's router missed (Hono routes the
257
259
  // WHATWG-normalised URL, where `/files/%2e%2e/x` became `/x`)
@@ -277,7 +279,7 @@ export class Pipeline {
277
279
  ),
278
280
  }
279
281
  }
280
- return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
282
+ return this.#serve(request, url, undefined, emptyRecord(), target, startTime, body)
281
283
  }
282
284
 
283
285
  /** A CORS preflight (with the `cors` option): answered without running any route. */
@@ -285,68 +287,112 @@ export class Pipeline {
285
287
  return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
286
288
  }
287
289
 
288
- #preflight(request: AdapterRequest, startTime: number): Promise<Served> {
289
- const allowed = this.#table.allowedMethods(splitUrl(request.url).path)
290
+ #preflight(request: AdapterRequest, startTime: number): Promise<AdapterReply> {
291
+ const url = splitUrl(request.url)
292
+ const allowed = this.#table.allowedMethods(url.path)
290
293
  const target: Target = { kind: 'preflight', allow: allowed.length === 0 ? undefined : allowHeader(allowed) }
291
- return this.#serve(request, undefined, emptyRecord(), target, startTime, this.#options.body)
294
+ return this.#serve(request, url, undefined, emptyRecord(), target, startTime, this.#options.body)
292
295
  }
293
296
 
294
- async #serve(
297
+ #serve(
295
298
  request: AdapterRequest,
299
+ url: RequestTarget,
296
300
  route: HttpRouteInfo | undefined,
297
301
  params: Record<string, string>,
298
302
  target: Target,
299
303
  startTime: number,
300
304
  body: BodyPlan,
301
- ): Promise<Served> {
302
- const requestId = this.#requestId(request)
303
- const proxy = this.#options.trustProxy
304
- const ctx = new RequestContext(request, {
305
- requestId,
306
- adapter: this.#options.adapter,
307
- route,
308
- params,
309
- body,
310
- cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
311
- ...(proxy === undefined
312
- ? {}
313
- : { client: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true) }),
314
- })
315
- // an event stream requested while draining is served (guards run) with its signal already
316
- // aborted: a well-behaved source ends at once and the client gets a jittered retry
317
- if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
318
- const event: HttpRequestEvent = {
319
- method: request.method,
320
- path: ctx.path,
321
- route: route?.path,
322
- requestId,
323
- headers: request.headers,
324
- context: ctx,
325
- startTime,
326
- responseHeaders: {},
305
+ ): Promise<AdapterReply> {
306
+ this.#enter()
307
+ let requestId: RequestId
308
+ let ctx: RequestContext
309
+ let event: HttpRequestEvent
310
+ 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
+ })
326
+ // an event stream requested while draining is served (guards run) with its signal already
327
+ // aborted: a well-behaved source ends at once and the client gets a jittered retry
328
+ 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
+ }
339
+ } catch (error) {
340
+ // failed before its scope opened (a request id generator that throws...)
341
+ this.#leave()
342
+ return Promise.reject(error)
327
343
  }
328
344
  let outcome: Outcome | undefined
329
- let reply: AdapterReply | undefined
330
345
  let cors: CorsDecision | undefined
331
346
  const preflight = target.kind === 'preflight'
332
- let replied: () => void = () => undefined
333
- const ready = new Promise<void>(resolve => {
334
- replied = resolve
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
335
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
+ }
336
375
  // the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
337
- const done = this.#container
376
+ this.#container
338
377
  .runInScope(async () => {
339
378
  let hookError: unknown
340
379
  let failed = false
341
380
  let run: ContextRunner | undefined
342
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')
343
384
  try {
344
- await this.#hooks.run('http:request', event, async () => {
345
- outcome = await this.#process(ctx, target, event, cors)
346
- if (outcome !== RAW) outcome = this.#checkCookies(this.#checkHeaders(outcome, ctx, event), ctx, event)
347
- run = captureContext() // stream producers run in the context of the handler (scope, hooks)
348
- return outcome
349
- })
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
+ }
350
396
  } catch (error) {
351
397
  failed = true
352
398
  hookError = error
@@ -359,6 +405,7 @@ export class Pipeline {
359
405
  const fallback = await this.#errorReply(error, ctx)
360
406
  outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
361
407
  event.status = outcome.status
408
+ recheck = true
362
409
  } else if (failed) {
363
410
  this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
364
411
  err: hookError,
@@ -367,28 +414,30 @@ export class Pipeline {
367
414
  }
368
415
  if (outcome === RAW) {
369
416
  ctx.commit()
370
- replied()
417
+ const status = request.rawStatus?.() ?? 200
418
+ event.status ??= status
419
+ answer({ status, headers: {}, body: null, raw: true })
371
420
  return
372
421
  }
373
- const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight)
422
+ const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight, recheck)
374
423
  const body = serialized.body
375
- if (!(body instanceof ReadableStream || body instanceof Blob)) {
376
- reply = adapterReply(serialized, body)
424
+ if (!isStreamed(body)) {
425
+ const reply = adapterReply(serialized, body)
377
426
  ctx.commit()
378
- replied()
379
427
  ctx.releaseBody()
428
+ answer(reply)
380
429
  return
381
430
  }
382
431
  // files too: a file-backed Blob is read lazily, after the handler returned
383
432
  const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
384
433
  const session = serialized.sse
385
- reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
434
+ const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
386
435
  if (session !== undefined) {
387
436
  this.#streams.add(session)
388
437
  if (this.draining) session.close('shutdown')
389
438
  }
390
439
  ctx.commit()
391
- replied()
440
+ answer(reply)
392
441
  const failure = await tracked.done
393
442
  if (session !== undefined) this.#streams.delete(session)
394
443
  if (failure !== undefined) {
@@ -399,42 +448,24 @@ export class Pipeline {
399
448
  [HttpTokens.Context, ctx],
400
449
  [HttpTokens.RequestId, requestId],
401
450
  ])
402
- .then(
403
- () => {
404
- ctx.complete()
405
- },
406
- (error: unknown) => {
407
- ctx.complete()
408
- // the callback above never throws: only the disposal of request-scoped instances can fail
409
- this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
410
- },
411
- )
412
- await Promise.race([ready, done])
413
- if (outcome === RAW) {
414
- const status = request.rawStatus?.() ?? 200
415
- event.status ??= status
416
- this.#emit(event)
417
- return { reply: { status, headers: {}, body: null, raw: true }, done }
418
- }
419
- if (reply === undefined) {
420
- const failure = new Error('Request not handled')
421
- reply = this.#complete(
422
- serializeError(500, this.#internalBody(failure), ctx.method),
423
- ctx,
424
- event,
425
- requestId,
426
- cors,
427
- preflight,
428
- )
429
- event.status = 500
430
- }
431
- this.#emit(event)
432
- return { reply, done }
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
+ })
456
+ // 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)
460
+ return reply
461
+ })
433
462
  }
434
463
 
435
464
  /**
436
465
  * Adds the headers of the `http:request` wrappers, then the framework headers (see
437
- * {@link #frameworkHeaders}), then checks the final headers (see {@link #checkHeaders}).
466
+ * {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
467
+ * {@link #checkHeaders}): the reply was checked before, and the cookies and framework headers are
468
+ * valid by construction.
438
469
  */
439
470
  #complete(
440
471
  reply: CheckedReply,
@@ -443,10 +474,12 @@ export class Pipeline {
443
474
  requestId: RequestId,
444
475
  cors: CorsDecision | undefined,
445
476
  preflight: boolean,
477
+ recheck: boolean,
446
478
  ): SerializedReply {
447
479
  addMissingHeaders(reply.headers, event.responseHeaders)
448
480
  applyCookies(reply, ctx)
449
481
  this.#frameworkHeaders(reply.headers, requestId, cors, preflight)
482
+ if (!recheck) return reply
450
483
  const checked = this.#checkHeaders(reply, ctx, event)
451
484
  if (checked !== reply) {
452
485
  applyCookies(checked, ctx)
@@ -455,6 +488,13 @@ export class Pipeline {
455
488
  return checked
456
489
  }
457
490
 
491
+ /** Checks the headers (unless only the serialiser set them), then the cookies of a reply. */
492
+ #checked(outcome: Outcome, ctx: RequestContext, event: HttpRequestEvent): Outcome {
493
+ if (outcome === RAW) return outcome
494
+ const checked = outcome.ownHeaders === true ? outcome : this.#checkHeaders(outcome, ctx, event)
495
+ return this.#checkCookies(checked, ctx, event)
496
+ }
497
+
458
498
  /**
459
499
  * Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
460
500
  * signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
@@ -492,7 +532,9 @@ export class Pipeline {
492
532
  preflight: boolean,
493
533
  ): void {
494
534
  const header = this.#options.requestIdHeader
495
- if (header !== undefined && headers[header] === undefined && isHeaderValue(requestId)) headers[header] = requestId
535
+ if (header !== undefined && headers[header] === undefined && (this.#validIds || isHeaderValue(requestId))) {
536
+ headers[header] = requestId
537
+ }
496
538
  const policy = this.#options.cors
497
539
  if (policy !== undefined) {
498
540
  // a preflight answer carries its own CORS headers; other replies get them for allowed origins,
@@ -503,8 +545,7 @@ export class Pipeline {
503
545
  }
504
546
  if (policy.varyOrigin) mergeVary(headers, 'origin')
505
547
  }
506
- const security = this.#options.securityHeaders
507
- if (security !== undefined) addMissingHeaders(headers, security)
548
+ for (const [name, value] of this.#securityHeaders) if (headers[name] === undefined) headers[name] = value
508
549
  if (this.draining) headers['connection'] = 'close'
509
550
  }
510
551
 
@@ -542,12 +583,23 @@ export class Pipeline {
542
583
  return serializeError(500, this.#internalBody(error), ctx.method)
543
584
  }
544
585
 
545
- async #process(
586
+ /** The outcome of a request: its route run, or a reply of the framework. Never rejects (errors are mapped). */
587
+ #process(
546
588
  ctx: RequestContext,
547
589
  target: Target,
548
590
  event: HttpRequestEvent,
549
591
  cors: CorsDecision | undefined,
550
592
  ): Promise<Outcome> {
593
+ return target.kind === 'route' ? this.#invoke(ctx, target.plan, event) : this.#answer(ctx, target, event, cors)
594
+ }
595
+
596
+ /** Answers a request without running a route. Never rejects. */
597
+ async #answer(
598
+ ctx: RequestContext,
599
+ target: Exclude<Target, { readonly kind: 'route' }>,
600
+ event: HttpRequestEvent,
601
+ cors: CorsDecision | undefined,
602
+ ): Promise<SerializedReply> {
551
603
  try {
552
604
  switch (target.kind) {
553
605
  case 'error':
@@ -577,59 +629,71 @@ export class Pipeline {
577
629
  headers: { location: target.location },
578
630
  }),
579
631
  )
580
- default:
581
- return await this.#invoke(ctx, target.plan, event)
582
632
  }
583
633
  } catch (error) {
584
- event.error = error
585
- const reply = await this.#errorReply(error, ctx)
586
- event.status = reply.status
587
- return reply
634
+ return await this.#failed(error, ctx, event)
588
635
  }
589
636
  }
590
637
 
638
+ /** The reply of an error (see {@link #errorReply}), recorded on the event. */
639
+ async #failed(error: unknown, ctx: RequestContext, event: HttpRequestEvent): Promise<SerializedReply> {
640
+ event.error = error
641
+ const reply = await this.#errorReply(error, ctx)
642
+ event.status = reply.status
643
+ return reply
644
+ }
645
+
646
+ /** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
591
647
  async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
592
- const controller = this.#resolve(plan.controller)
593
- for (const guard of plan.guards) {
594
- const allowed = await this.#resolve(guard).canActivate(ctx)
595
- if (allowed !== true) throw new ForbiddenException('Forbidden resource')
596
- }
597
- if (plan.parseBody) await ctx.parseBody()
598
- const definition = plan.definition
599
- const interceptors = plan.interceptors
600
- let result: unknown
601
- if (interceptors.length === 0) {
602
- result = await definition.invoke(controller, ctx)
603
- } else {
604
- const chain = async (index: number): Promise<unknown> => {
605
- if (index === interceptors.length) return await definition.invoke(controller, ctx)
606
- const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
607
- return await interceptor.intercept(ctx, () => chain(index + 1))
648
+ try {
649
+ const controller = this.#resolve(plan.controller)
650
+ for (const guard of plan.guards) {
651
+ const allowed = await this.#resolve(guard).canActivate(ctx)
652
+ if (allowed !== true) throw new ForbiddenException('Forbidden resource')
608
653
  }
609
- result = await chain(0)
610
- }
611
- if (definition.rawResponse === true && !(result instanceof Response)) return RAW
612
- const options: SerializeOptions = {
613
- method: ctx.method,
614
- routeMethod: definition.method,
615
- status: definition.status,
616
- headers: definition.headers,
617
- context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
618
- }
619
- const sse = definition.sse
620
- let reply: SerializedReply
621
- if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
622
- // an @Sse route: the (awaited) result is the event source
623
- if (!isSseSource(result)) {
624
- throw new TypeError(
625
- `@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
626
- )
654
+ if (plan.parseBody) await ctx.parseBody()
655
+ const definition = plan.definition
656
+ const interceptors = plan.interceptors
657
+ let result: unknown
658
+ if (interceptors.length === 0) {
659
+ result = definition.invoke(controller, ctx)
660
+ if (isThenable(result)) result = await result
661
+ } else {
662
+ const chain = async (index: number): Promise<unknown> => {
663
+ if (index === interceptors.length) return await definition.invoke(controller, ctx)
664
+ const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
665
+ return await interceptor.intercept(ctx, () => chain(index + 1))
666
+ }
667
+ result = await chain(0)
627
668
  }
628
- reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
629
- } else {
630
- reply = await this.#serialize(result, ctx, options, sse)
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
+ } catch (error) {
695
+ return await this.#failed(error, ctx, event)
631
696
  }
632
- return this.#finish(event, reply)
633
697
  }
634
698
 
635
699
  /** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
@@ -790,20 +854,10 @@ export class Pipeline {
790
854
  return this.#options.generateRequestId() as RequestId
791
855
  }
792
856
 
793
- #emit(event: HttpRequestEvent): void {
794
- if (this.#hooks.hasListeners('http:request')) this.#hooks.emit('http:request', event)
795
- }
796
-
797
857
  #enter(): void {
798
858
  this.#inflight++
799
859
  }
800
860
 
801
- /** Leaves once the request is over (immediately when it failed before being served). */
802
- #leaveWhen(served: Served | undefined): void {
803
- if (served === undefined) this.#leave()
804
- else void served.done.then(() => this.#leave())
805
- }
806
-
807
861
  #leave(): void {
808
862
  this.#inflight--
809
863
  if (this.#inflight === 0 && this.#idle.length > 0) {
@@ -821,6 +875,8 @@ export class Pipeline {
821
875
  * before a quiet stream's first byte, and lifts idle timeouts.
822
876
  */
823
877
  function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob): AdapterReply {
878
+ const text = serialized.text
879
+ if (text !== undefined) return { status: serialized.status, headers: serialized.headers, body, text }
824
880
  const reply: AdapterReply = { status: serialized.status, headers: serialized.headers, body }
825
881
  if (blob !== undefined) return { ...reply, blob }
826
882
  const eventStream =
@@ -836,7 +892,7 @@ function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob)
836
892
  * one in place. One line per cookie.
837
893
  */
838
894
  function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
839
- const own = reply.cookieLines ?? []
895
+ const own = reply.cookieLines ?? NO_COOKIE_LINES
840
896
  const context = ctx.cookieLines
841
897
  if (own.length === 0 && context.length === 0) return
842
898
  const merged = new Map<string, string>()
@@ -849,6 +905,13 @@ function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
849
905
  reply.headers['set-cookie'] = [...raw, ...merged.values()]
850
906
  }
851
907
 
908
+ const NO_COOKIE_LINES: readonly SerializedCookie[] = Object.freeze([])
909
+
910
+ /** Whether a reply body is streamed (a file is read through a stream too). */
911
+ function isStreamed(body: ReplyBody): body is ReadableStream<Uint8Array> | Blob {
912
+ return body instanceof ReadableStream || body instanceof Blob
913
+ }
914
+
852
915
  /** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
853
916
  function sseOptions(definition: HttpSseDefinition): SseOptions {
854
917
  return {
package/src/plugin.ts CHANGED
@@ -49,7 +49,14 @@ import { openInjectSse } from './inject-sse.ts'
49
49
  import { isMediaTypePattern } from './mime.ts'
50
50
  import { invalidOption } from './option-error.ts'
51
51
  import { compareRoutePaths, compareStrings, joinPaths, parseRoutePath, type RoutePath, RoutePathError } from './path.ts'
52
- import { DEFAULT_SSE_SETTINGS, Pipeline, type Resolvable, type RoutePlan, type SseSettings } from './pipeline.ts'
52
+ import {
53
+ DEFAULT_SSE_SETTINGS,
54
+ Pipeline,
55
+ type Resolvable,
56
+ type RoutePlan,
57
+ randomRequestId,
58
+ type SseSettings,
59
+ } from './pipeline.ts'
53
60
  import { collectPluginRoutes } from './plugin-routes.ts'
54
61
  import { resolveTrustProxy } from './proxy.ts'
55
62
  import { RouteTable } from './router.ts'
@@ -649,7 +656,7 @@ function resolve(options: HttpPluginOptions): Resolved {
649
656
  if (requestIdHeader !== false && (typeof requestIdHeader !== 'string' || !isHeaderName(requestIdHeader))) {
650
657
  throw invalidOption('requestIdHeader', 'a header name or false', requestIdHeader)
651
658
  }
652
- const generateRequestId = options.generateRequestId ?? ((): string => crypto.randomUUID())
659
+ const generateRequestId = options.generateRequestId ?? randomRequestId
653
660
  if (typeof generateRequestId !== 'function') throw invalidOption('generateRequestId', 'a function', generateRequestId)
654
661
  const adapter = options.adapter ?? nodeAdapter()
655
662
  if (