@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/dist/index.d.mts CHANGED
@@ -654,14 +654,31 @@ interface RouteMetaEntry<T = unknown> {
654
654
  /** Default `false`. */
655
655
  readonly required?: boolean;
656
656
  }
657
+ /**
658
+ * Anything that carries effective route metadata: the {@link HttpContext} of a request, and the
659
+ * contexts of other transports that reuse route decorators (a GraphQL resolver context, a WebSocket
660
+ * gateway context). {@link RouteDecorator.get} reads `route.meta[key]`; nothing else is required.
661
+ *
662
+ * ```ts
663
+ * const carrier: RouteMetaCarrier = { route: { meta: { roles: ['admin'] } } }
664
+ * Roles.get(carrier) // ['admin']
665
+ * ```
666
+ */
667
+ interface RouteMetaCarrier {
668
+ /** The matched route (or field, or handler), `undefined` when there is none. */
669
+ readonly route?: {
670
+ readonly meta: Readonly<Record<string, unknown>>;
671
+ } | undefined;
672
+ }
657
673
  /** A user-defined route metadata decorator created with {@link createRouteDecorator}. */
658
674
  interface RouteDecorator<T> extends RouteDecoratorInfo {
659
675
  (...args: RouteDecoratorArgs<T>): ClassMarker & MethodMarker;
660
676
  /**
661
677
  * The value of the current route, if set. Reads the effective metadata: the route's decision of
662
- * a group wins over the controller's.
678
+ * a group wins over the controller's. Accepts any {@link RouteMetaCarrier}: an
679
+ * {@link HttpContext}, or the context of another transport exposing `route.meta`.
663
680
  */
664
- get(ctx: HttpContext): T | undefined;
681
+ get(ctx: RouteMetaCarrier): T | undefined;
665
682
  /**
666
683
  * The same metadata as data (`{ key, value, group, required }`), for routes that are not compiled
667
684
  * controllers (plugin routes: `OpenApiPlugin.configure({ meta: [Public.meta()] })`). The value is
@@ -1113,6 +1130,13 @@ interface PluginRoute {
1113
1130
  readonly meta?: readonly RouteMetaEntry[];
1114
1131
  /** Guards of the route (provider classes or instances), run after the global guards. */
1115
1132
  readonly guards?: readonly GuardRef[];
1133
+ /**
1134
+ * Maximum body size of this route (`'100kb'`, `1048576`, `Infinity`), with the grammar of the
1135
+ * `HttpPlugin` option `bodyLimit`, which it replaces for this route only (lower or higher, like
1136
+ * `@BodyLimit` on a controller route). Past it, reading the body answers 413. Default: the
1137
+ * `HttpPlugin` option.
1138
+ */
1139
+ readonly bodyLimit?: ByteSize;
1116
1140
  }
1117
1141
  /** What {@link PluginHttpRoutes.httpRoutes} receives. */
