@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/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { n as RouteTable, t as nodeAdapter } from "./node-BUnkdSsa.mjs";
2
- import { $ as UnprocessableEntityException, A as serializeCookie, B as ConflictException, C as decodeCookieValue, D as parseCookieHeader, E as isCookieName, F as isStreamSource, G as InternalServerErrorException, H as GatewayTimeoutException, I as toByteStream, J as NotImplementedException, K as MethodNotAllowedException, L as trackStream, M as captureContext, N as discardStream, O as parseSetCookie, P as isByteStream, Q as UnauthorizedException, R as BadGatewayException, S as checkCookie, T as givenCookieKey, U as GoneException, V as ForbiddenException, W as HttpException, X as ServiceUnavailableException, Y as PayloadTooLargeException, Z as TooManyRequestsException, at as isBodylessStatus, et as UnsupportedMediaTypeException, h as isEventStreamType, it as toIssueDetail, j as serializeResponseCookie, k as serializeClearCookie, nt as isHttpException, ot as isValidStatus, q as NotFoundException, rt as issue, st as statusText, tt as ValidationException, w as encodeCookieValue, x as DEFAULT_COOKIE_OPTIONS, z as BadRequestException } from "./node-io-BY1ZDLRQ.mjs";
1
+ import { n as RouteTable, t as nodeAdapter } from "./node-Clm1Ia7T.mjs";
2
+ import { $ as UnprocessableEntityException, A as serializeCookie, B as ConflictException, C as decodeCookieValue, D as parseCookieHeader, E as isCookieName, F as isStreamSource, G as InternalServerErrorException, H as GatewayTimeoutException, I as toByteStream, J as NotImplementedException, K as MethodNotAllowedException, L as trackStream, M as captureContext, N as discardStream, O as parseSetCookie, P as isByteStream, Q as UnauthorizedException, R as BadGatewayException, S as checkCookie, T as givenCookieKey, U as GoneException, V as ForbiddenException, W as HttpException, X as ServiceUnavailableException, Y as PayloadTooLargeException, Z as TooManyRequestsException, at as isBodylessStatus, et as UnsupportedMediaTypeException, h as isEventStreamType, it as toIssueDetail, j as serializeResponseCookie, k as serializeClearCookie, nt as isHttpException, ot as isValidStatus, q as NotFoundException, rt as issue, st as statusText, tt as ValidationException, w as encodeCookieValue, x as DEFAULT_COOKIE_OPTIONS, z as BadRequestException } from "./node-io-CVnqpVNS.mjs";
3
3
  import { a as isMediaTypePattern, i as contentTypeFor, n as parseByteSize, o as matchesMediaType, r as OCTET_STREAM, s as mediaTypeEssence, t as formatByteSize } from "./size-D8rsQcV0.mjs";
4
- import { i as isThenable, n as errorMessage, t as emptyRecord } from "./utils-DCbu3a4w.mjs";
4
+ import { i as isThenable, n as errorMessage, r as isBun, t as emptyRecord } from "./utils-DCbu3a4w.mjs";
5
5
  import { RoutePathError, compareRoutePaths, compareStrings, decodePathValue, joinPaths, matchRoutePath, normalizePath, parseRoutePath } from "./path.mjs";
6
6
  import { Readable } from "node:stream";
7
7
  import { Buffer as Buffer$1 } from "node:buffer";
@@ -823,7 +823,7 @@ var RequestContext = class {
823
823
  this.#source = source;
824
824
  this.#plan = init.body;
825
825
  this.#cookieSettings = init.cookies ?? DEFAULT_COOKIE_SETTINGS;
826
- const { path, query } = splitUrl(source.url);
826
+ const { path, query } = init.url ?? splitUrl(source.url);
827
827
  this.#rawQuery = query;
828
828
  this.requestId = init.requestId;
829
829
  this.method = source.method;
@@ -910,7 +910,7 @@ var RequestContext = class {
910
910
  }
911
911
  /** The `Set-Cookie` lines of {@link setCookie} / {@link clearCookie}, with their keys, in order (internal). */
912
912
  get cookieLines() {
913
- return this.#jar === void 0 ? [] : [...this.#jar].map(([key, entry]) => ({
913
+ return this.#jar === void 0 ? NO_LINES : [...this.#jar].map(([key, entry]) => ({
914
914
  key,
915
915
  line: entry.line
916
916
  }));
@@ -1071,6 +1071,7 @@ function withoutLifetime(options) {
1071
1071
  }
1072
1072
  const EMPTY_HEADERS = Object.freeze({});
1073
1073
  const NO_COOKIES$1 = Object.freeze([]);
