@znight/http 0.1.3 → 0.2.0

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
@@ -43,9 +43,9 @@ import {
43
43
  } from './serialize.ts'
44
44
  import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
45
45
  import { isBodylessStatus } from './status.ts'
46
- import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
46
+ import { type ContextRunner, captureContext, discardStream, type TrackedStream, trackStream } from './stream.ts'
47
47
  import { HttpTokens } from './tokens.ts'
48
- import { emptyRecord, errorMessage, isBun, isThenable } from './utils.ts'
48
+ import { emptyRecord, errorMessage, fastRecord, isBun, isThenable } from './utils.ts'
49
49
 
50
50
  /** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
51
51
  export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
@@ -334,32 +334,30 @@ export class Pipeline {
334
334
  // a HEAD reply has no body: JSON results of an explicit `@Head` route take the general pipeline
335
335
  jsonStatus: definition.method === 'HEAD' || isBodylessStatus(json) ? undefined : json,
336
336
  emptyStatus: definition.status ?? (post ? 201 : 204),
337
+ responses: definition.method !== 'HEAD',
337
338
  }
338
339
  }
339
340
 
340
341
  /**
341
342
  * 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).
343
+ * of per request, without promises while the route stays synchronous. A request takes it when the
344
+ * server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
345
+ * plain object or array returned by the handler is serialised as JSON, and `undefined` answered
346
+ * without body, with the headers in their final order (a template precomputed at boot).
347
347
  * A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
348
348
  * path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
349
349
  * or any other result, headers or cookies set through the context — continues in the general
350
350
  * pipeline, in the same request scope, with the same outcome.
351
+ * With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
352
+ * get the event once the reply is ready, as in the general pipeline.
351
353
  */
352
354
  #fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
353
355
  const plan = route.plan
356
+ if (this.draining || request.method !== route.method) return this.handle(plan, request)
354
357
  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
- }
358
+ const wrapped = hooks.hasWrappers('http:request')
359
+ // a hooked request starts now (see handle): its event is created with the exchange
360
+ const startTime = wrapped || hooks.hasListeners('http:request') ? performance.now() : undefined
363
361
  const url = request.url
364
362
  const mark = url.indexOf('?')
365
363
  const path = mark === -1 ? url : url.slice(0, mark)
@@ -379,16 +377,13 @@ export class Pipeline {
379
377
  [HttpTokens.Context, ctx],
380
378
  [HttpTokens.RequestId, ctx.requestId],
381
379
  ])
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)
380
+ if (wrapped) {
381
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
382
+ x.done = scope.run(this.#fastWrapped, x, route)
383
+ return this.#fastExchange(x, scope)
391
384
  }
385
+ const done = scope.run(this.#fastRun, route, request, ctx, startTime)
386
+ if (done instanceof Exchange) return this.#fastExchange(done, scope)
392
387
  const closing = scope.close()
393
388
  if (closing === undefined) {
394
389
  ctx.complete()
@@ -401,9 +396,9 @@ export class Pipeline {
401
396
  (error: unknown) => this.#disposalFailed(x, error),
402
397
  )
403
398
  }
404
- // a listener registered while the handler ran (listeners are checked once the scope callback paused)
399
+ // listeners are checked once the scope callback paused (one may have been registered by the handler)
405
400
  if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
406
- const event = requestEvent(request, ctx, performance.now())
401
+ const event = requestEvent(request, ctx, startTime ?? performance.now())
407
402
  event.status = done.status
408
403
  return Promise.resolve(done).then(reply => {
409
404
  hooks.emit('http:request', event)
@@ -411,21 +406,41 @@ export class Pipeline {
411
406
  })
412
407
  }
413
408
 
409
+ /**
410
+ * The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
411
+ * it settled (see {@link #end}); listeners get the event once the reply is ready.
412
+ */
413
+ #fastExchange(x: Exchange, scope: OpenedScope): Promise<AdapterReply> {
414
+ ;(x.done as Promise<void>).then(
415
+ () => this.#end(x, scope, undefined),
416
+ (error: unknown) => this.#end(x, scope, error),
417
+ )
418
+ if (!this.#hooks.hasListeners('http:request')) return x.replied
419
+ x.tracked = true
420
+ return this.#emitted(x)
421
+ }
422
+
414
423
  /**
415
424
  * The fast lane in the request scope: the reply when the handler answered synchronously with a
416
425
  * plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
426
+ * `startTime`: the request has `http:request` listeners, its event starts with the exchange.
417
427
  */
418
- readonly #fastRun = (route: FastRoute, request: AdapterRequest, ctx: RequestContext): AdapterReply | Exchange => {
428
+ readonly #fastRun = (
429
+ route: FastRoute,
430
+ request: AdapterRequest,
431
+ ctx: RequestContext,
432
+ startTime: number | undefined,
433
+ ): AdapterReply | Exchange => {
419
434
  const plan = route.plan
420
- if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT)
435
+ if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime)
421
436
  let result: unknown
