@znight/http 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 }
@@ -339,27 +339,24 @@ export class Pipeline {
339
339
 
340
340
  /**
341
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).
342
+ * of per request, without promises while the route stays synchronous. A request takes it when the
343
+ * server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
344
+ * plain object or array returned by the handler is serialised as JSON, and `undefined` answered
345
+ * without body, with the headers in their final order (a template precomputed at boot).
347
346
  * A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
348
347
  * path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
349
348
  * or any other result, headers or cookies set through the context — continues in the general
350
349
  * pipeline, in the same request scope, with the same outcome.
350
+ * With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
351
+ * get the event once the reply is ready, as in the general pipeline.
351
352
  */
352
353
  #fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
353
354
  const plan = route.plan
355
+ if (this.draining || request.method !== route.method) return this.handle(plan, request)
354
356
  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
- }
357
+ const wrapped = hooks.hasWrappers('http:request')
358
+ // a hooked request starts now (see handle): its event is created with the exchange
359
+ const startTime = wrapped || hooks.hasListeners('http:request') ? performance.now() : undefined
363
360
  const url = request.url
364
361
  const mark = url.indexOf('?')
365
362
  const path = mark === -1 ? url : url.slice(0, mark)
@@ -379,16 +376,13 @@ export class Pipeline {
379
376
  [HttpTokens.Context, ctx],
380
377
  [HttpTokens.RequestId, ctx.requestId],
381
378
  ])
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)
379
+ if (wrapped) {
380
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
381
+ x.done = scope.run(this.#fastWrapped, x, route)
382
+ return this.#fastExchange(x, scope)
391
383
  }
384
+ const done = scope.run(this.#fastRun, route, request, ctx, startTime)
385
+ if (done instanceof Exchange) return this.#fastExchange(done, scope)
392
386
  const closing = scope.close()
393
387
  if (closing === undefined) {
394
388
  ctx.complete()
@@ -401,9 +395,9 @@ export class Pipeline {
401
395
  (error: unknown) => this.#disposalFailed(x, error),
402
396
  )
403
397
  }
404
- // a listener registered while the handler ran (listeners are checked once the scope callback paused)
398
+ // listeners are checked once the scope callback paused (one may have been registered by the handler)
405
399
  if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
406
- const event = requestEvent(request, ctx, performance.now())
400
+ const event = requestEvent(request, ctx, startTime ?? performance.now())
407
401
  event.status = done.status
408
402
  return Promise.resolve(done).then(reply => {
409
403
  hooks.emit('http:request', event)
@@ -411,21 +405,41 @@ export class Pipeline {
411
405
  })
412
406
  }
413
407
 
408
+ /**
409
+ * The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
410
+ * it settled (see {@link #end}); listeners get the event once the reply is ready.
411
+ */
412
+ #fastExchange(x: Exchange, scope: OpenedScope): Promise<AdapterReply> {
413
+ ;(x.done as Promise<void>).then(
414
+ () => this.#end(x, scope, undefined),
415
+ (error: unknown) => this.#end(x, scope, error),
416
+ )
417
+ if (!this.#hooks.hasListeners('http:request')) return x.replied
418
+ x.tracked = true
419
+ return this.#emitted(x)
420
+ }
421
+
414
422
  /**
415
423
  * The fast lane in the request scope: the reply when the handler answered synchronously with a
416
424
  * plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
425
+ * `startTime`: the request has `http:request` listeners, its event starts with the exchange.
417
426
  */
418
- readonly #fastRun = (route: FastRoute, request: AdapterRequest, ctx: RequestContext): AdapterReply | Exchange => {
427
+ readonly #fastRun = (
428
+ route: FastRoute,
429
+ request: AdapterRequest,
430
+ ctx: RequestContext,
431
+ startTime: number | undefined,
432
+ ): AdapterReply | Exchange => {
419
433
  const plan = route.plan
420
- if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT)
434
+ if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime)
421
435
  let result: unknown
422
436
  let reply: AdapterReply | undefined
423
437
  try {
424
438
  result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
425
- if (isThenable(result)) return this.#fastLater(route, request, ctx, result)
439
+ if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
426
440
  reply = this.#fastReply(route, ctx, result)
427
441
  } catch (error) {
428
- const x = new Exchange(request, ctx, ctx.requestId, route.target)
442
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
429
443
  x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
430
444
  return x
431
445
  }
