@daloyjs/core 1.0.0-rc.3 → 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.
Files changed (61) hide show
  1. package/README.md +103 -41
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +41 -5
  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 +104 -19
  9. package/dist/app.d.ts +131 -11
  10. package/dist/app.js +305 -217
  11. package/dist/bot-guard.js +30 -3
  12. package/dist/cli.js +41 -1
  13. package/dist/client.d.ts +64 -18
  14. package/dist/client.js +36 -6
  15. package/dist/combine.d.ts +11 -11
  16. package/dist/combine.js +90 -47
  17. package/dist/compression.d.ts +9 -0
  18. package/dist/compression.js +72 -1
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/docs.d.ts +5 -9
  22. package/dist/docs.js +36 -14
  23. package/dist/errors.d.ts +12 -3
  24. package/dist/errors.js +12 -3
  25. package/dist/fetch-guard.d.ts +27 -19
  26. package/dist/fetch-guard.js +50 -8
  27. package/dist/http-signatures.d.ts +4 -1
  28. package/dist/http-signatures.js +13 -1
  29. package/dist/idempotency.js +2 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/internal-response.d.ts +15 -0
  33. package/dist/internal-response.js +27 -0
  34. package/dist/jwk.d.ts +11 -7
  35. package/dist/jwk.js +11 -7
  36. package/dist/logger.d.ts +45 -0
  37. package/dist/logger.js +137 -0
  38. package/dist/mcp.js +21 -15
  39. package/dist/middleware.d.ts +48 -7
  40. package/dist/middleware.js +129 -43
  41. package/dist/mtls.d.ts +6 -5
  42. package/dist/mtls.js +8 -9
  43. package/dist/openapi.js +1 -1
  44. package/dist/pagination.js +4 -1
  45. package/dist/response-cache.js +2 -1
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +9 -2
  49. package/dist/safe-redirect.js +29 -4
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security.d.ts +62 -0
  53. package/dist/security.js +220 -15
  54. package/dist/session.d.ts +13 -2
  55. package/dist/session.js +111 -17
  56. package/dist/tenancy.d.ts +2 -2
  57. package/dist/time-claims.js +3 -1
  58. package/dist/types.d.ts +85 -20
  59. package/dist/types.js +16 -1
  60. package/dist/waf.js +86 -26
  61. package/package.json +11 -4
@@ -1,17 +1,56 @@
1
+ import { setConnInfo } from "../conn-info.js";
1
2
  const TEXT_TYPE_RE = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|.*\+json|.*\+xml))/i;
2
3
  /**
3
4
  * Wrap an {@link App} as a Lambda/Netlify handler accepting either v1.0 or v2.0 event payloads.
4
5
  *
6
+ * A malformed event (e.g. a `Host`/path combination that cannot form a valid
7
+ * URL) is answered with a clean `400` problem+json instead of throwing out of
8
+ * the handler, which API Gateway would otherwise surface as an opaque `502`.
9
+ *
5
10
  * @param app - The DaloyJS {@link App} that serves each translated request.
6
11
  * @returns A {@link LambdaHandler} that converts the event to a `Request`, calls {@link App.fetch}, and emits the matching v1.0/v2.0 response shape.
7
12
  */
8
13
  export function toLambdaHandler(app) {
9
14
  return async (event) => {
10
- const request = eventToRequest(event);
15
+ let request;
16
+ try {
17
+ request = eventToRequest(event);
18
+ }
19
+ catch {
20
+ return responseToLambda(badRequestResponse(), isV2Event(event));
21
+ }
11
22
  const response = await app.fetch(request);
12
23
  return responseToLambda(response, isV2Event(event));
13
24
  };
14
25
  }
