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

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.
Files changed (65) hide show
  1. package/README.md +34 -22
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +42 -7
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +117 -46
  9. package/dist/app.d.ts +30 -4
  10. package/dist/app.js +187 -45
  11. package/dist/auto-ban.js +1 -3
  12. package/dist/bot-guard.js +30 -3
  13. package/dist/cli.js +9 -6
  14. package/dist/client.d.ts +36 -7
  15. package/dist/client.js +7 -0
  16. package/dist/compression.d.ts +9 -0
  17. package/dist/compression.js +72 -1
  18. package/dist/config.js +1 -3
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/errors.d.ts +12 -3
  22. package/dist/errors.js +14 -8
  23. package/dist/etag.js +12 -2
  24. package/dist/fetch-guard.d.ts +27 -19
  25. package/dist/fetch-guard.js +50 -8
  26. package/dist/geo-block.js +4 -9
  27. package/dist/hashing.js +1 -1
  28. package/dist/http-signatures.d.ts +4 -1
  29. package/dist/http-signatures.js +16 -9
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +3 -3
  32. package/dist/ip-reputation.js +1 -1
  33. package/dist/ip-restriction.js +3 -12
  34. package/dist/jwt.js +12 -14
  35. package/dist/logger.d.ts +45 -0
  36. package/dist/logger.js +135 -0
  37. package/dist/mcp.js +10 -9
  38. package/dist/middleware.js +33 -3
  39. package/dist/mtls.js +6 -1
  40. package/dist/multipart.js +9 -12
  41. package/dist/openapi.d.ts +1 -1
  42. package/dist/openapi.js +2 -2
  43. package/dist/rate-limit-redis.d.ts +4 -4
  44. package/dist/response-cache.d.ts +179 -21
  45. package/dist/response-cache.js +338 -29
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +5 -1
  49. package/dist/safe-redirect.js +27 -3
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security-schemes.js +1 -2
  53. package/dist/security.d.ts +41 -0
  54. package/dist/security.js +131 -15
  55. package/dist/session.d.ts +13 -2
  56. package/dist/session.js +111 -17
  57. package/dist/subdomains.js +1 -4
  58. package/dist/tenancy.d.ts +40 -0
  59. package/dist/tenancy.js +54 -3
  60. package/dist/time-claims.js +3 -1
  61. package/dist/waf.js +124 -32
  62. package/dist/webhook-delivery.js +19 -3
  63. package/dist/websocket.d.ts +8 -0
  64. package/dist/websocket.js +19 -4
  65. package/package.json +6 -5
