@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.5

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.
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import { createServer, } from "node:http";
6
6
  import { Readable } from "node:stream";
7
- import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, } from "../app.js";
7
+ import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
8
+ import { BadRequestError } from "../errors.js";
8
9
  import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
10
+ import { setConnInfo } from "../conn-info.js";
9
11
  import { FrameSink, encodeFrame, encodeClosePayload, encodeSendPayload, validateUpgrade, validateSelectedSubprotocol, checkWebSocketOrigin, WS_OPCODE, WS_CLOSE_CODE, WS_READY_STATE, WS_MAX_CONTROL_PAYLOAD, WebSocketProtocolError, WebSocketPayloadTooLargeError, } from "../websocket.js";
10
12
  /**
11
13
  * Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}.
@@ -85,11 +87,23 @@ export function serve(app, opts = {}) {
85
87
  server.on("upgrade", (req, socket, head) => {
86
88
  wsSockets.add(socket);
87
89
  socket.on("close", () => wsSockets.delete(socket));
88
- void handleUpgrade(app, req, socket, head, trustProxy);
90
+ // Safety net: a rejection here would otherwise be unhandled and, under
91
+ // the production crash-on-unhandledRejection posture, kill the process
92
+ // from a single malformed upgrade request.
93
+ handleUpgrade(app, req, socket, head, trustProxy).catch((err) => {
94
+ app.log.error({ err }, "WebSocket upgrade failed");
95
+ try {
96
+ writeUpgradeError(socket, 400, "Bad Request");
97
+ }
98
+ catch {
99
+ /* socket already closed */
100
+ }
101
+ socket.destroy();
102
+ });
89
103
  });
90
104
  }
91
- const port = opts.port ?? 3000;
92
- server.listen(port, opts.hostname ?? "0.0.0.0");
105
+ const requestedPort = opts.port ?? 3000;
106
+ server.listen(requestedPort, opts.hostname ?? "0.0.0.0");
93
107
  // Kill idle keep-alive sockets immediately when draining begins.
94
108
  // In-flight requests keep their socket because Node's
95
109
  // `closeIdleConnections()` is a no-op for sockets with an in-flight request.
@@ -115,7 +129,16 @@ export function serve(app, opts = {}) {
115
129
  process.once("SIGTERM", () => onSignal("SIGTERM"));
116
130
  process.once("SIGINT", () => onSignal("SIGINT"));
117
131
  }
118
- return { server, port, close };
132
+ return {
133
+ server,
134
+ get port() {
135
+ const address = server.address();
136
+ return address !== null && typeof address === "object"
137
+ ? address.port
138
+ : requestedPort;
139
+ },
140
+ close,
141
+ };
119
142
  }