26
+ /**
27
+ * Wrap an {@link App} as an AWS Lambda response-streaming handler.
28
+ *
29
+ * The returned handler is decorated with the managed Node.js runtime's
30
+ * `awslambda.streamifyResponse()` helper, attaches status/headers with
31
+ * `HttpResponseStream.from()`, and pumps the web-standard response body while
32
+ * honoring writable-stream backpressure. The function throws during startup
33
+ * outside an AWS Lambda Node.js runtime so an accidentally buffered or broken
34
+ * deployment cannot start silently.
35
+ *
36
+ * @param app - The DaloyJS {@link App} that serves each translated request.
37
+ * @returns A response-streaming Lambda handler for Function URLs, API Gateway streaming proxy integrations, or `InvokeWithResponseStream`.
38
+ * @throws {Error} If the AWS Lambda response-streaming globals are unavailable.
39
+ */
40
+ export function toLambdaStreamHandler(app) {
41
+ const runtime = lambdaStreamingRuntime();
42
+ return runtime.streamifyResponse(async (event, rawStream) => {
43
+ let request;
44
+ try {
45
+ request = eventToRequest(event);
46
+ }
47
+ catch {
48
+ await streamLambdaResponse(badRequestResponse(), rawStream, runtime);
49
+ return;
50
+ }
51
+ await streamLambdaResponse(await app.fetch(request), rawStream, runtime);
52
+ });
53
+ }
15
54
  function eventToRequest(event) {
16
55
  const headers = new Headers();
17
56
  for (const [k, v] of Object.entries(event.headers ?? {})) {
@@ -28,35 +67,37 @@ function eventToRequest(event) {
28
67
  }
29
68
  if ("cookies" in event && event.cookies?.length)
30
69
  headers.set("cookie", event.cookies.join("; "));
31
- const method = isV2Event(event) ? event.requestContext?.http?.method ?? "GET" : event.httpMethod ?? "GET";
70
+ const method = isV2Event(event)
71
+ ? (event.requestContext?.http?.method ?? "GET")
72
+ : (event.httpMethod ?? "GET");
32
73
  const rawPath = isV2Event(event)
33
- ? event.rawPath ?? event.requestContext?.http?.path ?? "/"
34
- : event.path ?? event.requestContext?.path ?? "/";
74
+ ? (event.rawPath ?? event.requestContext?.http?.path ?? "/")
75
+ : (event.path ?? event.requestContext?.path ?? "/");
35
76
  const host = headers.get("host") ?? event.requestContext?.domainName ?? "localhost";
36
77
  const proto = headers.get("x-forwarded-proto") ?? "https";
37
- const rawQueryString = isV2Event(event) ? event.rawQueryString ?? "" : queryStringForV1(event);
78
+ const rawQueryString = isV2Event(event) ? (event.rawQueryString ?? "") : queryStringForV1(event);
38
79
  const qs = rawQueryString ? `?${rawQueryString}` : "";
39
80
  const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
40
81
  const url = `${proto}://${host}${path}${qs}`;
41
82
  const init = { method, headers };
42
83
  if (method !== "GET" && method !== "HEAD" && event.body != null) {
43
- init.body = event.isBase64Encoded
44
- ? base64ToBytes(event.body)
45
- : event.body;
84
+ init.body = event.isBase64Encoded ? base64ToBytes(event.body) : event.body;
85
+ }
86
+ const request = new Request(url, init);
87
+ // Fulfil the conn-info contract with the caller address API Gateway saw
88
+ // (v2: `requestContext.http.sourceIp`, v1: `requestContext.identity.sourceIp`),
89
+ // so `getConnInfo` / `resolveClientIp` work on Lambda. API Gateway and
90
+ // Function URLs only serve TLS.
91
+ const sourceIp = isV2Event(event)
92
+ ? event.requestContext?.http?.sourceIp
93
+ : event.requestContext?.identity?.sourceIp;
94
+ if (sourceIp) {
95
+ setConnInfo(request, { remoteAddress: sourceIp, tls: true });
46
96
  }
47
- return new Request(url, init);
97
+ return request;
48
98
  }
49
99
  async function responseToLambda(res, useV2Response) {
50
- const headers = {};
51
- const getSetCookie = res.headers.getSetCookie;
52
- const cookies = typeof getSetCookie === "function"
53
- ? getSetCookie.call(res.headers)
54
- : cookieFallback(res.headers);
55
- res.headers.forEach((value, key) => {
56
- if (key.toLowerCase() === "set-cookie")
57
- return;
58
- headers[key] = value;
59
- });
100
+ const { headers, cookies } = responseHeaders(res);
60
101
  const contentType = res.headers.get("content-type") ?? "";
61
102
  const isText = TEXT_TYPE_RE.test(contentType);
62
103
  let body = "";
@@ -87,7 +128,10 @@ async function responseToLambda(res, useV2Response) {
87
128
  }
88
129
  function isV2Event(event) {
89
130
  const requestContext = event.requestContext;
90
- return event.version === "2.0" || "rawPath" in event || "rawQueryString" in event || !!requestContext?.http;
131
+ return (event.version === "2.0" ||
132
+ "rawPath" in event ||
133
+ "rawQueryString" in event ||
134
+ !!requestContext?.http);
91
135
  }
92
136
  function queryStringForV1(event) {
93
137
  const values = new URLSearchParams();
@@ -107,6 +151,78 @@ function cookieFallback(headers) {
107
151
  const cookie = headers.get("set-cookie");
108
152
  return cookie ? [cookie] : [];
109
153
  }
154
+ function responseHeaders(res) {
155
+ const headers = {};
156
+ const getSetCookie = res.headers.getSetCookie;
157
+ const cookies = typeof getSetCookie === "function"
158
+ ? getSetCookie.call(res.headers)
159
+ : cookieFallback(res.headers);
160
+ res.headers.forEach((value, key) => {
161
+ if (key.toLowerCase() !== "set-cookie")
162
+ headers[key] = value;
163
+ });
164
+ return { headers, cookies };
165
+ }
166
+ function badRequestResponse() {
167
+ return Response.json({
168
+ type: "https://daloyjs.dev/errors/bad-request",
169
+ title: "Bad Request",
170
+ status: 400,
171
+ }, { status: 400, headers: { "content-type": "application/problem+json" } });
172
+ }
173
+ function lambdaStreamingRuntime() {
174
+ const runtime = globalThis
175
+ .awslambda;
176
+ if (!runtime ||
177
+ typeof runtime.streamifyResponse !== "function" ||
178
+ typeof runtime.HttpResponseStream?.from !== "function") {
179
+ throw new Error("AWS Lambda response streaming runtime not detected; toLambdaStreamHandler requires the managed Node.js awslambda globals");
180
+ }
181
+ return runtime;
182
+ }
183
+ async function streamLambdaResponse(response, rawStream, runtime) {
184
+ const { headers, cookies } = responseHeaders(response);
185
+ const metadata = { statusCode: response.status, headers };
186
+ if (cookies.length)
187
+ metadata.multiValueHeaders = { "set-cookie": cookies };
188
+ const responseStream = runtime.HttpResponseStream.from(rawStream, metadata);
189
+ if (response.body) {
190
+ const reader = response.body.getReader();
191
+ try {
192
+ for (;;) {
193
+ const chunk = await reader.read();
194
+ if (chunk.done)
195
+ break;
196
+ if (!responseStream.write(chunk.value))
197
+ await waitForDrain(responseStream);
198
+ }
199
+ }
200
+ catch (error) {
201
+ await reader.cancel(error).catch(() => undefined);
202
+ throw error;
203
+ }
204
+ finally {
205
+ reader.releaseLock();
206
+ }
207
+ }
208
+ responseStream.end();
209
+ if (responseStream.finished)
210
+ await responseStream.finished();
211
+ }
212
+ function waitForDrain(stream) {
213
+ return new Promise((resolve, reject) => {
214
+ const onDrain = () => {
215
+ stream.off?.("error", onError);
216
+ resolve();
217
+ };
218
+ const onError = (error) => {
219
+ stream.off?.("drain", onDrain);
220
+ reject(error);
221
+ };
222
+ stream.once("drain", onDrain);
223
+ stream.once("error", onError);
224
+ });
225
+ }
110
226
  function base64ToBytes(b64) {
111
227
  const binary = atob(b64);
112
228
  const bytes = new Uint8Array(binary.length);
@@ -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>;
@@ -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
@@ -93,6 +98,29 @@ export interface AppOptions {
93
98
  * @since 0.38.0
94
99
  */
95
100
  maxHeaderCount?: number;
101
+ /**
102
+ * Maximum number of keys (summed across every object in the tree) permitted
103
+ * when parsing a JSON request body. This bounds "hash-flood" / wide-object
104
+ * attacks that easily fit inside `bodyLimitBytes` (e.g. 40 000 tiny keys).
105
+ * Applies to top-level objects and all nested objects. Set to 0 to disable.
106
+ * Default: 10 000.
107
+ *
108
+ * Exposed in `getSecurityPosture()` and audited by `daloy doctor`.
109
+ *
110
+ * @since 1.0.0
111
+ */
112
+ jsonMaxKeys?: number;
113
+ /**
114
+ * Maximum nesting depth permitted for JSON request bodies (objects and arrays).
115
+ * Prevents deeply-nested structures that can consume excessive CPU/memory
116
+ * during schema validation or handler processing. Set to 0 to disable.
117
+ * Default: 50.
118
+ *
119
+ * Exposed in `getSecurityPosture()` and audited by `daloy doctor`.
120
+ *
121
+ * @since 1.0.0
122
+ */
123
+ jsonMaxDepth?: number;
96
124
  /**
97
125
  * Per-request limits applied when parsing `multipart/form-data` bodies.
98
126
  * These run in addition to `bodyLimitBytes`. Use them to cap the size of
@@ -516,7 +544,7 @@ export interface PluginExtension {
516
544
  /** Unique extension name. Referenced by `before` / `after` on siblings. */
517
545
  name: string;
518
546
  /** Lifecycle event the handler attaches to. */
519
- event: "onRequest" | "beforeHandle" | "afterHandle" | "onSend" | "onError";
547
+ event: "onRequest" | "preBody" | "beforeHandle" | "afterHandle" | "onSend" | "onError";
520
548
  /** Hook handler. The shape mirrors the matching {@link Hooks} entry. */
521
549
  handler: (...args: any[]) => any;
522
550
  /** Extension names this one must run before. */
@@ -748,6 +776,23 @@ export declare const DALOY_RAW_BODY: unique symbol;
748
776
  * so first-party adapters can opt in; not part of the userland API surface.
749
777
  */
750
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;
751
796
  /**
752
797
  * Internal Symbol set by handlers/serializers to attach a raw stream
753
798
  * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
@@ -828,6 +873,18 @@ export declare const DALOY_LIGHT_RESPONSE_OK: unique symbol;
828
873
  * @typeParam R - The route definition being registered.
829
874
  */
830
875
  type AppendRoute<Routes extends readonly RouteDefinition<any, any, any, any>[], R extends RouteDefinition<any, any, any, any>> = readonly RouteDefinition<any, any, any, any>[] extends Routes ? readonly [R] : readonly [...Routes, R];
876
+ /** Append a literal tuple of route contracts to an App's accumulated routes. */
877
+ type AppendRoutes<Routes extends readonly RouteDefinition<any, any, any, any>[], Added extends readonly RouteDefinition<any, any, any, any>[]> = readonly RouteDefinition<any, any, any, any>[] extends Routes ? Added : readonly [...Routes, ...Added];
878
+ type PascalWords<S extends string> = S extends `${infer Head}-${infer Tail}` ? `${PascalWords<Head>}${PascalWords<Tail>}` : S extends `${infer Head}_${infer Tail}` ? `${Capitalize<Head>}${PascalWords<Tail>}` : Capitalize<S>;
879
+ type OperationSegment<S extends string> = S extends `:${infer Param}` ? `By${PascalWords<Param>}` : PascalWords<S>;
880
+ type OperationPathTail<P extends string> = P extends `${infer Segment}/${infer Rest}` ? `${OperationSegment<Segment>}${OperationPathTail<Rest>}` : OperationSegment<P>;
881
+ type AutoOperationId<M extends HttpMethod, P extends PathString> = `${Lowercase<M>}${P extends "/" ? "Root" : P extends `/${infer Tail}` ? OperationPathTail<Tail> : never}`;
882
+ type ShorthandOptions<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, Op extends string | undefined> = Omit<RouteDefinition<P, M, Req, Res>, "method" | "path" | "operationId" | "handler"> & {
883
+ operationId?: Op;
884
+ };
885
+ type ShorthandRoute<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, Op extends string | undefined> = RouteDefinition<P, M, Req, Res> & {
886
+ operationId: Op extends string ? Op : AutoOperationId<M, P>;
887
+ };
831
888
  /**
832
889
  * The DaloyJS application: a contract-first router plus a web-standard
833
890
  * `fetch(Request): Promise<Response>` handler that runs unchanged on Node,
@@ -1007,6 +1064,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1007
1064
  bodyLimitBytes: number;
1008
1065
  requestTimeoutMs: number;
1009
1066
  maxHeaderCount: number;
1067
+ jsonMaxKeys: number;
1068
+ jsonMaxDepth: number;
1010
1069
  stripServerHeaders: boolean;
1011
1070
  production: boolean;
1012
1071
  };
@@ -1204,6 +1263,74 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1204
1263
  }): App<AppendRoute<Routes, RouteDefinition<P, M, Req, Res> & {
1205
1264
  operationId: Op;
1206
1265
  }>>;
1266
+ /**
1267
+ * Register a literal tuple of independently defined route contracts.
1268
+ *
1269
+ * Unlike repeated statements against an already-declared `App` variable,
1270
+ * this method returns an App whose route tuple includes every supplied
1271
+ * contract. That preserves the exact no-codegen client surface across route
1272
+ * files and feature modules.
1273
+ *
1274
+ * @param definitions - Readonly literal tuple of route definitions.
1275
+ * @returns This App instance widened with every supplied route contract.
1276
+ * @since 1.0.0
1277
+ */
1278
+ registerRoutes<const Added extends readonly RouteDefinition<any, any, any, any>[]>(definitions: Added): App<AppendRoutes<Routes, Added>>;
1279
+ /**
1280
+ * Register a contract-backed `GET` route with validation and typed responses.
1281
+ * @param path - Literal route path.
1282
+ * @param options - Route schemas, responses, metadata, hooks, and security.
1283
+ * @param handler - Handler contextually typed from the contract options.
1284
+ * @returns This App widened with the registered route.
1285
+ * @since 1.0.0
1286
+ */
1287
+ get<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "GET", Req, Res, Op>, handler: RouteDefinition<P, "GET", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "GET", Req, Res, Op>>>;
1288
+ /**
1289
+ * Register a contract-backed `POST` route with validation and typed responses.
1290
+ * @param path - Literal route path.
1291
+ * @param options - Route schemas, responses, metadata, hooks, and security.
1292
+ * @param handler - Handler contextually typed from the contract options.
1293
+ * @returns This App widened with the registered route.
1294
+ * @since 1.0.0
1295
+ */
1296
+ post<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "POST", Req, Res, Op>, handler: RouteDefinition<P, "POST", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "POST", Req, Res, Op>>>;
1297
+ /**
1298
+ * Register a contract-backed `PUT` route with validation and typed responses.
1299
+ * @param path - Literal route path.
1300
+ * @param options - Route schemas, responses, metadata, hooks, and security.
1301
+ * @param handler - Handler contextually typed from the contract options.
1302
+ * @returns This App widened with the registered route.
1303
+ * @since 1.0.0
1304
+ */
1305
+ put<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "PUT", Req, Res, Op>, handler: RouteDefinition<P, "PUT", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "PUT", Req, Res, Op>>>;
1306
+ /**
1307
+ * Register a contract-backed `PATCH` route with validation and typed responses.
1308
+ * @param path - Literal route path.
1309
+ * @param options - Route schemas, responses, metadata, hooks, and security.
1310
+ * @param handler - Handler contextually typed from the contract options.
1311
+ * @returns This App widened with the registered route.
1312
+ * @since 1.0.0
1313
+ */
1314
+ patch<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "PATCH", Req, Res, Op>, handler: RouteDefinition<P, "PATCH", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "PATCH", Req, Res, Op>>>;
1315
+ /**
1316
+ * Register a contract-backed `DELETE` route with typed response statuses.
1317
+ * @param path - Literal route path.
1318
+ * @param options - Route schemas, responses, metadata, hooks, and security.
1319
+ * @param handler - Handler contextually typed from the contract options.
1320
+ * @returns This App widened with the registered route.
1321
+ * @since 1.0.0
1322
+ */
1323
+ delete<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "DELETE", Req, Res, Op>, handler: RouteDefinition<P, "DELETE", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "DELETE", Req, Res, Op>>>;
1324
+ /**
1325
+ * Register an explicit contract-backed `HEAD` route.
1326
+ * @param path - Literal route path.
1327
+ * @param options - Route schemas, responses, metadata, hooks, and security.
1328
+ * @param handler - Handler contextually typed from the contract options.
1329
+ * @returns This App widened with the registered route.
1330
+ * @since 1.0.0
1331
+ */
1332
+ head<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "HEAD", Req, Res, Op>, handler: RouteDefinition<P, "HEAD", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "HEAD", Req, Res, Op>>>;
1333
+ private addHttpShorthand;
1207
1334
  /**
1208
1335
  * Register a WebSocket route. The handler runs when an HTTP client sends an
1209
1336
  * `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
@@ -1582,8 +1709,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1582
1709
  * "draining" signal); then the app waits up to `timeoutMs` for in-flight
1583
1710
  * requests to settle; finally, {@link App.onClose} cleanups run.
1584
1711
  *
1585
- * Both Node and Bun adapters call this automatically on `SIGINT` / `SIGTERM`.
1586
- * 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.
1587
1714
  *
1588
1715
  * @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
1589
1716
  * @param reason - Optional human-readable reason forwarded to listeners.
@@ -1671,11 +1798,4 @@ export declare function findRoutesMissingResponseBodySchema(routes: readonly Pic
1671
1798
  * @since 0.3.0
1672
1799
  */
1673
1800
  export declare function createApp(options?: AppOptions): App;
1674
- /**
1675
- * Test helper: clear the cached package.json read so each test starts
1676
- * from a fresh lookup. Not part of the public API.
1677
- *
1678
- * @internal
1679
- */
1680
- export declare function _resetPackageJsonCacheForTests(): void;
1681
1801
  export {};