1074
+ const NO_LINES = Object.freeze([]);
1074
1075
  const NO_FILES$1 = Object.freeze(emptyRecord());
1075
1076
  // src/decorators.ts
1076
1077
  function marker() {}
@@ -3347,6 +3348,11 @@ function isHttpResponse(value) {
3347
3348
  }
3348
3349
  // src/serialize.ts
3349
3350
  const ENCODER = new TextEncoder();
3351
+ /**
3352
+ * UTF-8 encoding of a text body: a pooled `Buffer` (a `Uint8Array`) is several times cheaper than
3353
+ * `TextEncoder` on Node.js, `TextEncoder` is the fastest on Bun.
3354
+ */
3355
+ const utf8 = isBun() ? (text) => ENCODER.encode(text) : (text) => Buffer$1.from(text, "utf8");
3350
3356
  /** Content types set by the serialiser. */
3351
3357
  const ContentType = Object.freeze({
3352
3358
  json: "application/json; charset=utf-8",
@@ -3387,10 +3393,20 @@ function serializeResult(result, options) {
3387
3393
  const finalStatus = options.status ?? status ?? defaultStatus(options.routeMethod, payload);
3388
3394
  const headers = emptyRecord();
3389
3395
  const body = encode(payload, headers);
3390
- if (options.headers !== void 0) assignHeaders(headers, options.headers);
3391
- if (options.context !== void 0) assignHeaders(headers, options.context);
3392
- if (extra !== void 0) assignHeaders(headers, extra);
3393
- return withCookies(finish(finalStatus, headers, body, options.method), cookies);
3396
+ let own = true;
3397
+ if (options.headers !== void 0) {
3398
+ assignHeaders(headers, options.headers);
3399
+ own = false;
3400
+ }
3401
+ if (options.context !== void 0) {
3402
+ assignHeaders(headers, options.context);
3403
+ own = false;
3404
+ }
3405
+ if (extra !== void 0) {
3406
+ assignHeaders(headers, extra);
3407
+ own = false;
3408
+ }
3409
+ return withCookies(finish(finalStatus, headers, body, options.method, own), cookies);
3394
3410
  }
3395
3411
  /**
3396
3412
  * Serialises an event stream (`@Sse` result, `HttpResponse.sse()`): status 200 unless `@HttpCode`
@@ -3466,7 +3482,11 @@ function addMissingHeaders(target, source) {
3466
3482
  if (value !== void 0 && target[key] === void 0) target[key] = typeof value === "string" ? value : [...value];
3467
3483
  }
3468
3484
  }
3469
- function finish(status, headers, body, method) {
3485
+ /**
3486
+ * The reply of a body: a string (see {@link encode}) is sent as its UTF-8 bytes, with its text.
3487
+ * `own`: only {@link encode} set `headers`, which are then valid for a text body.
3488
+ */
3489
+ function finish(status, headers, body, method, own = false) {
3470
3490
  if (isBodylessStatus(status)) {
3471
3491
  discard(body);
3472
3492
  delete headers["content-type"];
@@ -3485,18 +3505,34 @@ function finish(status, headers, body, method) {
3485
3505
  body: null
3486
3506
  };
3487
3507
  }
3488
- return {
3508
+ if (typeof body !== "string") return {
3489
3509
  status,
3490
3510
  headers,
3491
3511
  body
3492
3512
  };
3513
+ return own ? {
3514
+ status,
3515
+ headers,
3516
+ body: utf8(body),
3517
+ text: body,
3518
+ ownHeaders: true
3519
+ } : {
3520
+ status,
3521
+ headers,
3522
+ body: utf8(body),
3523
+ text: body
3524
+ };
3493
3525
  }
3494
3526
  function discard(body) {
3495
3527
  if (body instanceof ReadableStream) discardStream(body);
3496
3528
  }
3529
+ /**
3530
+ * The body of a payload, its `content-type` and, for bytes and text, its `content-length` set in
3531
+ * `headers`. Text (a string, JSON) is returned as a string, encoded as UTF-8 by {@link finish}.
3532
+ */
3497
3533
  function encode(payload, headers) {
3498
3534
  if (payload === void 0) return null;
3499
- if (typeof payload === "string") return bytes(ENCODER.encode(payload), headers, ContentType.text);
3535
+ if (typeof payload === "string") return text$1(payload, headers, ContentType.text);
3500
3536
  if (payload instanceof Uint8Array) return bytes(payload, headers, ContentType.bytes);
3501
3537
  if (payload instanceof ArrayBuffer) return bytes(new Uint8Array(payload), headers, ContentType.bytes);
3502
3538
  if (ArrayBuffer.isView(payload)) return bytes(new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength), headers, ContentType.bytes);
@@ -3517,13 +3553,18 @@ function encode(payload, headers) {
3517
3553
  if (isSseBody(payload)) throw new TypeError("An event stream (HttpResponse.sse()) cannot be serialised here");
3518
3554
  const json = JSON.stringify(payload);
3519
3555
  if (json === void 0) throw new TypeError(`Cannot serialise a ${typeof payload} as a response body`);
3520
- return bytes(ENCODER.encode(json), headers, ContentType.json);
3556
+ return text$1(json, headers, ContentType.json);
3521
3557
  }
3522
3558
  function bytes(data, headers, type) {
3523
3559
  headers["content-type"] = type;
3524
3560
  headers["content-length"] = String(data.byteLength);
3525
3561
  return data;
3526
3562
  }
3563
+ function text$1(data, headers, type) {
3564
+ headers["content-type"] = type;
3565
+ headers["content-length"] = String(Buffer$1.byteLength(data, "utf8"));
3566
+ return data;
3567
+ }
3527
3568
  function isNodeReadable(value) {
3528
3569
  return value instanceof Readable || value !== null && typeof value === "object" && typeof value.pipe === "function" && typeof value.on === "function" && typeof value.read === "function";
3529
3570
  }
@@ -3555,6 +3596,10 @@ const DEFAULT_SSE_SETTINGS = Object.freeze({
3555
3596
  const RAW = Symbol("raw response");
3556
3597
  const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/;
3557
3598
  const MAX_PATH_IN_MESSAGE = 200;
3599
+ /** The default request id generator: a random UUID. */
3600
+ function randomRequestId() {
3601
+ return crypto.randomUUID();
3602
+ }
3558
3603
  /**
3559
3604
  * The request pipeline (architecture §9.1):
3560
3605
  * `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
@@ -3566,6 +3611,10 @@ var Pipeline = class {
3566
3611
  #logger;
3567
3612
  #options;
3568
3613
  #table;
3614
+ /** The `securityHeaders` option as entries (lower-case names, values validated at boot). */
3615
+ #securityHeaders;
3616
+ /** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
3617
+ #validIds;
3569
3618
  #inflight = 0;
3570
3619
  #idle = [];
3571
3620
  /** Open event streams. */
@@ -3578,6 +3627,8 @@ var Pipeline = class {
3578
3627
  this.#logger = logger;
3579
3628
  this.#options = options;
3580
3629
  this.#table = table;
3630
+ this.#validIds = options.generateRequestId === randomRequestId;
3631
+ this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(([name, value]) => [name.toLowerCase(), value]);
3581
3632
  }
3582
3633
  /** Number of requests being processed. */
3583
3634
  get inflight() {
@@ -3603,28 +3654,25 @@ var Pipeline = class {
3603
3654
  * Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
3604
3655
  * open) until a streamed or file response body has been fully read or cancelled by the adapter.
3605
3656
  */
3606
- async handle(plan, request) {
3657
+ handle(plan, request) {
3607
3658
  const startTime = performance.now();
3608
- this.#enter();
3609
- let served;
3610
3659
  try {
3611
- served = this.#isPreflight(request) ? await this.#preflight(request, startTime) : await this.#route(plan, request, startTime);
3612
- return served.reply;
3613
- } finally {
3614
- this.#leaveWhen(served);
3660
+ return this.#isPreflight(request) ? this.#preflight(request, startTime) : this.#route(plan, request, startTime);
3661
+ } catch (error) {
3662
+ return Promise.reject(error);
3615
3663
  }
3616
3664
  }
3617
3665
  #route(plan, request, startTime, rerouted = false) {
3618
- const { path, query } = splitUrl(request.url);
3619
- const raw = matchRoutePath(plan.path, path);
3666
+ const url = splitUrl(request.url);
3667
+ const raw = matchRoutePath(plan.path, url.path);
3620
3668
  if (raw === void 0) return this.#fallback(request, void 0, startTime, !rerouted);
3621
3669
  if (plan.redirect === true) {
3622
- const canonical = sameOriginPath(path.slice(0, -1));
3623
- const location = query === "" ? canonical : `${canonical}?${query}`;
3624
- return this.#serve(request, void 0, emptyRecord(), {
3670
+ const canonical = sameOriginPath(url.path.slice(0, -1));
3671
+ const target = {
3625
3672
  kind: "redirect",
3626
- location
3627
- }, startTime, plan.body);
3673
+ location: url.query === "" ? canonical : `${canonical}?${url.query}`
3674
+ };
3675
+ return this.#serve(request, url, void 0, emptyRecord(), target, startTime, plan.body);
3628
3676
  }
3629
3677
  const params = emptyRecord();
3630
3678
  let malformed;
@@ -3640,18 +3688,15 @@ var Pipeline = class {
3640
3688
  kind: "error",
3641
3689
  error: new BadRequestException(`Malformed URL parameter "${malformed}"`)
3642
3690
  };
3643
- return this.#serve(request, plan.info, params, target, startTime, plan.body);
3691
+ return this.#serve(request, url, plan.info, params, target, startTime, plan.body);
3644
3692
  }
3645
3693
  /** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
3646
- async fallback(request, error) {
3694
+ fallback(request, error) {
3647
3695
  const startTime = performance.now();
3648
- this.#enter();
3649
- let served;
3650
3696
  try {
3651
- served = await this.#fallback(request, error, startTime);
3652
- return served.reply;
3653
- } finally {
3654
- this.#leaveWhen(served);
3697
+ return this.#fallback(request, error, startTime);
3698
+ } catch (failure) {
3699
+ return Promise.reject(failure);
3655
3700
  }
3656
3701
  }
3657
3702
  #fallback(request, error, startTime, reroute = true) {
@@ -3661,10 +3706,11 @@ var Pipeline = class {
3661
3706
  kind: "error",
3662
3707
  error: frameworkError(error)
3663
3708
  };
3664
- return this.#serve(request, void 0, emptyRecord(), target, startTime, body);
3709
+ return this.#serve(request, splitUrl(request.url), void 0, emptyRecord(), target, startTime, body);
3665
3710
  }
3666
3711
  if (this.#isPreflight(request)) return this.#preflight(request, startTime);
3667
- const { path } = splitUrl(request.url);
3712
+ const url = splitUrl(request.url);
3713
+ const path = url.path;
3668
3714
  if (reroute) {
3669
3715
  const plan = this.#table.find(request.method, path);
3670
3716
  if (plan !== void 0) return this.#route(plan, request, startTime, true);
@@ -3683,63 +3729,98 @@ var Pipeline = class {
3683
3729
  kind: "error",
3684
3730
  error: new MethodNotAllowedException(`Method ${request.method} is not allowed on ${truncate(path)}`, void 0, { headers: { allow: allowHeader(allowed) } })
3685
3731
  };
3686
- return this.#serve(request, void 0, emptyRecord(), target, startTime, body);
3732
+ return this.#serve(request, url, void 0, emptyRecord(), target, startTime, body);
3687
3733
  }
3688
3734
  /** A CORS preflight (with the `cors` option): answered without running any route. */
3689
3735
  #isPreflight(request) {
3690
3736
  return this.#options.cors !== void 0 && isPreflight(request.method, request.headers);
3691
3737
  }
3692
3738
  #preflight(request, startTime) {
3693
- const allowed = this.#table.allowedMethods(splitUrl(request.url).path);
3739
+ const url = splitUrl(request.url);
3740
+ const allowed = this.#table.allowedMethods(url.path);
3694
3741
  const target = {
3695
3742
  kind: "preflight",
3696
3743
  allow: allowed.length === 0 ? void 0 : allowHeader(allowed)
3697
3744
  };
3698
- return this.#serve(request, void 0, emptyRecord(), target, startTime, this.#options.body);
3699
- }
3700
- async #serve(request, route, params, target, startTime, body) {
3701
- const requestId = this.#requestId(request);
3702
- const proxy = this.#options.trustProxy;
3703
- const ctx = new RequestContext(request, {
3704
- requestId,
3705
- adapter: this.#options.adapter,
3706
- route,
3707
- params,
3708
- body,
3709
- cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
3710
- ...proxy === void 0 ? {} : { client: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true) }
3711
- });
3712
- if (this.draining && target.kind === "route" && target.plan.definition.sse !== void 0) ctx.abort("shutdown");
3713
- const event = {
3714
- method: request.method,
3715
- path: ctx.path,
3716
- route: route?.path,
3717
- requestId,
3718
- headers: request.headers,
3719
- context: ctx,
3720
- startTime,
3721
- responseHeaders: {}
3722
- };
3745
+ return this.#serve(request, url, void 0, emptyRecord(), target, startTime, this.#options.body);
3746
+ }
3747
+ #serve(request, url, route, params, target, startTime, body) {
3748
+ this.#enter();
3749
+ let requestId;
3750
+ let ctx;
3751
+ let event;
3752
+ try {
3753
+ requestId = this.#requestId(request);
3754
+ const proxy = this.#options.trustProxy;
3755
+ ctx = new RequestContext(request, {
3756
+ requestId,
3757
+ adapter: this.#options.adapter,
3758
+ url,
3759
+ route,
3760
+ params,
3761
+ body,
3762
+ cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
3763
+ client: proxy === void 0 ? void 0 : proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true)
3764
+ });
3765
+ if (this.draining && target.kind === "route" && target.plan.definition.sse !== void 0) ctx.abort("shutdown");
3766
+ event = {
3767
+ method: request.method,
3768
+ path: ctx.path,
3769
+ route: route?.path,
3770
+ requestId,
3771
+ headers: request.headers,
3772
+ context: ctx,
3773
+ startTime,
3774
+ responseHeaders: {}
3775
+ };
3776
+ } catch (error) {
3777
+ this.#leave();
3778
+ return Promise.reject(error);
3779
+ }
3723
3780
  let outcome;