120
143
  /**
121
144
  * Default pre-buffer ceiling for the Node adapter. 256 KiB is a compromise:
@@ -135,6 +158,14 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
135
158
  writeAdapterError(res, e);
136
159
  return;
137
160
  }
161
+ // Fulfil the conn-info contract: the immediate TCP peer, so
162
+ // `getConnInfo` / `resolveClientIp` / `behindProxy` and WAF client-IP
163
+ // attribution work on Node. Never derived from spoofable headers.
164
+ setConnInfo(request, {
165
+ remoteAddress: req.socket.remoteAddress,
166
+ remotePort: req.socket.remotePort,
167
+ tls: req.socket.encrypted === true,
168
+ });
138
169
  attachClientCertificate(req, request);
139
170
  const responseOrPromise = app.fetch(request);
140
171
  if (responseOrPromise instanceof Promise) {
@@ -279,12 +310,15 @@ function writeMethodRefused(res) {
279
310
  }
280
311
  function writeAdapterError(res, e) {
281
312
  if (!res.headersSent) {
282
- res.statusCode = 500;
313
+ const clientError = e instanceof BadRequestError;
314
+ res.statusCode = clientError ? 400 : 500;
283
315
  res.setHeader("content-type", "application/problem+json");
284
316
  res.end(JSON.stringify({
285
- type: "https://daloyjs.dev/errors/internal",
286
- title: "Internal Server Error",
287
- status: 500,
317
+ type: clientError
318
+ ? "https://daloyjs.dev/errors/bad-request"
319
+ : "https://daloyjs.dev/errors/internal",
320
+ title: clientError ? "Bad Request" : "Internal Server Error",
321
+ status: clientError ? 400 : 500,
288
322
  }));
289
323
  }
290
324
  else {
@@ -307,9 +341,11 @@ function writeAdapterError(res, e) {
307
341
  * - `instanceof Request` holds (prototype chain is re-rooted onto
308
342
  * `Request.prototype`), and every WHATWG method/getter is overridden here,
309
343
  * so nothing hits undici's brand-checked prototype accessors.
310
- * - `signal` is an inert per-instance `AbortSignal`. This matches the real
311
- * adapter behaviour today: the Node adapter never wires socket aborts into
312
- * the request signal, so the signal never fires in either implementation.
344
+ * - `signal` is a lazily-created per-instance `AbortSignal`. The framework
345
+ * aborts it (via the {@link DALOY_REQUEST_ABORT} hook) when the request
346
+ * exceeds `requestTimeoutMs`, so a handler that forwards `ctx.request.signal`
347
+ * to downstream `fetch`/DB calls sees them cancel on timeout. It is still NOT
348
+ * wired to client socket-disconnect — that teardown never fires the signal.
313
349
  * - Passing this object directly to `fetch()` is not supported (undici
314
350
  * brand-checks its input) — forward with `request.clone()` instead, which
315
351
  * returns a real `Request`. This mirrors @hono/node-server's shim.
@@ -327,7 +363,7 @@ class LightRequest {
327
363
  #headers;
328
364
  #bodyBytes;
329
365
  #real;
330
- #signal;
366
+ #controller;
331
367
  constructor(url, method, headers, bodyBytes) {
332
368
  this.#url = url;
333
369
  this.#method = method;
@@ -355,10 +391,22 @@ class LightRequest {
355
391
  return this.#headers;
356
392
  }
357
393
  get signal() {
358
- // Inert, lazily created: the Node adapter has never wired socket
359
- // teardown into the request signal, so a never-firing signal is
360
- // behaviourally identical to the one a real `Request` would carry.
361
- return (this.#signal ??= new AbortController().signal);
394
+ // Lazily created so only handlers that actually read `signal` pay for the
395
+ // controller. The framework aborts it via the DALOY_REQUEST_ABORT hook
396
+ // below when the request exceeds `requestTimeoutMs`; client
397
+ // socket-disconnect is not wired into it.
398
+ return (this.#controller ??= new AbortController()).signal;
399
+ }
400
+ /**
401
+ * Framework abort hook ({@link DALOY_REQUEST_ABORT}). Invoked by the core on
402
+ * request timeout so `ctx.request.signal` fires for cooperative teardown.
403
+ * A no-op when no handler ever read `signal` — there is no controller to
404
+ * abort and nothing observing it.
405
+ *
406
+ * @param reason - Abort reason surfaced on `signal.reason` (a `TimeoutError`).
407
+ */
408
+ [DALOY_REQUEST_ABORT](reason) {
409
+ this.#controller?.abort(reason);
362
410
  }