@@ -76,7 +76,14 @@ export interface NodeServerOptions {
76
76
  export interface NodeServerHandle {
77
77
  /** The underlying `node:http` `Server` instance, for advanced wiring (extra listeners, address introspection). */
78
78
  server: Server;
79
- /** Port the server was asked to listen on ({@link NodeServerOptions.port}, default `3000`). */
79
+ /**
80
+ * Bound TCP port once the server emits `listening`.
81
+ *
82
+ * Before the listener is ready, this is the requested
83
+ * {@link NodeServerOptions.port} (default `3000`). In particular, callers
84
+ * using `port: 0` must await the server's `listening` event before reading
85
+ * this property to receive the OS-assigned ephemeral port.
86
+ */
80
87
  port: number;
81
88
  /** Graceful shutdown: drains {@link App.shutdown} hooks, destroys WebSocket sockets, then closes the server. Idempotent. */
82
89
  close(): Promise<void>;
@@ -2,10 +2,12 @@
2
2
  * Node adapter: translates IncomingMessage/ServerResponse to web-standard
3
3
  * Request/Response. Includes graceful shutdown wired to SIGTERM/SIGINT.
4
4
  */
5
- import { createServer, } from "node:http";
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}.
@@ -72,9 +74,7 @@ export function serve(app, opts = {}) {
72
74
  // `0` opts out and restores Node's unbounded-ish default (2000).
73
75
  const maxHeaderCount = opts.maxHeaderCount;
74
76
  server.maxHeadersCount =
75
- typeof maxHeaderCount === "number" && maxHeaderCount >= 0
76
- ? maxHeaderCount
77
- : 100;
77
+ typeof maxHeaderCount === "number" && maxHeaderCount >= 0 ? maxHeaderCount : 100;
78
78
  // Connection-layer admission control. Reject overflow sockets at accept time
79
79
  // rather than queuing them into the event loop under overload.
80
80
  if (typeof opts.maxConnections === "number" && opts.maxConnections > 0) {
@@ -85,11 +85,23 @@ export function serve(app, opts = {}) {
85
85
  server.on("upgrade", (req, socket, head) => {
86
86
  wsSockets.add(socket);
87
87
  socket.on("close", () => wsSockets.delete(socket));
88
- void handleUpgrade(app, req, socket, head, trustProxy);
88
+ // Safety net: a rejection here would otherwise be unhandled and, under
89
+ // the production crash-on-unhandledRejection posture, kill the process
90
+ // from a single malformed upgrade request.
91
+ handleUpgrade(app, req, socket, head, trustProxy).catch((err) => {
92
+ app.log.error({ err }, "WebSocket upgrade failed");
93
+ try {
94
+ writeUpgradeError(socket, 400, "Bad Request");
95
+ }
96
+ catch {
97
+ /* socket already closed */
98
+ }
99
+ socket.destroy();
100
+ });
89
101
  });
90
102
  }
91
- const port = opts.port ?? 3000;
92
- server.listen(port, opts.hostname ?? "0.0.0.0");
103
+ const requestedPort = opts.port ?? 3000;
104
+ server.listen(requestedPort, opts.hostname ?? "0.0.0.0");
93
105
  // Kill idle keep-alive sockets immediately when draining begins.
94
106
  // In-flight requests keep their socket because Node's
95
107
  // `closeIdleConnections()` is a no-op for sockets with an in-flight request.
@@ -111,11 +123,21 @@ export function serve(app, opts = {}) {
111
123
  await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
112
124
  };
113
125
  if (opts.handleSignals !== false) {
114
- const onSignal = (sig) => { app.log.info({ sig }, "DaloyJS received signal, shutting down"); void close().then(() => process.exit(0)); };
126
+ const onSignal = (sig) => {
127
+ app.log.info({ sig }, "DaloyJS received signal, shutting down");
128
+ void close().then(() => process.exit(0));
129
+ };
115
130
  process.once("SIGTERM", () => onSignal("SIGTERM"));
116
131
  process.once("SIGINT", () => onSignal("SIGINT"));
117
132
  }
118
- return { server, port, close };
133
+ return {
134
+ server,
135
+ get port() {
136
+ const address = server.address();
137
+ return address !== null && typeof address === "object" ? address.port : requestedPort;
138
+ },
139
+ close,
140
+ };
119
141
  }
120
142
  /**
121
143
  * Default pre-buffer ceiling for the Node adapter. 256 KiB is a compromise:
@@ -135,6 +157,14 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
135
157
  writeAdapterError(res, e);
136
158
  return;
137
159
  }
160
+ // Fulfil the conn-info contract: the immediate TCP peer, so
161
+ // `getConnInfo` / `resolveClientIp` / `behindProxy` and WAF client-IP
162
+ // attribution work on Node. Never derived from spoofable headers.
163
+ setConnInfo(request, {
164
+ remoteAddress: req.socket.remoteAddress,
165
+ remotePort: req.socket.remotePort,
166
+ tls: req.socket.encrypted === true,
167
+ });
138
168
  attachClientCertificate(req, request);
139
169
  const responseOrPromise = app.fetch(request);
140
170
  if (responseOrPromise instanceof Promise) {
@@ -249,11 +279,7 @@ function attachClientCertificate(req, request) {
249
279
  * Node's `connect` event rather than the request listener; it is included here
250
280
  * defensively for runtimes/proxies that surface it as a normal request.)
251
281
  */
252
- const FETCH_FORBIDDEN_METHODS = new Set([
253
- "CONNECT",
254
- "TRACE",
255
- "TRACK",
256
- ]);
282
+ const FETCH_FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
257
283
  /**
258
284
  * Refuse a Fetch-forbidden HTTP method with a spec-correct `501 Not
259
285
  * Implemented`. `501` is more accurate than `405` here because the method is
@@ -279,12 +305,15 @@ function writeMethodRefused(res) {
279
305
  }
280
306
  function writeAdapterError(res, e) {
281
307
  if (!res.headersSent) {
282
- res.statusCode = 500;
308
+ const clientError = e instanceof BadRequestError;
309
+ res.statusCode = clientError ? 400 : 500;
283
310
  res.setHeader("content-type", "application/problem+json");
284
311
  res.end(JSON.stringify({
285
- type: "https://daloyjs.dev/errors/internal",
286
- title: "Internal Server Error",
287
- status: 500,
312
+ type: clientError
313
+ ? "https://daloyjs.dev/errors/bad-request"
314
+ : "https://daloyjs.dev/errors/internal",
315
+ title: clientError ? "Bad Request" : "Internal Server Error",
316
+ status: clientError ? 400 : 500,
288
317
  }));
289
318
  }
290
319
  else {
@@ -307,9 +336,11 @@ function writeAdapterError(res, e) {
307
336
  * - `instanceof Request` holds (prototype chain is re-rooted onto
308
337
  * `Request.prototype`), and every WHATWG method/getter is overridden here,
309
338
  * 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.
339
+ * - `signal` is a lazily-created per-instance `AbortSignal`. The framework
340
+ * aborts it (via the {@link DALOY_REQUEST_ABORT} hook) when the request
341
+ * exceeds `requestTimeoutMs`, so a handler that forwards `ctx.request.signal`
342
+ * to downstream `fetch`/DB calls sees them cancel on timeout. It is still NOT
343
+ * wired to client socket-disconnect — that teardown never fires the signal.
313
344
  * - Passing this object directly to `fetch()` is not supported (undici
314
345
  * brand-checks its input) — forward with `request.clone()` instead, which
315
346
  * returns a real `Request`. This mirrors @hono/node-server's shim.
@@ -327,7 +358,7 @@ class LightRequest {
327
358
  #headers;
328
359
  #bodyBytes;
329
360
  #real;
330
- #signal;
361
+ #controller;
331
362
  constructor(url, method, headers, bodyBytes) {
332
363
  this.#url = url;
333
364
  this.#method = method;
@@ -355,10 +386,22 @@ class LightRequest {
355
386
  return this.#headers;
356
387
  }
357
388
  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);
389
+ // Lazily created so only handlers that actually read `signal` pay for the
390
+ // controller. The framework aborts it via the DALOY_REQUEST_ABORT hook
391
+ // below when the request exceeds `requestTimeoutMs`; client
392
+ // socket-disconnect is not wired into it.
393
+ return (this.#controller ??= new AbortController()).signal;
394
+ }
395
+ /**
396
+ * Framework abort hook ({@link DALOY_REQUEST_ABORT}). Invoked by the core on
397
+ * request timeout so `ctx.request.signal` fires for cooperative teardown.
398
+ * A no-op when no handler ever read `signal` — there is no controller to
399
+ * abort and nothing observing it.
400
+ *
401
+ * @param reason - Abort reason surfaced on `signal.reason` (a `TimeoutError`).
402
+ */
403
+ [DALOY_REQUEST_ABORT](reason) {
404
+ this.#controller?.abort(reason);
362
405
  }
363
406
  get body() {
364
407
  return this.#materialize().body;
@@ -496,16 +539,18 @@ Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
496
539
  LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
497
540
  function toWebRequest(req, trustProxy, bufferedBody) {
498
541
  const reqHeaders = req.headers;
499
- const forwardedHost = trustProxy
500
- ? firstHeader(reqHeaders["x-forwarded-host"])
501
- : undefined;
542
+ const forwardedHost = trustProxy ? firstHeader(reqHeaders["x-forwarded-host"]) : undefined;
502
543
  const host = forwardedHost ?? reqHeaders.host ?? "localhost";
503
- const forwardedProto = trustProxy
504
- ? firstHeader(reqHeaders["x-forwarded-proto"])
505
- : undefined;
506
- const proto = forwardedProto ??
507
- (req.socket.encrypted ? "https" : "http");
544
+ const forwardedProto = trustProxy ? firstHeader(reqHeaders["x-forwarded-proto"]) : undefined;
545
+ const proto = forwardedProto ?? (req.socket.encrypted ? "https" : "http");
508
546
  const url = `${proto}://${host}${normalizeRequestTarget(req.url)}`;
547
+ // Reject malformed Host / request-target combinations at the adapter
548
+ // boundary instead of letting the invalid URL propagate as a 500 later.
549
+ // URL.canParse applies WHATWG validation without allocating and immediately
550
+ // discarding a URL object on every request.
551
+ if (!URL.canParse(url)) {
552
+ throw new BadRequestError("Invalid request target or Host header");
553
+ }
509
554
  // Build headers from `rawHeaders` (a flat [k0,v0,k1,v1,...] array) instead
510
555
  // of the parsed `req.headers` object. This matches @hono/node-server's
511
556
  // `newHeadersFromIncoming`: one `new Headers([[k,v],...])` constructor
@@ -578,7 +623,21 @@ function normalizeRequestTarget(target) {
578
623
  }
579
624
  function sendWebResponse(res, out) {
580
625
  out.statusCode = res.status;
581
- res.headers.forEach((v, k) => out.setHeader(k, v));
626
+ // `Headers.forEach` yields each `Set-Cookie` as a separate callback while
627
+ // `ServerResponse.setHeader` overwrites repeated keys — copying naively
628
+ // keeps only the LAST cookie (e.g. dropping the session cookie when
629
+ // `csrf()` also sets its token cookie). Collect them via `getSetCookie()`
630
+ // and set the array once so every cookie reaches the wire.
631
+ let hasSetCookie = false;
632
+ res.headers.forEach((v, k) => {
633
+ if (k === "set-cookie") {
634
+ hasSetCookie = true;
635
+ return;
636
+ }
637
+ out.setHeader(k, v);
638
+ });
639
+ if (hasSetCookie)
640
+ out.setHeader("set-cookie", res.headers.getSetCookie());
582
641
  // Fast-path: response was produced by serializeResult and carries the raw
583
642
  // body bytes via the DALOY_RAW_BODY Symbol. Skip arrayBuffer() and the
584
643
  // reader-loop microtask churn entirely for buffer-backed responses.
@@ -598,7 +657,8 @@ function sendWebResponse(res, out) {
598
657
  // which we must strip so Node falls back to chunked transfer-encoding.
599
658
  const rawStream = res[DALOY_RAW_STREAM];
600
659
  if (rawStream !== undefined) {
601
- if (typeof rawStream.pipe === "function" && !(rawStream instanceof ReadableStream)) {
660
+ if (typeof rawStream.pipe === "function" &&
661
+ !(rawStream instanceof ReadableStream)) {
602
662
  // Node `Readable` from the handler: skip the Web-stream bridge entirely
603
663
  // and `.pipe(out)` like Fastify/Koa/Express do.
604
664
  out.removeHeader("content-length");
@@ -652,16 +712,22 @@ function pumpBody(body, out) {
652
712
  }
653
713
  // ---------- WebSocket upgrade ----------
654
714
  async function handleUpgrade(app, req, socket, head, trustProxy) {
655
- const forwardedHost = trustProxy
656
- ? firstHeader(req.headers["x-forwarded-host"])
657
- : undefined;
715
+ const forwardedHost = trustProxy ? firstHeader(req.headers["x-forwarded-host"]) : undefined;
658
716
  const host = forwardedHost ?? req.headers.host ?? "localhost";
659
- const forwardedProto = trustProxy
660
- ? firstHeader(req.headers["x-forwarded-proto"])
661
- : undefined;
662
- const proto = forwardedProto ??
663
- (req.socket.encrypted ? "https" : "http");
664
- const url = new URL(`${proto}://${host}${req.url ?? "/"}`);
717
+ const forwardedProto = trustProxy ? firstHeader(req.headers["x-forwarded-proto"]) : undefined;
718
+ const proto = forwardedProto ?? (req.socket.encrypted ? "https" : "http");
719
+ // A malformed `Host` header (e.g. containing a space) reaches this point:
720
+ // Node's HTTP parser accepts it and fires `upgrade`, but WHATWG URL
721
+ // parsing throws. Reject it as the client error it is instead of letting
722
+ // the throw escape the adapter.
723
+ let url;
724
+ try {
725
+ url = new URL(`${proto}://${host}${req.url ?? "/"}`);
726
+ }
727
+ catch {
728
+ writeUpgradeError(socket, 400, "Bad Request");
729
+ return;
730
+ }
665
731
  const match = app.webSocketRoutes.find(url.pathname);
666
732
  if (!match) {
667
733
  writeUpgradeError(socket, 404, "Not Found");
@@ -682,6 +748,11 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
682
748
  method: "GET",
683
749
  headers,
684
750
  });
751
+ setConnInfo(request, {
752
+ remoteAddress: req.socket.remoteAddress,
753
+ remotePort: req.socket.remotePort,
754
+ tls: req.socket.encrypted === true,
755
+ });
685
756
  const ctx = {
686
757
  request,
687
758
  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
@@ -1154,7 +1176,11 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1154
1176
  * auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
1155
1177
  * MCP tools are model-controlled and side-effecting, so a public one is a
1156
1178
  * high-impact default.
1157
- * 3. **Missing CSRF** — when `session()` is installed and any route accepts a
1179
+ * 3. **Cache ahead of tenancy** — a `responseCache()` that runs before
1180
+ * `tenancy()` builds its key before the tenant exists in `ctx.state`, so
1181
+ * every tenant collides on one entry and one tenant's response is served to
1182
+ * the next caller (CWE-524).
1183
+ * 4. **Missing CSRF** — when `session()` is installed and any route accepts a
1158
1184
  * state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
1159
1185
  * (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
1160
1186
  * also be present. Skipped when `app({ csrf: "off" })`.
@@ -1687,8 +1713,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1687
1713
  * "draining" signal); then the app waits up to `timeoutMs` for in-flight
1688
1714
  * requests to settle; finally, {@link App.onClose} cleanups run.
1689
1715
  *
1690
- * Both Node and Bun adapters call this automatically on `SIGINT` / `SIGTERM`.
1691
- * Call it manually from custom runtimes or integration tests.
1716
+ * The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
1717
+ * `SIGTERM`. Call it manually from custom runtimes or integration tests.
1692
1718
  *
1693
1719
  * @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
1694
1720
  * @param reason - Optional human-readable reason forwarded to listeners.