3724
- let reply;
3725
3781
  let cors;
3726
3782
  const preflight = target.kind === "preflight";
3727
- let replied = () => void 0;
3728
- const ready = new Promise((resolve) => {
3729
- replied = resolve;
3783
+ let resolve = () => void 0;
3784
+ let reject = () => void 0;
3785
+ const replied = new Promise((onReply, onError) => {
3786
+ resolve = onReply;
3787
+ reject = onError;
3730
3788
  });
3731
- const done = this.#container.runInScope(async () => {
3789
+ let answered = false;
3790
+ const answer = (reply) => {
3791
+ answered = true;
3792
+ resolve(reply);
3793
+ };
3794
+ /** The request is over (response body sent or abandoned, scope closed). */
3795
+ const over = () => {
3796
+ ctx.complete();
3797
+ try {
3798
+ if (!answered) {
3799
+ const failure = /* @__PURE__ */ new Error("Request not handled");
3800
+ const reply = serializeError(500, this.#internalBody(failure), ctx.method);
3801
+ const completed = this.#complete(reply, ctx, event, requestId, cors, preflight, true);
3802
+ event.status = 500;
3803
+ answer(completed);
3804
+ }
3805
+ } catch (error) {
3806
+ reject(error);
3807
+ } finally {
3808
+ this.#leave();
3809
+ }
3810
+ };
3811
+ this.#container.runInScope(async () => {
3732
3812
  let hookError;
3733
3813
  let failed = false;
3734
3814
  let run;
3735
3815
  if (this.#options.cors !== void 0) cors = await this.#corsDecision(request, requestId);
3816
+ let recheck = this.#hooks.hasWrappers("http:request");
3736
3817
  try {
3737
- await this.#hooks.run("http:request", event, async () => {
3738
- outcome = await this.#process(ctx, target, event, cors);
3739
- if (outcome !== RAW) outcome = this.#checkCookies(this.#checkHeaders(outcome, ctx, event), ctx, event);
3740
- run = captureContext();
3818
+ if (recheck) await this.#hooks.run("http:request", event, async () => {
3819
+ outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event);
3820
+ if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext();
3741
3821
  return outcome;
3742
3822
  });
3823
+ else outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event);
3743
3824
  } catch (error) {
3744
3825
  failed = true;
3745
3826
  hookError = error;
@@ -3750,33 +3831,41 @@ var Pipeline = class {
3750
3831
  const fallback = await this.#errorReply(error, ctx);
3751
3832
  outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event);
3752
3833
  event.status = outcome.status;
3834
+ recheck = true;
3753
3835
  } else if (failed) this.#logger.error("An \"http:request\" hook wrapper failed after the request was handled", {