@@ -434,14 +448,20 @@ export class Pipeline {
434
448
  ctx.releaseBody()
435
449
  return reply
436
450
  }
437
- const x = new Exchange(request, ctx, ctx.requestId, route.target)
451
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
438
452
  x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
439
453
  return x
440
454
  }
441
455
 
442
456
  /** 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)
457
+ #fastLater(
458
+ route: FastRoute,
459
+ request: AdapterRequest,
460
+ ctx: RequestContext,
461
+ result: unknown,
462
+ startTime: number | undefined,
463
+ ): Exchange {
464
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
445
465
  x.done = this.#fastAsync(x, route, result)
446
466
  return x
447
467
  }
@@ -492,22 +512,171 @@ export class Pipeline {
492
512
  const requestId = ctx.requestId
493
513
  // a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
494
514
  if (!this.#validIds && !isHeaderValue(requestId)) return undefined
495
- if (result === undefined) {
515
+ if (result === undefined) return this.#templateReply(status, null, undefined, requestId)
516
+ if (!isPlainJson(result)) return undefined
517
+ const json = JSON.stringify(result)
518
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
519
+ // encoded once, its length from the bytes (see serialize.ts encodeText)
520
+ return this.#templateReply(status, utf8(json), json, requestId)
521
+ }
522
+
523
+ /**
524
+ * The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
525
+ * the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
526
+ * can be sent).
527
+ */
528
+ #templateReply(
529
+ status: number,
530
+ body: Uint8Array | null,
531
+ text: string | undefined,
532
+ requestId: RequestId,
533
+ ): AdapterReply {
534
+ if (text === undefined) {
496
535
  const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
497
536
  const idHeader = this.#emptyIdHeader
498
537
  if (idHeader !== undefined) headers[idHeader] = requestId
499
538
  return { status, headers, body: null }
500
539
  }
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
540
  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)
541
+ headers['content-length'] = String((body as Uint8Array).byteLength)
508
542
  const idHeader = this.#jsonIdHeader
509
543
  if (idHeader !== undefined) headers[idHeader] = requestId