1118
1142
  interface PluginHttpRoutesContext {
@@ -1258,5 +1282,5 @@ export declare function isThenable<T>(value: T): value is unknown extends T ? T
1258
1282
  * @throws TypeError when `schema` is not a Standard Schema v1 (a programming error → 500).
1259
1283
  */
1260
1284
  export declare function validate<S extends StandardSchemaV1>(schema: S, value: unknown, location: string): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>>;
1261
- export { type AdapterCloseOptions, type AdapterHandler, type AdapterReply, type AdapterRequest, type AdapterRoute, BadGatewayException, BadRequestException, type ByteRange, type ByteSize, type CallHandler, type CanActivate, type ClearCookieOptions, type Coercer, type Coercers, ConflictException, type ContentDispositionType, type ControllerOptions, type CookieDefaults, type CookieOptions, type CorsOptions, type CorsOrigin, type EnforcementMode, type EventChannelOptions, type ExceptionMapper, type ExceptionMapperClass, type ExceptionMapperRef, type ExceptionMapperResult, type FallbackHandler, type FileBody, type FileResponseOptions, ForbiddenException, GatewayTimeoutException, GoneException, type GuardClass, type GuardEnforcement, type GuardRef, type HeaderValue, type HstsOptions, type HttpAdapter, type HttpContext, type HttpControllerDefinition, type HttpCookieOptions, type HttpErrorBody, HttpException, type HttpExceptionOptions, type HttpInterceptor, type HttpInterceptorClass, type HttpMetadata, type HttpMethod, type HttpParamDefinition, type HttpParamSource, type HttpPluginOptions, type HttpResponseInit, type HttpRouteDefinition, type HttpRouteGroupDefinition, type HttpRouteInfo, type HttpRouteSummary, type HttpServerAddress, type HttpSseDefinition, type HttpSseOptions, type HttpUploadDefinition, type InferSchemaInput, type InferSchemaOutput, type InjectRequest, type InjectResponse, type InjectSseResponse, type InjectSseWaitOptions, type InjectStreamOptions, type InjectStreamRequest, type InjectStreamResponse, type InterceptorRef, InternalServerErrorException, type MediaType, MethodNotAllowedException, type NodeAdapter, type NodeAdapterOptions, NotFoundException, NotImplementedException, type ParseSseOptions, PayloadTooLargeException, type PluginHttpRoutes, type PluginHttpRoutesContext, type PluginRoute, type QueryRecord, type RedirectStatus, type ReplyBody, type RequestAbortKind, RequestAbortedError, type RequestId, type ResponseCookie, type RouteDecorator, type RouteDecoratorArgs, type RouteDecoratorInfo, type RouteDecoratorOptions, type RouteMetaEntry, type SecurityHeadersOptions, ServiceUnavailableException, type SetCookieInfo, type SseBody, type SseEvent, type SseItem, type SseMessage, type SseOptions, type SseSource, type StandardSchemaV1, type StandardSchemaV1FailureResult, type StandardSchemaV1Issue, type StandardSchemaV1Options, type StandardSchemaV1PathSegment, type StandardSchemaV1Props, type StandardSchemaV1Result, type StandardSchemaV1SuccessResult, type StandardSchemaV1Types, type StreamResponseOptions, type StreamSource, TooManyRequestsException, type TrailingSlashPolicy, type TrustProxy, UnauthorizedException, UnprocessableEntityException, UnsupportedMediaTypeException, type UploadLimits, type UploadedFileOptions, type UploadedFilesOptions, type Uploads, ValidationException, type ValidationIssueDetail, formatByteSize, isHttpException, isStandardSchema, nodeAdapter, parseByteSize, parseCookieHeader, parseQuery, parseSetCookie, serializeClearCookie, serializeCookie, toIssueDetail, uploads };
1285
+ export { type AdapterCloseOptions, type AdapterHandler, type AdapterReply, type AdapterRequest, type AdapterRoute, BadGatewayException, BadRequestException, type ByteRange, type ByteSize, type CallHandler, type CanActivate, type ClearCookieOptions, type Coercer, type Coercers, ConflictException, type ContentDispositionType, type ControllerOptions, type CookieDefaults, type CookieOptions, type CorsOptions, type CorsOrigin, type EnforcementMode, type EventChannelOptions, type ExceptionMapper, type ExceptionMapperClass, type ExceptionMapperRef, type ExceptionMapperResult, type FallbackHandler, type FileBody, type FileResponseOptions, ForbiddenException, GatewayTimeoutException, GoneException, type GuardClass, type GuardEnforcement, type GuardRef, type HeaderValue, type HstsOptions, type HttpAdapter, type HttpContext, type HttpControllerDefinition, type HttpCookieOptions, type HttpErrorBody, HttpException, type HttpExceptionOptions, type HttpInterceptor, type HttpInterceptorClass, type HttpMetadata, type HttpMethod, type HttpParamDefinition, type HttpParamSource, type HttpPluginOptions, type HttpResponseInit, type HttpRouteDefinition, type HttpRouteGroupDefinition, type HttpRouteInfo, type HttpRouteSummary, type HttpServerAddress, type HttpSseDefinition, type HttpSseOptions, type HttpUploadDefinition, type InferSchemaInput, type InferSchemaOutput, type InjectRequest, type InjectResponse, type InjectSseResponse, type InjectSseWaitOptions, type InjectStreamOptions, type InjectStreamRequest, type InjectStreamResponse, type InterceptorRef, InternalServerErrorException, type MediaType, MethodNotAllowedException, type NodeAdapter, type NodeAdapterOptions, NotFoundException, NotImplementedException, type ParseSseOptions, PayloadTooLargeException, type PluginHttpRoutes, type PluginHttpRoutesContext, type PluginRoute, type QueryRecord, type RedirectStatus, type ReplyBody, type RequestAbortKind, RequestAbortedError, type RequestId, type ResponseCookie, type RouteDecorator, type RouteDecoratorArgs, type RouteDecoratorInfo, type RouteDecoratorOptions, type RouteMetaCarrier, type RouteMetaEntry, type SecurityHeadersOptions, ServiceUnavailableException, type SetCookieInfo, type SseBody, type SseEvent, type SseItem, type SseMessage, type SseOptions, type SseSource, type StandardSchemaV1, type StandardSchemaV1FailureResult, type StandardSchemaV1Issue, type StandardSchemaV1Options, type StandardSchemaV1PathSegment, type StandardSchemaV1Props, type StandardSchemaV1Result, type StandardSchemaV1SuccessResult, type StandardSchemaV1Types, type StreamResponseOptions, type StreamSource, TooManyRequestsException, type TrailingSlashPolicy, type TrustProxy, UnauthorizedException, UnprocessableEntityException, UnsupportedMediaTypeException, type UploadLimits, type UploadedFileOptions, type UploadedFilesOptions, type Uploads, ValidationException, type ValidationIssueDetail, formatByteSize, isHttpException, isStandardSchema, nodeAdapter, parseByteSize, parseCookieHeader, parseQuery, parseSetCookie, serializeClearCookie, serializeCookie, toIssueDetail, uploads };
1262
1286
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -3889,20 +3889,23 @@ var Pipeline = class {
3889
3889
  }
3890
3890
  /**
3891
3891
  * The fast lane: what {@link handle} does for a route of {@link #fastRoute}, decided once instead
3892
- * of per request, without promises while the route stays synchronous. A request takes it when no
3893
- * `http:request` wrapper nor listener is registered, the server is not draining and its method
3894
- * is the route's (not a `HEAD` served by a `GET` route); a plain object or array returned by the
3895
- * handler is serialised as JSON, and `undefined` answered without body, with the headers in their
3896
- * final order (a template precomputed at boot).
3892
+ * of per request, without promises while the route stays synchronous. A request takes it when the
3893
+ * server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
3894
+ * plain object or array returned by the handler is serialised as JSON, and `undefined` answered
3895
+ * without body, with the headers in their final order (a template precomputed at boot).
3897
3896
  * A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
3898
3897
  * path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
3899
3898
  * or any other result, headers or cookies set through the context — continues in the general
3900
3899
  * pipeline, in the same request scope, with the same outcome.
3900
+ * With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
3901
+ * get the event once the reply is ready, as in the general pipeline.
3901
3902
  */
3902
3903
  #fast(route, request) {
3903
3904
  const plan = route.plan;
3905
+ if (this.draining || request.method !== route.method) return this.handle(plan, request);
3904
3906
  const hooks = this.#hooks;
3905
- if (this.draining || request.method !== route.method || hooks.hasWrappers("http:request") || hooks.hasListeners("http:request")) return this.handle(plan, request);
3907
+ const wrapped = hooks.hasWrappers("http:request");
3908
+ const startTime = wrapped || hooks.hasListeners("http:request") ? performance.now() : void 0;
3906
3909
  const url = request.url;
3907
3910
  const mark = url.indexOf("?");
3908
3911
  const path = mark === -1 ? url : url.slice(0, mark);
@@ -3918,13 +3921,13 @@ var Pipeline = class {
3918
3921
  return Promise.reject(error);
3919
3922
  }
3920
3923
  const scope = this.#container.openScope([[HttpTokens.Context, ctx], [HttpTokens.RequestId, ctx.requestId]]);
3921
- const done = scope.run(this.#fastRun, route, request, ctx);
3922
- if (done instanceof Exchange) {
3923
- done.done.then(() => this.#end(done, scope, void 0), (error) => this.#end(done, scope, error));
3924
- if (!hooks.hasListeners("http:request")) return done.replied;
3925
- done.tracked = true;
3926
- return this.#emitted(done);
3924
+ if (wrapped) {
3925
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime);
3926
+ x.done = scope.run(this.#fastWrapped, x, route);
3927
+ return this.#fastExchange(x, scope);
3927
3928
  }
3929
+ const done = scope.run(this.#fastRun, route, request, ctx, startTime);
3930
+ if (done instanceof Exchange) return this.#fastExchange(done, scope);
3928
3931
  const closing = scope.close();
3929
3932
  if (closing === void 0) {
3930
3933
  ctx.complete();
@@ -3935,7 +3938,7 @@ var Pipeline = class {
3935
3938
  closing.then(() => this.#over(x), (error) => this.#disposalFailed(x, error));
3936
3939
  }
3937
3940
  if (!hooks.hasListeners("http:request")) return Promise.resolve(done);
3938
- const event = requestEvent(request, ctx, performance.now());
3941
+ const event = requestEvent(request, ctx, startTime ?? performance.now());
3939
3942
  event.status = done.status;
3940
3943
  return Promise.resolve(done).then((reply) => {
3941
3944
  hooks.emit("http:request", event);
@@ -3943,20 +3946,31 @@ var Pipeline = class {
3943
3946
  });
3944
3947
  }
3945
3948
  /**
3949
+ * The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
3950
+ * it settled (see {@link #end}); listeners get the event once the reply is ready.
3951
+ */
3952
+ #fastExchange(x, scope) {
3953
+ x.done.then(() => this.#end(x, scope, void 0), (error) => this.#end(x, scope, error));
3954
+ if (!this.#hooks.hasListeners("http:request")) return x.replied;
3955
+ x.tracked = true;
3956
+ return this.#emitted(x);
3957
+ }
3958
+ /**
3946
3959
  * The fast lane in the request scope: the reply when the handler answered synchronously with a
3947
3960
  * plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
3961
+ * `startTime`: the request has `http:request` listeners, its event starts with the exchange.
3948
3962
  */
3949
- #fastRun = (route, request, ctx) => {
3963
+ #fastRun = (route, request, ctx, startTime) => {
3950
3964
  const plan = route.plan;
3951
- if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT);
3965
+ if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime);
3952
3966
  let result;
3953
3967
  let reply;
3954
3968
  try {
3955
3969
  result = plan.definition.invoke(this.#resolve(plan.controller), ctx);
3956
- if (isThenable(result)) return this.#fastLater(route, request, ctx, result);
3970
+ if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime);
3957
3971
  reply = this.#fastReply(route, ctx, result);
3958
3972
  } catch (error) {
3959
- const x = new Exchange(request, ctx, ctx.requestId, route.target);
3973
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime);
3960
3974
  x.done = this.#respond(x, () => this.#failed(error, ctx, x.event));
3961
3975
  return x;
3962
3976
  }
@@ -3965,13 +3979,13 @@ var Pipeline = class {
3965
3979
  ctx.releaseBody();
3966
3980
  return reply;
3967
3981
  }
3968
- const x = new Exchange(request, ctx, ctx.requestId, route.target);
3982
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime);
3969
3983
  x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result));
3970
3984
  return x;
3971
3985
  };
3972
3986
  /** The fast lane of a route with a body to read or a handler that returned a thenable. */
3973
- #fastLater(route, request, ctx, result) {
3974
- const x = new Exchange(request, ctx, ctx.requestId, route.target);
3987
+ #fastLater(route, request, ctx, result, startTime) {
3988
+ const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime);
3975
3989
  x.done = this.#fastAsync(x, route, result);