3754
3836
  err: hookError,
3755
3837
  requestId
3756
3838
  });
3757
3839
  if (outcome === RAW) {
3758
3840
  ctx.commit();
3759
- replied();
3841
+ const status = request.rawStatus?.() ?? 200;
3842
+ event.status ??= status;
3843
+ answer({
3844
+ status,
3845
+ headers: {},
3846
+ body: null,
3847
+ raw: true
3848
+ });
3760
3849
  return;
3761
3850
  }
3762
- const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight);
3851
+ const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight, recheck);
3763
3852
  const body = serialized.body;
3764
- if (!(body instanceof ReadableStream || body instanceof Blob)) {
3765
- reply = adapterReply(serialized, body);
3853
+ if (!isStreamed(body)) {
3854
+ const reply = adapterReply(serialized, body);
3766
3855
  ctx.commit();
3767
- replied();
3768
3856
  ctx.releaseBody();
3857
+ answer(reply);
3769
3858
  return;
3770
3859
  }
3771
3860
  const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort("client"));
3772
3861
  const session = serialized.sse;
3773
- reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : void 0);
3862
+ const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : void 0);
3774
3863
  if (session !== void 0) {
3775
3864
  this.#streams.add(session);
3776
3865
  if (this.draining) session.close("shutdown");
3777
3866
  }