363
411
  get body() {
364
412
  return this.#materialize().body;
@@ -506,6 +554,13 @@ function toWebRequest(req, trustProxy, bufferedBody) {
506
554
  const proto = forwardedProto ??
507
555
  (req.socket.encrypted ? "https" : "http");
508
556
  const url = `${proto}://${host}${normalizeRequestTarget(req.url)}`;
557
+ // Reject malformed Host / request-target combinations at the adapter
558
+ // boundary instead of letting the invalid URL propagate as a 500 later.
559
+ // URL.canParse applies WHATWG validation without allocating and immediately
560
+ // discarding a URL object on every request.
561
+ if (!URL.canParse(url)) {
562
+ throw new BadRequestError("Invalid request target or Host header");
563
+ }
509
564
  // Build headers from `rawHeaders` (a flat [k0,v0,k1,v1,...] array) instead
510
565
  // of the parsed `req.headers` object. This matches @hono/node-server's
511
566
  // `newHeadersFromIncoming`: one `new Headers([[k,v],...])` constructor
@@ -578,7 +633,21 @@ function normalizeRequestTarget(target) {
578
633
  }
579
634
  function sendWebResponse(res, out) {
580
635
  out.statusCode = res.status;
581
- res.headers.forEach((v, k) => out.setHeader(k, v));
636
+ // `Headers.forEach` yields each `Set-Cookie` as a separate callback while
637
+ // `ServerResponse.setHeader` overwrites repeated keys — copying naively
638
+ // keeps only the LAST cookie (e.g. dropping the session cookie when
639
+ // `csrf()` also sets its token cookie). Collect them via `getSetCookie()`
640
+ // and set the array once so every cookie reaches the wire.
641
+ let hasSetCookie = false;
642
+ res.headers.forEach((v, k) => {
643
+ if (k === "set-cookie") {
644
+ hasSetCookie = true;
645
+ return;
646
+ }
647
+ out.setHeader(k, v);
648
+ });
649
+ if (hasSetCookie)
650
+ out.setHeader("set-cookie", res.headers.getSetCookie());
582
651
  // Fast-path: response was produced by serializeResult and carries the raw
583
652
  // body bytes via the DALOY_RAW_BODY Symbol. Skip arrayBuffer() and the
584
653
  // reader-loop microtask churn entirely for buffer-backed responses.
@@ -661,7 +730,18 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
661
730
  : undefined;
662
731
  const proto = forwardedProto ??
663
732
  (req.socket.encrypted ? "https" : "http");
664
- const url = new URL(`${proto}://${host}${req.url ?? "/"}`);
733
+ // A malformed `Host` header (e.g. containing a space) reaches this point:
734
+ // Node's HTTP parser accepts it and fires `upgrade`, but WHATWG URL
735
+ // parsing throws. Reject it as the client error it is instead of letting
736
+ // the throw escape the adapter.
737
+ let url;
738
+ try {
739
+ url = new URL(`${proto}://${host}${req.url ?? "/"}`);
740
+ }
741
+ catch {
742
+ writeUpgradeError(socket, 400, "Bad Request");
743
+ return;
744
+ }
665
745
  const match = app.webSocketRoutes.find(url.pathname);
666
746
  if (!match) {
667
747
  writeUpgradeError(socket, 404, "Not Found");
@@ -682,6 +762,11 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
682
762
  method: "GET",
683
763
  headers,
684
764
  });
765
+ setConnInfo(request, {
766
+ remoteAddress: req.socket.remoteAddress,
767
+ remotePort: req.socket.remotePort,
768
+ tls: req.socket.encrypted === true,
769
+ });
685
770
  const ctx = {
686
771
  request,
687
772
  params: match.params,
package/dist/app.d.ts CHANGED
@@ -78,7 +78,12 @@ export interface AppOptions {
78
78
  bodyLimitBytes?: number;
79
79
  /** Reject requests whose Content-Type isn't in this allowlist (when a body schema is declared). */
80
80
  allowedContentTypes?: string[];
81
- /** Per-request timeout in ms (handler + hooks). Default: 30000. Set 0 to disable. */
81
+ /**
82
+ * Per-request timeout in ms (handler + hooks). Default: 30000. Set 0 to
83
+ * disable. On timeout the framework aborts `ctx.request.signal` (cooperative
84
+ * cancellation of downstream I/O) and responds `408`; see
85
+ * {@link RequestTimeoutError}. It does not forcibly stop CPU-bound work.
86
+ */
82
87
  requestTimeoutMs?: number;
83
88
  /**
84
89
  * Maximum number of distinct request header fields accepted before the
@@ -771,6 +776,23 @@ export declare const DALOY_RAW_BODY: unique symbol;
771
776
  * so first-party adapters can opt in; not part of the userland API surface.
772
777
  */
773
778
  export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
779
+ /**
780
+ * Internal Symbol an adapter sets (on its request shim) to expose the request's
781
+ * abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
782
+ * backing `request.signal`. The core invokes it when a request exceeds
783
+ * {@link AppOptions.requestTimeoutMs} so a handler that forwarded
784
+ * `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
785
+ * calls cancel — cooperative teardown, since single-threaded JS cannot preempt
786
+ * a running handler.
787
+ *
788
+ * The hook is invoked as a method on the request (`this` stays bound to the
789
+ * shim) so it can reach the shim's private controller. Absent on runtimes
790
+ * whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
791
+ * on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
792
+ * and the timeout still resolves as a `408`. Module-public so first-party
793
+ * adapters can opt in; not part of the userland API surface.
794
+ */
795
+ export declare const DALOY_REQUEST_ABORT: unique symbol;
774
796
  /**
775
797
  * Internal Symbol set by handlers/serializers to attach a raw stream
776
798
  * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
@@ -1687,8 +1709,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1687
1709
  * "draining" signal); then the app waits up to `timeoutMs` for in-flight
1688
1710
  * requests to settle; finally, {@link App.onClose} cleanups run.
1689
1711
  *
1690
- * Both Node and Bun adapters call this automatically on `SIGINT` / `SIGTERM`.
1691
- * Call it manually from custom runtimes or integration tests.
1712
+ * The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
1713
+ * `SIGTERM`. Call it manually from custom runtimes or integration tests.
1692
1714
  *
1693
1715
  * @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
1694
1716
  * @param reason - Optional human-readable reason forwarded to listeners.
package/dist/app.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Router } from "./router.js";
2
2
  import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
3
3
  import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
4
- import { readBodyLimited, safeJsonParseLimited, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
5
- import { createLogger, noopLogger } from "./logger.js";
4
+ import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
5
+ import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
6
6
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
7
7
  import { isSchemaValidatedResponse } from "./internal-response.js";
8
8
  import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
@@ -196,6 +196,23 @@ export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
196
196
  * so first-party adapters can opt in; not part of the userland API surface.
197
197
  */
198
198
  export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
199
+ /**
200
+ * Internal Symbol an adapter sets (on its request shim) to expose the request's
201
+ * abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
202
+ * backing `request.signal`. The core invokes it when a request exceeds
203
+ * {@link AppOptions.requestTimeoutMs} so a handler that forwarded
204
+ * `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
205
+ * calls cancel — cooperative teardown, since single-threaded JS cannot preempt
206
+ * a running handler.
207
+ *
208
+ * The hook is invoked as a method on the request (`this` stays bound to the
209
+ * shim) so it can reach the shim's private controller. Absent on runtimes
210
+ * whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
211
+ * on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
212
+ * and the timeout still resolves as a `408`. Module-public so first-party
213
+ * adapters can opt in; not part of the userland API surface.
214
+ */
215
+ export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
199
216
  /**
200
217
  * Internal Symbol set by handlers/serializers to attach a raw stream
201
218
  * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
@@ -1701,6 +1718,7 @@ export class App {
1701
1718
  }));
1702
1719
  this._coldPathHooksCache = undefined;
1703
1720
  const buckets = rateLimitConfig ? new Map() : null;
1721
+ const trustProxyHeaders = appTrustsProxyHeaders(this.options);
1704
1722
  this.route({
1705
1723
  method: "GET",
1706
1724
  path,
@@ -1711,7 +1729,7 @@ export class App {
1711
1729
  acknowledgeNoResponseBodySchema: true,
1712
1730
  handler: async ({ request }) => {
1713
1731
  if (buckets && rateLimitConfig) {
1714
- const key = healthRouteKey(request);
1732
+ const key = healthRouteKey(request, trustProxyHeaders);
1715
1733
  const now = Date.now();
1716
1734
  const entry = buckets.get(key);
1717
1735
  if (!entry || entry.resetMs <= now) {
@@ -1821,6 +1839,7 @@ export class App {
1821
1839
  `to acknowledge that this probe is reachable without credentials.`);
1822
1840
  }
1823
1841
  const buckets = rateLimitConfig ? new Map() : null;
1842
+ const trustProxyHeaders = appTrustsProxyHeaders(this.options);
1824
1843
  this.route({
1825
1844
  method: "GET",
1826
1845
  path,
@@ -1831,7 +1850,7 @@ export class App {
1831
1850
  acknowledgeNoResponseBodySchema: true,
1832
1851
  handler: async ({ request }) => {
1833
1852
  if (buckets && rateLimitConfig) {
1834
- const key = healthRouteKey(request);
1853
+ const key = healthRouteKey(request, trustProxyHeaders);
1835
1854
  const now = Date.now();
1836
1855
  const entry = buckets.get(key);
1837
1856
  if (!entry || entry.resetMs <= now) {
@@ -1895,6 +1914,7 @@ export class App {
1895
1914
  }
1896
1915
  const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1897
1916
  const buckets = rateLimitConfig ? new Map() : null;
1917
+ const trustProxyHeaders = appTrustsProxyHeaders(this.options);
1898
1918
  const log = this.log;
1899
1919
  // Only log report bodies when explicitly enabled. In
1900
1920
  // production this is opt-in; in development the body is included by
@@ -1908,7 +1928,7 @@ export class App {
1908
1928
  summary: "CSP / Reporting API violation receiver",
1909
1929
  handler: async ({ request }) => {
1910
1930
  if (buckets && rateLimitConfig) {
1911
- const key = healthRouteKey(request);
1931
+ const key = healthRouteKey(request, trustProxyHeaders);
1912
1932
  const now = Date.now();
1913
1933
  const entry = buckets.get(key);
1914
1934
  if (!entry || entry.resetMs <= now) {
@@ -1944,7 +1964,7 @@ export class App {
1944
1964
  if (parsed === undefined) {
1945
1965
  throw new BadRequestError("Invalid JSON report body");
1946
1966
  }
1947
- const ip = healthRouteKey(request);
1967
+ const ip = healthRouteKey(request, trustProxyHeaders);
1948
1968
  const userAgent = request.headers.get("user-agent");
1949
1969
  try {
1950
1970
  if (opts.onReport) {
@@ -2385,7 +2405,11 @@ export class App {
2385
2405
  : baseLog.child({
2386
2406
  requestId,
2387
2407
  method: request.method,
2388
- url: request.url,
2408
+ // Never bind the raw request URL: query strings commonly carry
2409
+ // OAuth codes, API keys, and signed-URL tokens. sanitizeUrlForLog
2410
+ // keeps origin+path and redacts sensitive query values so 4xx/5xx
2411
+ // lines cannot become a credential sink under the field name `url`.
2412
+ url: sanitizeUrlForLog(request.url),
2389
2413
  });
2390
2414
  const stripFingerprint = this.options.stripServerHeaders !== false;
2391
2415
  let ctx;
@@ -2394,9 +2418,10 @@ export class App {
2394
2418
  let activeResponseHook = globalHooks.onResponse;
2395
2419
  let activeSendHook = globalHooks.onSend;
2396
2420
  try {
2397
- assertNoDuplicateSingletonHeaders(request.headers);
2398
- assertNoReservedInternalHeaders(request.headers);
2399
- assertHeaderCountWithinLimit(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
2421
+ // Singleton-duplicate + reserved-prefix + header-count cap share ONE
2422
+ // Headers.forEach walk (assertInboundHeaderGuards) instead of a
2423
+ // three-Headers.get() pass plus a separate walk.
2424
+ assertInboundHeaderGuards(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
2400
2425
  this.assertTrustProxyConfigured(request);
2401
2426
  this.assertBootGuards();
2402
2427
  if (globalHooks.onRequest !== undefined) {
@@ -2862,8 +2887,8 @@ export class App {
2862
2887
  * "draining" signal); then the app waits up to `timeoutMs` for in-flight
2863
2888
  * requests to settle; finally, {@link App.onClose} cleanups run.
2864
2889
  *
2865
- * Both Node and Bun adapters call this automatically on `SIGINT` / `SIGTERM`.
2866
- * Call it manually from custom runtimes or integration tests.
2890
+ * The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
2891
+ * `SIGTERM`. Call it manually from custom runtimes or integration tests.
2867
2892
  *
2868
2893
  * @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
2869
2894
  * @param reason - Optional human-readable reason forwarded to listeners.
@@ -2954,14 +2979,30 @@ function joinPath(a, b) {
2954
2979
  const joined = `${left}${right}`;
2955
2980
  return joined === "" ? "/" : joined;
2956
2981
  }
2957
- function healthRouteKey(request) {
2958
- // The probe rate limit deliberately does NOT honour `X-Forwarded-For`
2959
- // health probes typically arrive directly from a sidecar / orchestrator,
2960
- // so even apps that trust forwarded headers should not let an attacker
2961
- // bypass the per-IP cap by spoofing the header. Fall back to a constant
2962
- // key when no proxy header is available (single shared bucket).
2982
+ /**
2983
+ * Rate-limit / attribution key for built-in observability routes
2984
+ * (`/healthz`, `/readyz`, `/metrics`, CSP report).
2985
+ *
2986
+ * Secure default: a single shared `"global"` bucket. Spoofable platform
2987
+ * headers (`X-Real-IP`, `Fly-Client-IP`) are only read when the app has an
2988
+ * explicit trusted-proxy posture (`trustProxy: true` or `behindProxy` set).
2989
+ * `X-Forwarded-For` is never used here — probes and scrapers often hit the
2990
+ * process directly, and a free-form XFF chain would let an attacker rotate
2991
+ * identities to bypass the cap.
2992
+ *
2993
+ * @param request - Inbound request.
2994
+ * @param trustProxyHeaders - When true, platform client-IP headers may be used.
2995
+ * @returns A stable string key for the in-memory rate-limit map.
2996
+ */
2997
+ function healthRouteKey(request, trustProxyHeaders) {
2998
+ if (!trustProxyHeaders)
2999
+ return "global";
2963
3000
  return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
2964
3001
  }
3002
+ /** True when the app declared a trusted reverse-proxy posture. */
3003
+ function appTrustsProxyHeaders(options) {
3004
+ return options.trustProxy === true || options.behindProxy !== undefined;
3005
+ }
2965
3006
  function corsOriginAllowsFromHooks(layers) {
2966
3007
  const allows = [];
2967
3008
  for (const hooks of layers) {
@@ -3335,34 +3376,32 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
3335
3376
  }
3336
3377
  function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
3337
3378
  let final = res;
3338
- const finish = (f) => {
3339
- if (stripFingerprint) {
3340
- f.headers.delete("server");
3341
- f.headers.delete("x-powered-by");
3342
- }
3343
- if (hooks.onResponse !== undefined) {
3344
- const onResponseResult = hooks.onResponse(f);
3345
- if (isPromiseLike(onResponseResult)) {
3346
- return onResponseResult.then(() => f);
3347
- }
3348
- }
3349
- return f;
3350
- };
3351
3379
  if (hooks.onSend !== undefined) {
3352
3380
  const sentResult = hooks.onSend(res, ctx);
3353
3381
  if (isPromiseLike(sentResult)) {
3354
3382
  return sentResult.then((sent) => {
3355
3383
  if (sent instanceof Response)
3356
3384
  final = sent;
3357
- return finish(final);
3385
+ return finishFinalize(final, hooks, stripFingerprint);
3358
3386
  });
3359
3387
  }
3360
- else {
3361
- if (sentResult instanceof Response)
3362
- final = sentResult;
3388
+ if (sentResult instanceof Response)
3389
+ final = sentResult;
3390
+ }
3391
+ return finishFinalize(final, hooks, stripFingerprint);
3392
+ }
3393
+ function finishFinalize(res, hooks, stripFingerprint) {
3394
+ if (stripFingerprint) {
3395
+ res.headers.delete("server");
3396
+ res.headers.delete("x-powered-by");
3397
+ }
3398
+ if (hooks.onResponse !== undefined) {
3399
+ const onResponseResult = hooks.onResponse(res);
3400
+ if (isPromiseLike(onResponseResult)) {
3401
+ return onResponseResult.then(() => res);
3363
3402
  }
3364
3403
  }
3365
- return finish(final);
3404
+ return res;
3366
3405
  }
3367
3406
  function isPromiseLike(value) {
3368
3407
  return value !== null && typeof value === "object" && typeof value.then === "function";
@@ -3991,11 +4030,46 @@ function runHandler(def, ctx, requestTimeoutMs) {
3991
4030
  if (requestTimeoutMs === 0 || !isPromiseLike(result)) {
3992
4031
  return result;
3993
4032
  }
3994
- return withTimeout(result, requestTimeoutMs);
4033
+ return withTimeout(result, requestTimeoutMs, ctx.request);
3995
4034
  }
3996
- function withTimeout(p, ms) {
4035
+ /**
4036
+ * Fire an adapter's request abort hook (see {@link DALOY_REQUEST_ABORT}) if the
4037
+ * request shim exposes one. Called as a method so `this` stays bound to the
4038
+ * request. A no-op when the hook is absent (platform-managed `Request.signal`
4039
+ * or a direct `app.fetch()` caller), so the caller must not depend on the
4040
+ * signal actually firing.
4041
+ *
4042
+ * @param request - The in-flight request whose signal should be aborted.
4043
+ * @param reason - Abort reason surfaced on `request.signal.reason`.
4044
+ */
4045
+ function abortRequest(request, reason) {
4046
+ const hooked = request;
4047
+ hooked[DALOY_REQUEST_ABORT]?.(reason);
4048
+ }
4049
+ /**
4050
+ * Race a handler promise against the per-request timeout.
4051
+ *
4052
+ * On timeout the request's {@link DALOY_REQUEST_ABORT} hook is fired first —
4053
+ * aborting `request.signal` with a `TimeoutError` `DOMException` (the same
4054
+ * reason shape as `AbortSignal.timeout()`) so cooperative downstream I/O the
4055
+ * handler forwarded the signal to unwinds — and then the returned promise
4056
+ * rejects with a {@link RequestTimeoutError} so the client receives a `408`.
4057
+ * The handler promise itself keeps a rejection handler attached, so a late
4058
+ * settle (including the `AbortError` from the work it just cancelled) never
4059
+ * surfaces as an unhandled rejection.
4060
+ *
4061
+ * @typeParam T - The handler's resolved value type.
4062
+ * @param p - The handler (or hook chain) promise to bound.
4063
+ * @param ms - Timeout in milliseconds; assumed non-zero by the caller.
4064
+ * @param request - The in-flight request, used to fire the abort hook.
4065
+ * @returns A promise that settles with the handler result or a 408 timeout.
4066
+ */
4067
+ function withTimeout(p, ms, request) {
3997
4068
  return new Promise((resolve, reject) => {
3998
- const t = setTimeout(() => reject(new RequestTimeoutError(ms)), ms);
4069
+ const t = setTimeout(() => {
4070
+ abortRequest(request, new DOMException(`Request exceeded ${ms}ms`, "TimeoutError"));
4071
+ reject(new RequestTimeoutError(ms));
4072
+ }, ms);
3999
4073
  p.then((v) => {
4000
4074
  clearTimeout(t);
4001
4075
  resolve(v);
package/dist/bot-guard.js CHANGED
@@ -76,8 +76,14 @@ function matchesUserAgent(ua, patterns) {
76
76
  if (pattern && lower.includes(pattern.toLowerCase()))
77
77
  return true;
78
78
  }
79
- else if (pattern.test(ua)) {
80
- return true;
79
+ else {
80
+ // Reset lastIndex so caller-supplied /g or /y regexes cannot flip-flop
81
+ // between match and miss across requests (intermittent allowlist bypass).
82
+ pattern.lastIndex = 0;
83
+ const hit = pattern.test(ua);
84
+ pattern.lastIndex = 0;
85
+ if (hit)
86
+ return true;
81
87
  }
82
88
  }
83
89
  return false;
@@ -225,12 +231,28 @@ export function botGuard(opts = {}) {
225
231
  };
226
232
  const writeCache = (key, verified) => {
227
233
  const now = Date.now();
234
+ // Move this key to the newest insertion slot on every (re)write. Eviction
235
+ // below is therefore FIFO over WRITE-recency (Map preserves insertion
236
+ // order), not true LRU: cache *reads* on the verification path do not
237
+ // reorder entries, so a frequently-read-but-never-rewritten key can still
238
+ // be evicted. That is intentional — reordering on read would add a Map
239
+ // delete+set to the hot lookup path for no security benefit.
240
+ if (cache.has(key))
241
+ cache.delete(key);
228
242
  cache.set(key, { verified, expiresMs: now + cacheTtlMs });
229
243
  if (cache.size > cacheMax) {
230
244
  for (const [k, v] of cache)
231
245
  if (v.expiresMs <= now)
232
246
  cache.delete(k);
233
247
  }
248
+ // Still over the cap after pruning expired entries: evict the
249
+ // oldest-written live keys (front of insertion order) until within cacheMax.
250
+ while (cache.size > cacheMax) {
251
+ const oldest = cache.keys().next().value;
252
+ if (oldest === undefined)
253
+ break;
254
+ cache.delete(oldest);
255
+ }
234
256
  };
235
257
  const reject = (event) => {
236
258
  opts.onBlock?.(event);
@@ -252,7 +274,12 @@ export function botGuard(opts = {}) {
252
274
  reject({ reason: "blocked-user-agent", userAgent: ua });
253
275
  return undefined;
254
276
  }
255
- const rule = verifiedBots.find((r) => r.userAgent.test(ua));
277
+ const rule = verifiedBots.find((r) => {
278
+ r.userAgent.lastIndex = 0;
279
+ const hit = r.userAgent.test(ua);
280
+ r.userAgent.lastIndex = 0;
281
+ return hit;
282
+ });
256
283
  if (!rule)
257
284
  return undefined;
258
285
  const ip = resolveIp(ctx);