422
437
  let reply: AdapterReply | undefined
423
438
  try {
424
439
  result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
425
- if (isThenable(result)) return this.#fastLater(route, request, ctx, result)
440
+ if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
426
441
  reply = this.#fastReply(route, ctx, result)
427
442
  } catch (error) {
428
- const x = new Exchange(request, ctx, ctx.requestId, route.target)
443
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
429
444
  x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
430
445
  return x
431
446
  }
@@ -434,14 +449,20 @@ export class Pipeline {
434
449
  ctx.releaseBody()
435
450
  return reply
436
451
  }
437
- const x = new Exchange(request, ctx, ctx.requestId, route.target)
452
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
438
453
  x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
439
454
  return x
440
455
  }
441
456
 
442
457
  /** 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)
458
+ #fastLater(
459
+ route: FastRoute,
460
+ request: AdapterRequest,
461
+ ctx: RequestContext,
462
+ result: unknown,
463
+ startTime: number | undefined,
464
+ ): Exchange {
465
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
445
466
  x.done = this.#fastAsync(x, route, result)
446
467
  return x
447
468
  }
@@ -480,34 +501,206 @@ export class Pipeline {
480
501
  }
481
502
 
482
503
  /**
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`.
504
+ * The reply of a plain JSON value (see {@link isPlainJson}), of an `HttpResponse` carrying one
505
+ * (see {@link fastResponseType}) or of `undefined`: what `serializeResult` and {@link #complete}
506
+ * produce for it, the headers in their final order. `undefined` when the result or the context
507
+ * needs the general pipeline. Throws like `JSON.stringify`.
487
508
  */
488
509
  #fastReply(route: FastRoute, ctx: RequestContext, result: unknown): AdapterReply | undefined {
489
- const status = result === undefined ? route.emptyStatus : route.jsonStatus
510
+ let payload = result
511
+ let status = result === undefined ? route.emptyStatus : route.jsonStatus
512
+ let type: string | null = null
513
+ if (route.responses && result instanceof HttpResponse) {
514
+ const own = fastResponseType(result)
515
+ if (own === undefined) return undefined
516
+ type = own
517
+ payload = result.body
518
+ // `@HttpCode` > `HttpResponse.status` > the default of the route (see serializeResult)
519
+ if (route.plan.definition.status === undefined && result.status !== undefined) {
520
+ status = isBodylessStatus(result.status) ? undefined : result.status
521
+ }
522
+ // an HttpResponse without body takes the general pipeline
523
+ if (payload === undefined) return undefined
524
+ }
490
525
  // draining since the request started (`connection: close`, see #frameworkHeaders)