3778
3867
  ctx.commit();
3779
- replied();
3868
+ answer(reply);
3780
3869
  const failure = await tracked.done;
3781
3870
  if (session !== void 0) this.#streams.delete(session);
3782
3871
  if (failure !== void 0) this.#logger.error("The response stream failed", {
@@ -3785,49 +3874,30 @@ var Pipeline = class {
3785
3874
  path: ctx.path
3786
3875
  });
3787
3876
  ctx.releaseBody();
3788
- }, [[HttpTokens.Context, ctx], [HttpTokens.RequestId, requestId]]).then(() => {
3789
- ctx.complete();
3790
- }, (error) => {
3791
- ctx.complete();
3877
+ }, [[HttpTokens.Context, ctx], [HttpTokens.RequestId, requestId]]).then(over, (error) => {
3878
+ over();
3792
3879
  this.#logger.error("Failed to dispose request-scoped providers", {
3793
3880
  err: error,
3794
3881
  requestId
3795
3882
  });
3796
3883
  });
3797
- await Promise.race([ready, done]);
3798
- if (outcome === RAW) {
3799
- const status = request.rawStatus?.() ?? 200;
3800
- event.status ??= status;
3801
- this.#emit(event);
3802
- return {
3803
- reply: {
3804
- status,
3805
- headers: {},
3806
- body: null,
3807
- raw: true
3808
- },
3809
- done
3810
- };
3811
- }
3812
- if (reply === void 0) {
3813
- const failure = /* @__PURE__ */ new Error("Request not handled");
3814
- reply = this.#complete(serializeError(500, this.#internalBody(failure), ctx.method), ctx, event, requestId, cors, preflight);
3815
- event.status = 500;
3816
- }
3817
- this.#emit(event);
3818
- return {
3819
- reply,
3820
- done
3821
- };
3884
+ if (!this.#hooks.hasListeners("http:request")) return replied;
3885
+ return replied.then((reply) => {
3886
+ this.#hooks.emit("http:request", event);
3887
+ return reply;
3888
+ });
3822
3889
  }