3976
3990
  return x;
3977
3991
  }
@@ -4017,7 +4031,19 @@ var Pipeline = class {
4017
4031
  if (status === void 0 || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return void 0;
4018
4032
  const requestId = ctx.requestId;
4019
4033
  if (!this.#validIds && !isHeaderValue(requestId)) return void 0;
4020
- if (result === void 0) {
4034
+ if (result === void 0) return this.#templateReply(status, null, void 0, requestId);
4035
+ if (!isPlainJson(result)) return void 0;
4036
+ const json = JSON.stringify(result);
4037
+ if (json === void 0) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`);
4038
+ return this.#templateReply(status, utf8(json), json, requestId);
4039
+ }
4040
+ /**
4041
+ * The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
4042
+ * the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
4043
+ * can be sent).
4044
+ */
4045
+ #templateReply(status, body, text, requestId) {
4046
+ if (text === void 0) {
4021
4047
  const headers = { ...this.#emptyHeaders };
4022
4048
  const idHeader = this.#emptyIdHeader;
4023
4049
  if (idHeader !== void 0) headers[idHeader] = requestId;
@@ -4027,11 +4053,7 @@ var Pipeline = class {
4027
4053
  body: null
4028
4054
  };
4029
4055
  }
4030
- if (!isPlainJson(result)) return void 0;
4031
- const json = JSON.stringify(result);
4032
- if (json === void 0) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`);
4033
4056
  const headers = { ...this.#jsonHeaders };
4034
- const body = utf8(json);
4035
4057
  headers["content-length"] = String(body.byteLength);
4036
4058
  const idHeader = this.#jsonIdHeader;
4037
4059
  if (idHeader !== void 0) headers[idHeader] = requestId;
@@ -4039,8 +4061,128 @@ var Pipeline = class {
4039
4061
  status,
4040
4062
  headers,
4041
4063
  body,
4042
- text: json
4064
+ text
4065
+ };
4066
+ }
4067
+ /**
4068
+ * The fast lane through the `http:request` wrappers (registered when the request started), in the
4069
+ * request scope: what {@link #respond} does for the route, the handler run inside `next()` like
4070
+ * {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
4071
+ * the reply completed from the template of the fast lane when the wrappers added no response header
4072
+ * (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
4073
+ */
4074
+ #fastWrapped = (x, route) => this.#hooks.run("http:request", x.event, () => this.#fastOperation(x, route)).then(() => this.#fastDeliver(x, false, void 0), (error) => this.#fastDeliver(x, true, error));
4075
+ /** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
4076
+ #fastOperation(x, route) {
4077
+ const ctx = x.ctx;
4078
+ const plan = route.plan;
4079
+ if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT);
4080
+ let result;
4081
+ try {
4082
+ result = plan.definition.invoke(this.#resolve(plan.controller), ctx);
4083
+ } catch (error) {
4084
+ return this.#fastChecked(x, this.#failed(error, ctx, x.event));
4085
+ }
4086
+ if (isThenable(result)) return this.#fastOperationLater(x, route, result);
4087
+ return this.#fastChecked(x, this.#fastOutcome(x, route, result));
4088
+ }
4089
+ /** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
4090
+ async #fastOperationLater(x, route, pending) {
4091
+ const ctx = x.ctx;
4092
+ const plan = route.plan;
4093
+ let result = pending;
4094
+ try {
4095
+ if (result === NO_RESULT) {
4096
+ const controller = this.#resolve(plan.controller);
4097
+ await ctx.parseBody();
4098
+ result = plan.definition.invoke(controller, ctx);
4099
+ }
4100
+ if (isThenable(result)) result = await result;
4101
+ } catch (error) {
4102
+ return await this.#fastChecked(x, this.#failed(error, ctx, x.event));
4103
+ }
4104
+ return await this.#fastChecked(x, this.#fastOutcome(x, route, result));
4105
+ }
4106
+ /**
4107
+ * The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
4108
+ * the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
4109
+ * pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
4110
+ */
4111
+ #fastOutcome(x, route, result) {
4112
+ const ctx = x.ctx;
4113
+ const event = x.event;
4114
+ const status = result === void 0 ? route.emptyStatus : route.jsonStatus;
4115
+ if (status === void 0 || ctx.hasResponseHeaders || result !== void 0 && !isPlainJson(result)) return this.#settle(ctx, route.plan, event, result);
4116
+ let reply;
4117
+ if (result === void 0) reply = {
4118
+ status,
4119
+ headers: fastRecord(),
4120
+ body: null
4043
4121
  };
4122
+ else {
4123
+ let json;
4124
+ try {
4125
+ json = JSON.stringify(result);
4126
+ if (json === void 0) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`);
4127
+ } catch (error) {
4128
+ return this.#failed(error, ctx, event);
4129
+ }
4130
+ const headers = fastRecord();
4131
+ headers["content-type"] = ContentType.json;
4132
+ const body = utf8(json);
4133
+ headers["content-length"] = String(body.byteLength);
4134
+ reply = {
4135
+ status,
4136
+ headers,
4137
+ body,
4138
+ text: json,
4139
+ ownHeaders: true
4140
+ };
4141
+ }
4142
+ event.status = status;
4143
+ x.fast = true;
4144
+ return reply;
4145
+ }
4146
+ /**
4147
+ * What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
4148
+ * headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
4149
+ */
4150
+ #fastChecked(x, pending) {
4151
+ if (pending instanceof Promise) return pending.then((outcome) => this.#fastKeep(x, outcome));
4152
+ return Promise.resolve(this.#fastKeep(x, pending));
4153
+ }
4154
+ #fastKeep(x, outcome) {
4155
+ const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event);
4156
+ if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext();
4157
+ x.outcome = checked;
4158
+ return checked;
4159
+ }
4160
+ /**
4161
+ * Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
4162
+ * headers of the template when nothing was added to it (no response header from the wrappers, no
4163
+ * cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
4164
+ * anything else is completed by {@link #deliver}. Never rejects.
4165
+ */
4166
+ #fastDeliver(x, failed, hookError) {
4167
+ const outcome = x.outcome;
4168
+ const ctx = x.ctx;
4169
+ if (outcome === void 0) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED);
4170
+ if (failed) this.#wrapperFailed(x, hookError);
4171
+ else if (x.fast && outcome !== RAW && !hasOwnKeys(x.event.responseHeaders) && !ctx.hasCookies && !this.draining && (this.#validIds || isHeaderValue(x.requestId))) {
4172
+ const reply = this.#templateReply(outcome.status, outcome.body, outcome.text, x.requestId);
4173
+ ctx.commit();
4174
+ ctx.releaseBody();
4175
+ x.answer(reply);
4176
+ return;
4177
+ }
4178
+ const streaming = this.#send(x, outcome, true, x.run);
4179
+ if (streaming === void 0) return void 0;
4180
+ return streaming.tracked.done.then((failure) => this.#streamed(x, streaming, failure));
4181
+ }
4182
+ /** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
4183
+ async #fastUnhandled(x, failure) {
4184
+ const streaming = this.#send(x, await this.#unhandled(x, failure), true, void 0);
4185
+ if (streaming !== void 0) this.#streamed(x, streaming, await streaming.tracked.done);
4044
4186
  }
4045
4187
  /** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
4046
4188
  #end(x, scope, error) {
@@ -4225,16 +4367,41 @@ var Pipeline = class {
4225
4367
  hookError = error;
4226
4368
  }
4227
4369
  if (outcome === void 0) {
4228
- const error = failed ? hookError : /* @__PURE__ */ new Error("An \"http:request\" hook wrapper did not call next()");
4229
- event.error ??= error;
4230
- const fallback = await this.#errorReply(error, ctx);
4231
- outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event);
4232
- event.status = outcome.status;
4370
+ outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED);
4233
4371
  recheck = true;
4234
- } else if (failed) this.#logger.error("An \"http:request\" hook wrapper failed after the request was handled", {
4235
- err: hookError,
4236
- requestId
4372
+ } else if (failed) this.#wrapperFailed(x, hookError);
4373
+ const streaming = this.#send(x, outcome, recheck, run);
4374
+ if (streaming !== void 0) this.#streamed(x, streaming, await streaming.tracked.done);
4375
+ }
4376
+ /**
4377
+ * The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
4378
+ * did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
4379
+ * exception mapper's `HttpResponse` may carry cookies). Never rejects.
4380
+ */
4381
+ async #unhandled(x, failure) {
4382
+ const ctx = x.ctx;
4383
+ const event = x.event;
4384
+ const error = failure === NOT_CALLED ? /* @__PURE__ */ new Error("An \"http:request\" hook wrapper did not call next()") : failure;
4385
+ event.error ??= error;
4386
+ const fallback = await this.#errorReply(error, ctx);
4387
+ const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event);
4388
+ event.status = reply.status;
4389
+ return reply;
4390
+ }
4391
+ #wrapperFailed(x, error) {
4392
+ this.#logger.error("An \"http:request\" hook wrapper failed after the request was handled", {
4393
+ err: error,
4394
+ requestId: x.requestId
4237
4395
  });
4396
+ }
4397
+ /**
4398
+ * Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
4399
+ * Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
4400
+ * captured), whose end {@link #streamed} records.
4401
+ */
4402
+ #send(x, outcome, recheck, run) {
4403
+ const ctx = x.ctx;
4404
+ const event = x.event;
4238
4405
  if (outcome === RAW) {
4239
4406
  ctx.commit();
4240
4407
  const status = x.request.rawStatus?.() ?? 200;
@@ -4247,7 +4414,7 @@ var Pipeline = class {
4247
4414
  });
4248
4415
  return;
4249
4416
  }
4250
- const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck);
4417
+ const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck);
4251
4418
  const body = serialized.body;
4252
4419
  if (!isStreamed(body)) {
4253
4420
  const reply = adapterReply(serialized, body);
@@ -4265,11 +4432,18 @@ var Pipeline = class {
4265
4432
  }
4266
4433
  ctx.commit();
4267
4434
  x.answer(reply);
4268
- const failure = await tracked.done;
4269
- if (session !== void 0) this.#streams.delete(session);
4435
+ return {
4436
+ tracked,
4437
+ session
4438
+ };
4439
+ }
4440
+ /** A streamed body was sent or abandoned (`failure`: the stream failed). */
4441
+ #streamed(x, streaming, failure) {
4442
+ const ctx = x.ctx;
4443
+ if (streaming.session !== void 0) this.#streams.delete(streaming.session);
4270
4444
  if (failure !== void 0) this.#logger.error("The response stream failed", {
4271
4445
  err: failure,
4272
- requestId,
4446
+ requestId: x.requestId,
4273
4447
  path: ctx.path
4274
4448
  });
4275
4449
  ctx.releaseBody();
@@ -4672,6 +4846,8 @@ var Pipeline = class {
4672
4846
  };
4673
4847
  /** `#fastAsync` reads the body and runs the handler. */
4674
4848
  const NO_RESULT = Symbol("no result");
4849
+ /** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
4850
+ const NOT_CALLED = Symbol("next() not called");
4675
4851
  /** A request being answered (internal): its context, its target and the promise of its reply. */
4676
4852
  var Exchange = class {
4677
4853
  request;
@@ -4686,6 +4862,12 @@ var Exchange = class {
4686
4862
  tracked = false;
4687
4863
  /** The asynchronous part of a fast-lane request (never rejects). */
4688
4864
  done = void 0;
4865
+ /** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
4866
+ outcome = void 0;
4867
+ /** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
4868
+ fast = false;
4869
+ /** Fast lane with wrappers: the async context of the handler, for a streamed body. */
4870
+ run = void 0;
4689
4871
  #resolve;
4690
4872
  #reject;
4691
4873
  #event;
@@ -4735,6 +4917,11 @@ function requestEvent(request, ctx, startTime) {
4735
4917
  responseHeaders: {}
4736
4918
  };
4737
4919
  }
4920
+ /** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
4921
+ function hasOwnKeys(record) {
4922
+ for (const key in record) if (Object.hasOwn(record, key)) return true;
4923
+ return false;
4924
+ }
4738
4925
  /**
4739
4926
  * The decoded parameters of a raw request path matched against a route, in one pass: what
4740
4927
  * `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
@@ -4947,6 +5134,8 @@ function validateRoute(instance, plugin, value, prefix) {
4947
5134
  }
4948
5135
  const guards = route.guards ?? [];
4949
5136
  if (!Array.isArray(guards)) throw invalid(plugin, `the guards of the route ${where} must be an array`);
5137
+ const bodyLimit = route.bodyLimit === void 0 ? void 0 : parseByteSize(route.bodyLimit);
5138
+ if (route.bodyLimit !== void 0 && bodyLimit === void 0) throw invalid(plugin, `invalid bodyLimit ${describe(route.bodyLimit)} of the route ${where}: expected a size in bytes (1048576, '100kb', Infinity)`);
4950
5139
  return {
4951
5140
  plugin,
4952
5141
  instance,
@@ -4956,7 +5145,8 @@ function validateRoute(instance, plugin, value, prefix) {
4956
5145
  name: route.name,
4957
5146
  handler: route.handler,
4958
5147
  meta: entries,
4959
- guards
5148
+ guards,
5149
+ bodyLimit
4960
5150
  };
4961
5151
  }
4962
5152
  function metaEntry(plugin, where, value) {
@@ -5940,7 +6130,7 @@ function buildRegistrations(ctx, options, raw, pluginRoutes) {
5940
6130
  invoke: (_plugin, http) => handler(http)
5941
6131
  });
5942
6132
  const instance = { instance: route.instance };
5943
- const body = bodyPlan({}, options.bodyLimit, options.uploads);
6133
+ const body = bodyPlan(route.bodyLimit === void 0 ? {} : { bodyLimit: route.bodyLimit }, options.bodyLimit, options.uploads);
5944
6134
  const chain = guards.map((guard) => guard.resolvable);
5945
6135
  const path = route.path;
5946
6136
  declared.push({