491
526
  if (status === undefined || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return undefined
492
527
  const requestId = ctx.requestId
493
528
  // a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
494
529
  if (!this.#validIds && !isHeaderValue(requestId)) return undefined
495
- if (result === undefined) {
530
+ if (payload === undefined) return this.#templateReply(status, null, undefined, requestId, null)
531
+ if (!isPlainJson(payload)) return undefined
532
+ const json = JSON.stringify(payload)
533
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof payload} as a response body`)
534
+ // encoded once, its length from the bytes (see serialize.ts encodeText)
535
+ return this.#templateReply(status, utf8(json), json, requestId, type)
536
+ }
537
+
538
+ /**
539
+ * The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
540
+ * the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
541
+ * can be sent). `type` replaces the JSON content type in place (the `HttpResponse`'s own).
542
+ */
543
+ #templateReply(
544
+ status: number,
545
+ body: Uint8Array | null,
546
+ text: string | undefined,
547
+ requestId: RequestId,
548
+ type: string | null,
549
+ ): AdapterReply {
550
+ if (text === undefined) {
496
551
  const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
497
552
  const idHeader = this.#emptyIdHeader
498
553
  if (idHeader !== undefined) headers[idHeader] = requestId
499
554
  return { status, headers, body: null }
500
555
  }
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
556
  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)
557
+ // assignHeaders keeps the position of a key: the content type stays in place
558
+ if (type !== null) headers['content-type'] = type
559
+ headers['content-length'] = String((body as Uint8Array).byteLength)
508
560
  const idHeader = this.#jsonIdHeader
509
561
  if (idHeader !== undefined) headers[idHeader] = requestId
510
- return { status, headers, body, text: json }
562
+ return { status, headers, body, text }
563
+ }
564
+
565
+ /**
566
+ * The fast lane through the `http:request` wrappers (registered when the request started), in the
567
+ * request scope: what {@link #respond} does for the route, the handler run inside `next()` like
568
+ * {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
569
+ * the reply completed from the template of the fast lane when the wrappers added no response header
570
+ * (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
571
+ */
572
+ readonly #fastWrapped = (x: Exchange, route: FastRoute): Promise<void> =>
573
+ this.#hooks
574
+ .run('http:request', x.event, () => this.#fastOperation(x, route))
575
+ .then(
576
+ () => this.#fastDeliver(x, false, undefined),
577
+ (error: unknown) => this.#fastDeliver(x, true, error),
578
+ )
579
+
580
+ /** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
581
+ #fastOperation(x: Exchange, route: FastRoute): Promise<Outcome> {
582
+ const ctx = x.ctx
583
+ const plan = route.plan
584
+ if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
585
+ let result: unknown
586
+ try {
587
+ result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
588
+ } catch (error) {
589
+ return this.#fastChecked(x, this.#failed(error, ctx, x.event))
590
+ }
591
+ if (isThenable(result)) return this.#fastOperationLater(x, route, result)
592
+ return this.#fastChecked(x, this.#fastOutcome(x, route, result))
593
+ }
594
+
595
+ /** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
596
+ async #fastOperationLater(x: Exchange, route: FastRoute, pending: unknown): Promise<Outcome> {
597
+ const ctx = x.ctx
598
+ const plan = route.plan
599
+ let result = pending
600
+ try {
601
+ if (result === NO_RESULT) {
602
+ const controller = this.#resolve(plan.controller)
603
+ await ctx.parseBody()
604
+ result = plan.definition.invoke(controller, ctx)
605
+ }
606
+ if (isThenable(result)) result = await result
607
+ } catch (error) {
608
+ return await this.#fastChecked(x, this.#failed(error, ctx, x.event))
609
+ }
610
+ return await this.#fastChecked(x, this.#fastOutcome(x, route, result))
611
+ }
612
+
613
+ /**
614
+ * The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
615
+ * the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
616
+ * pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
617
+ */
618
+ #fastOutcome(x: Exchange, route: FastRoute, result: unknown): Outcome | Promise<Outcome> {
619
+ const ctx = x.ctx
620
+ const event = x.event
621
+ const status = result === undefined ? route.emptyStatus : route.jsonStatus
622
+ if (status === undefined || ctx.hasResponseHeaders || (result !== undefined && !isPlainJson(result))) {
623
+ return this.#settle(ctx, route.plan, event, result)
624
+ }
625
+ let reply: SerializedReply
626
+ if (result === undefined) {
627
+ reply = { status, headers: fastRecord<HeaderValue>(), body: null }
628
+ } else {
629
+ let json: string | undefined
630
+ try {
631
+ json = JSON.stringify(result)
632
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
633
+ } catch (error) {
634
+ return this.#failed(error, ctx, event)
635
+ }
636
+ const headers = fastRecord<HeaderValue>()
637
+ headers['content-type'] = ContentType.json
638
+ const body = utf8(json)
639
+ headers['content-length'] = String(body.byteLength)
640
+ reply = { status, headers, body, text: json, ownHeaders: true }
641
+ }
642
+ event.status = status
643
+ x.fast = true
644
+ return reply
645
+ }
646
+
647
+ /**
648
+ * What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
649
+ * headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
650
+ */
651
+ #fastChecked(x: Exchange, pending: Outcome | Promise<Outcome>): Promise<Outcome> {
652
+ if (pending instanceof Promise) return pending.then(outcome => this.#fastKeep(x, outcome))
653
+ return Promise.resolve(this.#fastKeep(x, pending))
654
+ }
655
+
656
+ #fastKeep(x: Exchange, outcome: Outcome): Outcome {
657
+ // a reply of #fastOutcome is valid by construction (what #checked returns for it)
658
+ const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event)
659
+ if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext()
660
+ x.outcome = checked
661
+ return checked
662
+ }
663
+
664
+ /**
665
+ * Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
666
+ * headers of the template when nothing was added to it (no response header from the wrappers, no
667
+ * cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
668
+ * anything else is completed by {@link #deliver}. Never rejects.
669
+ */
670
+ #fastDeliver(x: Exchange, failed: boolean, hookError: unknown): Promise<void> | undefined {
671
+ const outcome = x.outcome
672
+ const ctx = x.ctx
673
+ if (outcome === undefined) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED)
674
+ if (failed) this.#wrapperFailed(x, hookError)
675
+ else if (
676
+ x.fast &&
677
+ outcome !== RAW &&
678
+ !hasOwnKeys(x.event.responseHeaders) &&
679
+ !ctx.hasCookies &&
680
+ !this.draining &&
681
+ (this.#validIds || isHeaderValue(x.requestId))
682
+ ) {
683
+ const reply = this.#templateReply(
684
+ outcome.status,
685
+ outcome.body as Uint8Array | null,
686
+ outcome.text,
687
+ x.requestId,
688
+ null,
689
+ )
690
+ ctx.commit()
691
+ ctx.releaseBody()
692
+ x.answer(reply)
693
+ return undefined
694
+ }
695
+ const streaming = this.#send(x, outcome, true, x.run)
696
+ if (streaming === undefined) return undefined
697
+ return streaming.tracked.done.then(failure => this.#streamed(x, streaming, failure))
698
+ }
699
+
700
+ /** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
701
+ async #fastUnhandled(x: Exchange, failure: unknown): Promise<void> {
702
+ const streaming = this.#send(x, await this.#unhandled(x, failure), true, undefined)
703
+ if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
511
704
  }
512
705
 
513
706
  /** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
@@ -752,35 +945,61 @@ export class Pipeline {
752
945
  hookError = error
753
946
  }
754
947
  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
948
+ outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED)
762
949
  recheck = true
763
950
  } else if (failed) {
764
- this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
765
- err: hookError,
766
- requestId,
767
- })
951
+ this.#wrapperFailed(x, hookError)
768
952
  }
953
+ const streaming = this.#send(x, outcome, recheck, run)
954
+ if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
955
+ }
956
+
957
+ /**
958
+ * The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
959
+ * did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
960
+ * exception mapper's `HttpResponse` may carry cookies). Never rejects.
961
+ */
962
+ async #unhandled(x: Exchange, failure: unknown): Promise<CheckedReply> {
963
+ const ctx = x.ctx
964
+ const event = x.event
965
+ const error = failure === NOT_CALLED ? new Error('An "http:request" hook wrapper did not call next()') : failure
966
+ event.error ??= error
967
+ const fallback = await this.#errorReply(error, ctx)
968
+ const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
969
+ event.status = reply.status
970
+ return reply
971
+ }
972
+
973
+ #wrapperFailed(x: Exchange, error: unknown): void {
974
+ this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
975
+ err: error,
976
+ requestId: x.requestId,
977
+ })
978
+ }
979
+
980
+ /**
981
+ * Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
982
+ * Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
983
+ * captured), whose end {@link #streamed} records.
984
+ */
985
+ #send(x: Exchange, outcome: Outcome, recheck: boolean, run: ContextRunner | undefined): Streaming | undefined {
986
+ const ctx = x.ctx
987
+ const event = x.event
769
988
  if (outcome === RAW) {
770
989
  ctx.commit()
771
990
  const status = x.request.rawStatus?.() ?? 200
772
991
  event.status ??= status
773
992
  x.answer({ status, headers: {}, body: null, raw: true })
774
- return
993
+ return undefined
775
994
  }
776
- const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck)
995
+ const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck)
777
996
  const body = serialized.body
778
997
  if (!isStreamed(body)) {
779
998
  const reply = adapterReply(serialized, body)
780
999
  ctx.commit()
781
1000
  ctx.releaseBody()
782
1001
  x.answer(reply)
783
- return
1002
+ return undefined
784
1003
  }
785
1004
  // files too: a file-backed Blob is read lazily, after the handler returned
786
1005
  const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
@@ -792,10 +1011,15 @@ export class Pipeline {
792
1011
  }
793
1012
  ctx.commit()
794
1013
  x.answer(reply)
795
- const failure = await tracked.done
796
- if (session !== undefined) this.#streams.delete(session)
1014
+ return { tracked, session }
1015
+ }
1016
+
1017
+ /** A streamed body was sent or abandoned (`failure`: the stream failed). */
1018
+ #streamed(x: Exchange, streaming: Streaming, failure: unknown): void {
1019
+ const ctx = x.ctx
1020
+ if (streaming.session !== undefined) this.#streams.delete(streaming.session)
797
1021
  if (failure !== undefined) {
798
- this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
1022
+ this.#logger.error('The response stream failed', { err: failure, requestId: x.requestId, path: ctx.path })
799
1023
  }
800
1024
  ctx.releaseBody()
801
1025
  }
@@ -1271,11 +1495,22 @@ interface FastRoute {
1271
1495
  readonly jsonStatus: number | undefined
1272
1496
  /** The status of a reply without value (`undefined` returned). */
1273
1497
  readonly emptyStatus: number
1498
+ /** An `HttpResponse` carrying a plain JSON body may be served (not on a `@Head` route: no body). */
1499
+ readonly responses: boolean
1274
1500
  }
1275
1501
 
1276
1502
  /** `#fastAsync` reads the body and runs the handler. */
1277
1503
  const NO_RESULT: unique symbol = Symbol('no result')
1278
1504
 
1505
+ /** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
1506
+ const NOT_CALLED: unique symbol = Symbol('next() not called')
1507
+
1508
+ /** A streamed body handed to the adapter, until it was sent or abandoned (see `Pipeline.#send`). */
1509
+ interface Streaming {
1510
+ readonly tracked: TrackedStream
1511
+ readonly session: SseSession | undefined
1512
+ }
1513
+
1279
1514
  /** A request being answered (internal): its context, its target and the promise of its reply. */
1280
1515
  class Exchange {
1281
1516
  readonly request: AdapterRequest
@@ -1290,6 +1525,12 @@ class Exchange {
1290
1525
  tracked = false
1291
1526
  /** The asynchronous part of a fast-lane request (never rejects). */
1292
1527
  done: Promise<void> | undefined = undefined
1528
+ /** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
1529
+ outcome: Outcome | undefined = undefined
1530
+ /** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
1531
+ fast = false
1532
+ /** Fast lane with wrappers: the async context of the handler, for a streamed body. */
1533
+ run: ContextRunner | undefined = undefined
1293
1534
  readonly #resolve: (reply: AdapterReply) => void
1294
1535
  readonly #reject: (error: unknown) => void
1295
1536
  #event: HttpRequestEvent | undefined
@@ -1345,6 +1586,12 @@ function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: n
1345
1586
  }
1346
1587
  }
1347
1588
 
1589
+ /** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
1590
+ function hasOwnKeys(record: object): boolean {
1591
+ for (const key in record) if (Object.hasOwn(record, key)) return true
1592
+ return false
1593
+ }
1594
+
1348
1595
  /**
1349
1596
  * The decoded parameters of a raw request path matched against a route, in one pass: what
1350
1597
  * `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
@@ -1401,6 +1648,23 @@ function isPlainJson(value: unknown): value is object {
1401
1648
  return typeof (value as { pipe?: unknown }).pipe !== 'function'
1402
1649
  }
1403
1650
 
1651
+ /**
1652
+ * The content type of an `HttpResponse` the fast lane can serve: `null` without headers, its
1653
+ * `content-type` when that is its only header (lower-case name, a value every adapter can send);
1654
+ * `undefined` when it needs the general pipeline (other headers, cookies).
1655
+ */
1656
+ function fastResponseType(response: HttpResponse): string | null | undefined {
1657
+ if (response.cookies.length > 0) return undefined
1658
+ const headers = response.headers
1659
+ let type: string | null = null
1660
+ for (const name of Object.keys(headers)) {
1661
+ const value = headers[name]
1662
+ if (name !== 'content-type' || typeof value !== 'string' || !isHeaderValue(value)) return undefined
1663
+ type = value
1664
+ }
1665
+ return type
1666
+ }
1667
+
1404
1668
  const ENCODER = new TextEncoder()
1405
1669
 
1406
1670
  /** UTF-8 encoding of a text body, like the serialiser (a pooled `Buffer` on Node.js, `TextEncoder` on Bun). */
@@ -8,11 +8,13 @@ import type { HttpContext } from './context.ts'
8
8
  import type { RouteMetaEntry } from './decorators.ts'
9
9
  import type { HttpMethod } from './metadata.ts'
10
10
  import type { GuardRef } from './plugin.ts'
11
+ import type { ByteSize } from './size.ts'
11
12
 
12
13
  import { ErrorCode, ZnightError } from '@znight/core'
13
14
 
14
15
  import { isGroupOf } from './decorators.ts'
15
16
  import { joinPaths, normalizePath, parseRoutePath, type RoutePath, RoutePathError } from './path.ts'
17
+ import { parseByteSize } from './size.ts'
16
18
 
17
19
  /**
18
20
  * A route served by `HttpPlugin` on behalf of another plugin. It runs through the same pipeline as
@@ -35,6 +37,13 @@ export interface PluginRoute {
35
37
  readonly meta?: readonly RouteMetaEntry[]
36
38
  /** Guards of the route (provider classes or instances), run after the global guards. */
37
39
  readonly guards?: readonly GuardRef[]
40
+ /**
41
+ * Maximum body size of this route (`'100kb'`, `1048576`, `Infinity`), with the grammar of the
42
+ * `HttpPlugin` option `bodyLimit`, which it replaces for this route only (lower or higher, like
43
+ * `@BodyLimit` on a controller route). Past it, reading the body answers 413. Default: the
44
+ * `HttpPlugin` option.
45
+ */
46
+ readonly bodyLimit?: ByteSize
38
47
  }
39
48
 
40
49
  /** What {@link PluginHttpRoutes.httpRoutes} receives. */
@@ -78,6 +87,8 @@ export interface CollectedPluginRoute {
78
87
  readonly handler: (ctx: HttpContext) => unknown
79
88
  readonly meta: readonly NormalizedMetaEntry[]
80
89
  readonly guards: readonly unknown[]
90
+ /** Parsed `bodyLimit` in bytes, `undefined`: the plugin limit. */
91
+ readonly bodyLimit: number | undefined
81
92
  }
82
93
 
83
94
  /** A plugin route metadata entry with its group (internal). */
@@ -147,6 +158,13 @@ function validateRoute(instance: object, plugin: string, value: unknown, prefix:
147
158
  }
148
159
  const guards = route.guards ?? []
149
160
  if (!Array.isArray(guards)) throw invalid(plugin, `the guards of the route ${where} must be an array`)
161
+ const bodyLimit = route.bodyLimit === undefined ? undefined : parseByteSize(route.bodyLimit)
162
+ if (route.bodyLimit !== undefined && bodyLimit === undefined) {
163
+ throw invalid(
164
+ plugin,
165
+ `invalid bodyLimit ${describe(route.bodyLimit)} of the route ${where}: expected a size in bytes (1048576, '100kb', Infinity)`,
166
+ )
167
+ }
150
168
  return {
151
169
  plugin,
152
170
  instance,
@@ -157,6 +175,7 @@ function validateRoute(instance: object, plugin: string, value: unknown, prefix:
157
175
  handler: route.handler,
158
176
  meta: entries,
159
177
  guards,
178
+ bodyLimit,
160
179
  }
161
180
  }
162
181
 
package/src/plugin.ts CHANGED
@@ -1035,7 +1035,12 @@ function buildRegistrations(
1035
1035
  invoke: (_plugin: unknown, http: HttpContext): unknown => handler(http),
1036
1036
  })
1037
1037
  const instance: Resolvable<object> = { instance: route.instance }
1038
- const body = bodyPlan({}, options.bodyLimit, options.uploads)
1038
+ // the route's own limit (`bodyLimit`), otherwise the plugin limit
1039
+ const body = bodyPlan(
1040
+ route.bodyLimit === undefined ? {} : { bodyLimit: route.bodyLimit },
1041
+ options.bodyLimit,
1042
+ options.uploads,
1043
+ )
1039
1044
  const chain = guards.map(guard => guard.resolvable)
1040
1045
  const path = route.path
1041
1046
  declared.push({