3823
3890
  /**
3824
3891
  * Adds the headers of the `http:request` wrappers, then the framework headers (see
3825
- * {@link #frameworkHeaders}), then checks the final headers (see {@link #checkHeaders}).
3892
+ * {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
3893
+ * {@link #checkHeaders}): the reply was checked before, and the cookies and framework headers are
3894
+ * valid by construction.
3826
3895
  */
3827
- #complete(reply, ctx, event, requestId, cors, preflight) {
3896
+ #complete(reply, ctx, event, requestId, cors, preflight, recheck) {
3828
3897
  addMissingHeaders(reply.headers, event.responseHeaders);
3829
3898
  applyCookies(reply, ctx);
3830
3899
  this.#frameworkHeaders(reply.headers, requestId, cors, preflight);
3900
+ if (!recheck) return reply;
3831
3901
  const checked = this.#checkHeaders(reply, ctx, event);
3832
3902
  if (checked !== reply) {
3833
3903
  applyCookies(checked, ctx);
@@ -3835,6 +3905,12 @@ var Pipeline = class {
3835
3905
  }
3836
3906
  return checked;
3837
3907
  }
3908
+ /** Checks the headers (unless only the serialiser set them), then the cookies of a reply. */
3909
+ #checked(outcome, ctx, event) {
3910
+ if (outcome === RAW) return outcome;
3911
+ const checked = outcome.ownHeaders === true ? outcome : this.#checkHeaders(outcome, ctx, event);
3912
+ return this.#checkCookies(checked, ctx, event);
3913
+ }
3838
3914
  /**
3839
3915
  * Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
3840
3916
  * signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
@@ -3869,7 +3945,7 @@ var Pipeline = class {
3869
3945
  */
3870
3946
  #frameworkHeaders(headers, requestId, cors, preflight) {
3871
3947
  const header = this.#options.requestIdHeader;