510
- return { status, headers, body, text: json }
544
+ return { status, headers, body, text }
545
+ }
546
+
547
+ /**
548
+ * The fast lane through the `http:request` wrappers (registered when the request started), in the
549
+ * request scope: what {@link #respond} does for the route, the handler run inside `next()` like
550
+ * {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
551
+ * the reply completed from the template of the fast lane when the wrappers added no response header
552
+ * (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
553
+ */
554
+ readonly #fastWrapped = (x: Exchange, route: FastRoute): Promise<void> =>
555
+ this.#hooks
556
+ .run('http:request', x.event, () => this.#fastOperation(x, route))
557
+ .then(
558
+ () => this.#fastDeliver(x, false, undefined),
559
+ (error: unknown) => this.#fastDeliver(x, true, error),
560
+ )
561
+
562
+ /** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
563
+ #fastOperation(x: Exchange, route: FastRoute): Promise<Outcome> {
564
+ const ctx = x.ctx
565
+ const plan = route.plan
566
+ if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
567
+ let result: unknown
568
+ try {
569
+ result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
570
+ } catch (error) {
571
+ return this.#fastChecked(x, this.#failed(error, ctx, x.event))
572
+ }
573
+ if (isThenable(result)) return this.#fastOperationLater(x, route, result)
574
+ return this.#fastChecked(x, this.#fastOutcome(x, route, result))
575
+ }
576
+
577
+ /** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
578
+ async #fastOperationLater(x: Exchange, route: FastRoute, pending: unknown): Promise<Outcome> {
579
+ const ctx = x.ctx
580
+ const plan = route.plan
581
+ let result = pending
582
+ try {
583
+ if (result === NO_RESULT) {
584
+ const controller = this.#resolve(plan.controller)
585
+ await ctx.parseBody()
586
+ result = plan.definition.invoke(controller, ctx)
587
+ }
588
+ if (isThenable(result)) result = await result
589
+ } catch (error) {
590
+ return await this.#fastChecked(x, this.#failed(error, ctx, x.event))
591
+ }
592
+ return await this.#fastChecked(x, this.#fastOutcome(x, route, result))
593
+ }
594
+
595
+ /**
596
+ * The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
597
+ * the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
598
+ * pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
599
+ */
600
+ #fastOutcome(x: Exchange, route: FastRoute, result: unknown): Outcome | Promise<Outcome> {
601
+ const ctx = x.ctx
602
+ const event = x.event
603
+ const status = result === undefined ? route.emptyStatus : route.jsonStatus
604
+ if (status === undefined || ctx.hasResponseHeaders || (result !== undefined && !isPlainJson(result))) {
605
+ return this.#settle(ctx, route.plan, event, result)
606
+ }
607
+ let reply: SerializedReply
608
+ if (result === undefined) {
609
+ reply = { status, headers: fastRecord<HeaderValue>(), body: null }
610
+ } else {
611
+ let json: string | undefined
612
+ try {
613
+ json = JSON.stringify(result)
614
+ if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
615
+ } catch (error) {
616
+ return this.#failed(error, ctx, event)
617
+ }
618
+ const headers = fastRecord<HeaderValue>()
619
+ headers['content-type'] = ContentType.json
620
+ const body = utf8(json)
621
+ headers['content-length'] = String(body.byteLength)
622
+ reply = { status, headers, body, text: json, ownHeaders: true }
623
+ }
624
+ event.status = status
625
+ x.fast = true
626
+ return reply
627
+ }
628
+
629
+ /**
630
+ * What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
631
+ * headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
632
+ */
633
+ #fastChecked(x: Exchange, pending: Outcome | Promise<Outcome>): Promise<Outcome> {
634
+ if (pending instanceof Promise) return pending.then(outcome => this.#fastKeep(x, outcome))
635
+ return Promise.resolve(this.#fastKeep(x, pending))
636
+ }
637
+
638
+ #fastKeep(x: Exchange, outcome: Outcome): Outcome {
639
+ // a reply of #fastOutcome is valid by construction (what #checked returns for it)
640
+ const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event)
641
+ if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext()
642
+ x.outcome = checked
643
+ return checked
644
+ }
645
+
646
+ /**
647
+ * Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
648
+ * headers of the template when nothing was added to it (no response header from the wrappers, no
649
+ * cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
650
+ * anything else is completed by {@link #deliver}. Never rejects.
651
+ */
652
+ #fastDeliver(x: Exchange, failed: boolean, hookError: unknown): Promise<void> | undefined {
653
+ const outcome = x.outcome
654
+ const ctx = x.ctx
655
+ if (outcome === undefined) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED)
656
+ if (failed) this.#wrapperFailed(x, hookError)
657
+ else if (
658
+ x.fast &&
659
+ outcome !== RAW &&
660
+ !hasOwnKeys(x.event.responseHeaders) &&
661
+ !ctx.hasCookies &&
662
+ !this.draining &&
663
+ (this.#validIds || isHeaderValue(x.requestId))
664
+ ) {
665
+ const reply = this.#templateReply(outcome.status, outcome.body as Uint8Array | null, outcome.text, x.requestId)
666
+ ctx.commit()
667
+ ctx.releaseBody()
668
+ x.answer(reply)
669
+ return undefined
670
+ }
671
+ const streaming = this.#send(x, outcome, true, x.run)
672
+ if (streaming === undefined) return undefined
673
+ return streaming.tracked.done.then(failure => this.#streamed(x, streaming, failure))
674
+ }
675
+
676
+ /** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
677
+ async #fastUnhandled(x: Exchange, failure: unknown): Promise<void> {
678
+ const streaming = this.#send(x, await this.#unhandled(x, failure), true, undefined)
679
+ if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
511
680
  }
512
681
 
513
682
  /** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
@@ -752,35 +921,61 @@ export class Pipeline {
752
921
  hookError = error
753
922
  }
754
923
  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
924
+ outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED)
762
925
  recheck = true
763
926
  } else if (failed) {
764
- this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
765
- err: hookError,
766
- requestId,
767
- })
927
+ this.#wrapperFailed(x, hookError)
768
928
  }
929
+ const streaming = this.#send(x, outcome, recheck, run)
930
+ if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
931
+ }
932
+
933
+ /**
934
+ * The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
935
+ * did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
936
+ * exception mapper's `HttpResponse` may carry cookies). Never rejects.
937
+ */
938
+ async #unhandled(x: Exchange, failure: unknown): Promise<CheckedReply> {
939
+ const ctx = x.ctx
940
+ const event = x.event
941
+ const error = failure === NOT_CALLED ? new Error('An "http:request" hook wrapper did not call next()') : failure
942
+ event.error ??= error
943
+ const fallback = await this.#errorReply(error, ctx)
944
+ const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
945
+ event.status = reply.status
946
+ return reply
947
+ }
948
+
949
+ #wrapperFailed(x: Exchange, error: unknown): void {
950
+ this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
951
+ err: error,
952
+ requestId: x.requestId,
953
+ })
954
+ }
955
+
956
+ /**
957
+ * Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
958
+ * Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
959
+ * captured), whose end {@link #streamed} records.
960
+ */
961
+ #send(x: Exchange, outcome: Outcome, recheck: boolean, run: ContextRunner | undefined): Streaming | undefined {
962
+ const ctx = x.ctx
963
+ const event = x.event
769
964
  if (outcome === RAW) {
770
965
  ctx.commit()
771
966
  const status = x.request.rawStatus?.() ?? 200
772
967
  event.status ??= status
773
968
  x.answer({ status, headers: {}, body: null, raw: true })
774
- return
969
+ return undefined
775
970
  }