3872
- if (header !== void 0 && headers[header] === void 0 && isHeaderValue(requestId)) headers[header] = requestId;
3948
+ if (header !== void 0 && headers[header] === void 0 && (this.#validIds || isHeaderValue(requestId))) headers[header] = requestId;
3873
3949
  const policy = this.#options.cors;
3874
3950
  if (policy !== void 0) {
3875
3951
  if (!preflight) {
@@ -3878,8 +3954,7 @@ var Pipeline = class {
3878
3954
  }
3879
3955
  if (policy.varyOrigin) mergeVary(headers, "origin");
3880
3956
  }
3881
- const security = this.#options.securityHeaders;
3882
- if (security !== void 0) addMissingHeaders(headers, security);
3957
+ for (const [name, value] of this.#securityHeaders) if (headers[name] === void 0) headers[name] = value;
3883
3958
  if (this.draining) headers["connection"] = "close";
3884
3959
  }
3885
3960
  /**
@@ -3921,7 +3996,12 @@ var Pipeline = class {
3921
3996
  this.#log(error, ctx);
3922
3997
  return serializeError(500, this.#internalBody(error), ctx.method);
3923
3998
  }
3924
- async #process(ctx, target, event, cors) {
3999
+ /** The outcome of a request: its route run, or a reply of the framework. Never rejects (errors are mapped). */
4000
+ #process(ctx, target, event, cors) {
4001
+ return target.kind === "route" ? this.#invoke(ctx, target.plan, event) : this.#answer(ctx, target, event, cors);
4002
+ }
4003
+ /** Answers a request without running a route. Never rejects. */
4004
+ async #answer(ctx, target, event, cors) {
3925
4005
  try {
3926
4006
  switch (target.kind) {
3927
4007
  case "error": throw target.error;
@@ -3944,45 +4024,55 @@ var Pipeline = class {
3944
4024
  status: 308,
3945
4025
  headers: { location: target.location }
3946
4026
  }));
3947
- default: return await this.#invoke(ctx, target.plan, event);
3948
4027
  }
3949
4028
  } catch (error) {
3950
- event.error = error;
3951
- const reply = await this.#errorReply(error, ctx);
3952
- event.status = reply.status;
3953
- return reply;
4029
+ return await this.#failed(error, ctx, event);
3954
4030
  }
3955
4031
  }
4032
+ /** The reply of an error (see {@link #errorReply}), recorded on the event. */
4033
+ async #failed(error, ctx, event) {
4034
+ event.error = error;
4035
+ const reply = await this.#errorReply(error, ctx);
4036
+ event.status = reply.status;
4037
+ return reply;
4038
+ }
4039
+ /** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
3956
4040
  async #invoke(ctx, plan, event) {
3957
- const controller = this.#resolve(plan.controller);
3958
- for (const guard of plan.guards) if (await this.#resolve(guard).canActivate(ctx) !== true) throw new ForbiddenException("Forbidden resource");
3959
- if (plan.parseBody) await ctx.parseBody();
3960
- const definition = plan.definition;
3961
- const interceptors = plan.interceptors;
3962
- let result;
3963
- if (interceptors.length === 0) result = await definition.invoke(controller, ctx);
3964
- else {
3965
- const chain = async (index) => {
3966
- if (index === interceptors.length) return await definition.invoke(controller, ctx);
3967
- return await this.#resolve(interceptors[index]).intercept(ctx, () => chain(index + 1));
4041
+ try {
4042
+ const controller = this.#resolve(plan.controller);
4043
+ for (const guard of plan.guards) if (await this.#resolve(guard).canActivate(ctx) !== true) throw new ForbiddenException("Forbidden resource");
4044
+ if (plan.parseBody) await ctx.parseBody();
4045
+ const definition = plan.definition;
4046
+ const interceptors = plan.interceptors;
4047
+ let result;
4048
+ if (interceptors.length === 0) {
4049
+ result = definition.invoke(controller, ctx);
4050
+ if (isThenable(result)) result = await result;
4051
+ } else {
4052
+ const chain = async (index) => {
4053
+ if (index === interceptors.length) return await definition.invoke(controller, ctx);
4054
+ return await this.#resolve(interceptors[index]).intercept(ctx, () => chain(index + 1));
4055
+ };
4056
+ result = await chain(0);
4057
+ }
4058
+ if (definition.rawResponse === true && !(result instanceof Response)) return RAW;
4059
+ const options = {
4060
+ method: ctx.method,
4061
+ routeMethod: definition.method,
4062
+ status: definition.status,
4063
+ headers: definition.headers,
4064
+ context: ctx.hasResponseHeaders ? ctx.responseHeaders : void 0
3968
4065
  };
3969
- result = await chain(0);
4066
+ const sse = definition.sse;
4067
+ let reply;
4068
+ if (sse !== void 0 && !(result instanceof Response) && !isHttpResponse(result)) {
4069
+ if (!isSseSource(result)) throw new TypeError(`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`);
4070
+ reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), void 0, options);
4071
+ } else reply = isHttpResponse(result) ? await this.#serialize(result, ctx, options, sse) : serializeResult(result, options);
4072
+ return this.#finish(event, reply);
4073
+ } catch (error) {
4074
+ return await this.#failed(error, ctx, event);
3970
4075
  }
3971
- if (definition.rawResponse === true && !(result instanceof Response)) return RAW;
3972
- const options = {
3973
- method: ctx.method,
3974
- routeMethod: definition.method,
3975
- status: definition.status,
3976
- headers: definition.headers,
3977
- context: ctx.hasResponseHeaders ? ctx.responseHeaders : void 0
3978
- };
3979
- const sse = definition.sse;
3980
- let reply;
3981
- if (sse !== void 0 && !(result instanceof Response) && !isHttpResponse(result)) {
3982
- if (!isSseSource(result)) throw new TypeError(`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`);
3983
- reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), void 0, options);
3984
- } else reply = await this.#serialize(result, ctx, options, sse);
3985
- return this.#finish(event, reply);
3986
4076
  }
3987
4077
  /** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
3988
4078
  async #serialize(result, ctx, options, route) {
@@ -4132,17 +4222,9 @@ var Pipeline = class {
4132
4222
  }
4133
4223
  return this.#options.generateRequestId();
4134
4224
  }
4135
- #emit(event) {
4136
- if (this.#hooks.hasListeners("http:request")) this.#hooks.emit("http:request", event);
4137
- }
4138
4225
  #enter() {
4139
4226
  this.#inflight++;
4140
4227
  }
4141
- /** Leaves once the request is over (immediately when it failed before being served). */
4142
- #leaveWhen(served) {
4143
- if (served === void 0) this.#leave();
4144
- else served.done.then(() => this.#leave());
4145
- }
4146
4228
  #leave() {
4147
4229
  this.#inflight--;
4148
4230
  if (this.#inflight === 0 && this.#idle.length > 0) {
@@ -4159,6 +4241,13 @@ var Pipeline = class {
4159
4241
  * before a quiet stream's first byte, and lifts idle timeouts.
4160
4242
  */
4161
4243
  function adapterReply(serialized, body, blob) {
4244
+ const text = serialized.text;
4245
+ if (text !== void 0) return {
4246
+ status: serialized.status,
4247
+ headers: serialized.headers,
4248
+ body,
4249
+ text
4250
+ };
4162
4251
  const reply = {
4163
4252
  status: serialized.status,
4164
4253
  headers: serialized.headers,
@@ -4180,7 +4269,7 @@ function adapterReply(serialized, body, blob) {
4180
4269
  * one in place. One line per cookie.
4181
4270
  */
4182
4271
  function applyCookies(reply, ctx) {
4183
- const own = reply.cookieLines ?? [];
4272
+ const own = reply.cookieLines ?? NO_COOKIE_LINES;
4184
4273
  const context = ctx.cookieLines;
4185
4274
  if (own.length === 0 && context.length === 0) return;
4186
4275
  const merged = /* @__PURE__ */ new Map();
@@ -4192,6 +4281,11 @@ function applyCookies(reply, ctx) {
4192
4281
  else if (existing !== void 0) raw = [...existing];
4193
4282
  reply.headers["set-cookie"] = [...raw, ...merged.values()];
4194
4283
  }
4284
+ const NO_COOKIE_LINES = Object.freeze([]);
4285
+ /** Whether a reply body is streamed (a file is read through a stream too). */
4286
+ function isStreamed(body) {
4287
+ return body instanceof ReadableStream || body instanceof Blob;
4288
+ }
4195
4289
  /** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
4196
4290
  function sseOptions(definition) {
4197
4291
  return {
@@ -5029,7 +5123,7 @@ function resolve$1(options) {
5029
5123
  if (typeof drainTimeoutMs !== "number" || !(drainTimeoutMs >= 0)) throw invalidOption("drainTimeoutMs", "a positive number (or Infinity)", drainTimeoutMs);
5030
5124
  const requestIdHeader = options.requestIdHeader ?? DEFAULT_REQUEST_ID_HEADER;
5031
5125
  if (requestIdHeader !== false && (typeof requestIdHeader !== "string" || !isHeaderName(requestIdHeader))) throw invalidOption("requestIdHeader", "a header name or false", requestIdHeader);
5032
- const generateRequestId = options.generateRequestId ?? (() => crypto.randomUUID());
5126
+ const generateRequestId = options.generateRequestId ?? randomRequestId;
5033
5127
  if (typeof generateRequestId !== "function") throw invalidOption("generateRequestId", "a function", generateRequestId);
5034
5128
  const adapter = options.adapter ?? nodeAdapter();
5035
5129
  if (adapter === null || typeof adapter !== "object" || typeof adapter.registerRoute !== "function" || typeof adapter.listen !== "function" || typeof adapter.inject !== "function") throw invalidOption("adapter", "an HttpAdapter (nodeAdapter(), fastifyAdapter()...)", adapter);