776
- const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck)
971
+ const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck)
777
972
  const body = serialized.body
778
973
  if (!isStreamed(body)) {
779
974
  const reply = adapterReply(serialized, body)
780
975
  ctx.commit()
781
976
  ctx.releaseBody()
782
977
  x.answer(reply)
783
- return
978
+ return undefined
784
979
  }
785
980
  // files too: a file-backed Blob is read lazily, after the handler returned
786
981
  const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
@@ -792,10 +987,15 @@ export class Pipeline {
792
987
  }
793
988
  ctx.commit()
794
989
  x.answer(reply)
795
- const failure = await tracked.done
796
- if (session !== undefined) this.#streams.delete(session)
990
+ return { tracked, session }
991
+ }
992
+
993
+ /** A streamed body was sent or abandoned (`failure`: the stream failed). */
994
+ #streamed(x: Exchange, streaming: Streaming, failure: unknown): void {
995
+ const ctx = x.ctx
996
+ if (streaming.session !== undefined) this.#streams.delete(streaming.session)
797
997
  if (failure !== undefined) {
798
- this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
998
+ this.#logger.error('The response stream failed', { err: failure, requestId: x.requestId, path: ctx.path })
799
999
  }
800
1000
  ctx.releaseBody()
801
1001
  }
@@ -1276,6 +1476,15 @@ interface FastRoute {
1276
1476
  /** `#fastAsync` reads the body and runs the handler. */
1277
1477
  const NO_RESULT: unique symbol = Symbol('no result')
1278
1478
 
1479
+ /** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
1480
+ const NOT_CALLED: unique symbol = Symbol('next() not called')
1481
+
1482
+ /** A streamed body handed to the adapter, until it was sent or abandoned (see `Pipeline.#send`). */
1483
+ interface Streaming {
1484
+ readonly tracked: TrackedStream
1485
+ readonly session: SseSession | undefined
1486
+ }
1487
+
1279
1488
  /** A request being answered (internal): its context, its target and the promise of its reply. */
1280
1489
  class Exchange {
1281
1490
  readonly request: AdapterRequest
@@ -1290,6 +1499,12 @@ class Exchange {
1290
1499
  tracked = false
1291
1500
  /** The asynchronous part of a fast-lane request (never rejects). */
1292
1501
  done: Promise<void> | undefined = undefined
1502
+ /** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
1503
+ outcome: Outcome | undefined = undefined
1504
+ /** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
1505
+ fast = false
1506
+ /** Fast lane with wrappers: the async context of the handler, for a streamed body. */
1507
+ run: ContextRunner | undefined = undefined
1293
1508
  readonly #resolve: (reply: AdapterReply) => void
1294
1509
  readonly #reject: (error: unknown) => void
1295
1510
  #event: HttpRequestEvent | undefined
@@ -1345,6 +1560,12 @@ function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: n
1345
1560
  }
1346
1561
  }
1347
1562
 
1563
+ /** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
1564
+ function hasOwnKeys(record: object): boolean {
1565
+ for (const key in record) if (Object.hasOwn(record, key)) return true
1566
+ return false
1567
+ }
1568
+
1348
1569
  /**
1349
1570
  * The decoded parameters of a raw request path matched against a route, in one pass: what
1350
1571
  * `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
@@ -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({