@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
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  [![Security Responsible
23
23
  Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/daloyjs/daloy/blob/main/SECURITY.md)
24
24
 
25
- > A **runtime-portable TypeScript web framework** with built-in **contract-first routing**, **validation**, **OpenAPI (Hey API)**, **typed client generation**, **large-scale maintainability**, and **security-focused runtime plus supply-chain posture**.
25
+ > The first TypeScript REST API framework built for secure AI-assisted services. DaloyJS combines runtime portability, contract-first OpenAPI, generated typed clients, zero-dependency runtime guardrails, and supply-chain-aware project defaults in one framework.
26
26
 
27
27
  **One-line API docs.** `new App({ openapi: { info: ... }, docs: true })` auto-mounts `GET /docs` (Scalar), `GET /openapi.json`, and `GET /openapi.yaml` — the same DX as FastAPI, without leaving TypeScript.
28
28
 
@@ -66,7 +66,7 @@ DaloyJS exists to be the framework you'd build if you took the best ideas from e
66
66
  | **Portable supply-chain hardening** for the apps you build | [pnpm](https://pnpm.io/motivation) defaults + a zero-runtime-dep core | Hardened `.npmrc`, source-verified lockfiles, zero runtime deps, CycloneDX + SPDX SBOM, and npm provenance attestations. |
67
67
 
68
68
  ```
69
- framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 6 with `strict: true`
69
+ framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 7 with `strict: true`
70
70
  runs on Node, Bun, Deno, Cloudflare, Vercel
71
71
  ~12.3M static-route ops/sec · ~1.5M dynamic-route ops/sec on M-class CPU
72
72
  ```
@@ -87,7 +87,7 @@ Each existing stack is excellent at one thing and forces tradeoffs everywhere el
87
87
 
88
88
  DaloyJS combines the wins:
89
89
 
90
- 1. **Explicit contracts, minimal ceremony.** One `app.route({...})` is the source of truth for validation, types, OpenAPI, the typed client, and contract tests.
90
+ 1. **Explicit contracts, minimal ceremony.** One `app.get(path, contract, handler)` (or the matching shorthand, or `app.route({...})` for reusable contracts) is the source of truth for validation, types, OpenAPI, the typed client, and contract tests.
91
91
  2. **One source of truth for validation, typing, and docs** via [Standard Schema](https://github.com/standard-schema/standard-schema) — Zod 4 / Valibot / ArkType / TypeBox all work, no lock-in.
92
92
  3. **Portable core, optional runtime optimizations** — the only thing the core knows is `Request → Response`. Adapters live at the edge.
93
93
  4. **Security guardrails by default — bad defaults are bugs.** The core enforces body limits, prototype-pollution-safe JSON, path-traversal rejection, request timeouts, content-type checks, and RFC 9457 problem+json errors with prod-mode redaction. First-party middleware covers Helmet-grade headers, CORS, CSRF, rate limits, request ids, and signed-cookie sessions.
@@ -180,28 +180,64 @@ app.use(requestId());
180
180
  app.use(secureHeaders());
181
181
  app.use(rateLimit({ windowMs: 60_000, max: 120 }));
182
182
 
183
- app.route({
184
- method: "GET",
185
- path: "/books/:id",
186
- operationId: "getBookById",
187
- tags: ["Books"],
188
- request: { params: z.object({ id: z.string() }) },
189
- responses: {
190
- 200: {
191
- description: "Found",
192
- body: z.object({ id: z.string(), title: z.string() }),
183
+ app.get(
184
+ "/books/:id",
185
+ {
186
+ tags: ["Books"],
187
+ request: { params: z.object({ id: z.string() }) },
188
+ responses: {
189
+ 200: {
190
+ description: "Found",
191
+ body: z.object({ id: z.string(), title: z.string() }),
192
+ },
193
+ 404: { description: "Not found" },
193
194
  },
194
- 404: { description: "Not found" },
195
195
  },
196
- handler: async ({ params }) => ({
196
+ async ({ params }) => ({
197
197
  status: 200,
198
198
  body: { id: params.id, title: `Book ${params.id}` },
199
199
  }),
200
- });
200
+ );
201
201
 
202
202
  serve(app, { port: 3000 });
203
203
  ```
204
204
 
205
+ For a concise route that keeps the full contract, use a method shorthand. The
206
+ method and path produce a stable operation id (`getRoot` here), while the
207
+ response schema preserves validation, OpenAPI, and data-exposure protection:
208
+
209
+ ```ts
210
+ const app = new App().get(
211
+ "/",
212
+ { responses: { 200: { body: z.object({ hello: z.string() }) } } },
213
+ () => ({ status: 200, body: { hello: "world" } })
214
+ );
215
+ ```
216
+
217
+ There is intentionally no `app.get(path, handler)` form. If a streaming or
218
+ proxy route genuinely needs to return a raw `Response`, declare a contract and
219
+ set `acknowledgeNoResponseBodySchema: true`; otherwise DaloyJS fails closed
220
+ rather than silently bypassing response-body validation. The same rule applies
221
+ when `preBody` or `beforeHandle` short-circuits with a successful raw response;
222
+ ordinary `4xx`/`5xx` hook denials remain secure by default without an opt-out.
223
+
224
+ Add request schemas and other contract options as the endpoint grows:
225
+
226
+ ```ts
227
+ app.post(
228
+ "/books",
229
+ {
230
+ request: { body: z.object({ title: z.string().min(1) }) },
231
+ responses: {
232
+ 201: {
233
+ body: z.object({ id: z.string(), title: z.string() }),
234
+ },
235
+ },
236
+ },
237
+ ({ body }) => ({ status: 201, body: { id: "1", title: body.title } })
238
+ );
239
+ ```
240
+
205
241
  ---
206
242
 
207
243
  ## OpenAPI + Hey API typed client
@@ -237,15 +273,32 @@ export default defineConfig({
237
273
  For TypeScript consumers in the same monorepo you can skip codegen entirely and use the **in-process typed client**:
238
274
 
239
275
  ```ts
240
- import { createClient } from "@daloyjs/core/client";
241
- const client = createClient(app, { baseUrl: "http://localhost:3000" });
276
+ import { createInProcessClient } from "@daloyjs/core/client";
277
+ const client = createInProcessClient(app);
242
278
  const r = await client.getBookById({ params: { id: "1" } });
243
279
  // ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }
244
280
  ```
245
281
 
246
- > Method inference relies on **chaining** your `app.route(...)` calls (`new App().route(a).route(b)`) and letting
247
- > TypeScript infer the variable's type. A widening `const app: App` annotation, a `: App` factory return type, or
248
- > registering routes as separate statements erases the per-route types and collapses the client to an untyped surface.
282
+ For multi-file applications, export each contract with `defineRoute()` and
283
+ compose the literal tuple with `app.registerRoutes([...])`. This retains every
284
+ operation across module boundaries:
285
+
286
+ ```ts
287
+ import { App, defineRoute } from "@daloyjs/core";
288
+ import { createInProcessClient } from "@daloyjs/core/client";
289
+ import { listBooksRoute } from "./routes/list-books.js";
290
+ import { getBookRoute } from "./routes/get-book.js";
291
+
292
+ const app = new App().registerRoutes([listBooksRoute, getBookRoute] as const);
293
+ const client = createInProcessClient(app); // typed, no socket or port
294
+ ```
295
+
296
+ Chained `route()` calls remain supported. Avoid widening a composed app back to
297
+ a bare `App` annotation, because that deliberately discards its route tuple.
298
+ Callback-style `group()` and plugin `register()` provide runtime encapsulation,
299
+ but TypeScript cannot widen the already-created parent variable from inside a
300
+ callback. Export route tuples and compose them with `registerRoutes()` whenever
301
+ the no-codegen client must include those module routes.
249
302
 
250
303
  ---
251
304
 
@@ -329,7 +382,10 @@ import { generateOpenAPI } from "@daloyjs/core/openapi";
329
382
 
330
383
  The UI is always contract-accurate — never stale. `create-daloy` templates opt in with `docs: true`.
331
384
 
332
- If you omit `openapi.info.title` / `info.version`, Daloy reads your project's `package.json` (`name`, `version`, `description`) automatically — no boilerplate. Deno projects without a `package.json` fall back to `deno.json` / `deno.jsonc`. Explicit values always win.
385
+ If you omit `openapi.info`, the portable defaults are `DaloyJS API` / `0.0.0`.
386
+ Set `openapi.info` (or the top-level `title`, `version`, and `description`) for
387
+ real services. The core never reads the host filesystem, so the same docs
388
+ bundle runs unchanged on Workers, Vercel, Bun, Deno, and Node.
333
389
 
334
390
  Prefer a factory? `createApp(options)` is exported as an alias of `new App(options)`.
335
391
 
@@ -418,15 +474,17 @@ for the full picture, plus the per-provider guides under [`/docs/auth`](https://
418
474
 
419
475
  ```text
420
476
  $ pnpm bench
421
- static route lookup 12,363,799 ops/sec
422
- dynamic 4-segment lookup 1,513,983 ops/sec
423
- miss 4,763,878 ops/sec
477
+ static route lookup 25,810,420 ops/sec
478
+ dynamic 4-segment lookup 2,105,121 ops/sec
479
+ miss 7,742,635 ops/sec
424
480
  ```
425
481
 
426
- - Static (no-param) routes resolve via a single `Map.get` **~12M ops/sec**.
482
+ - After traversal checks, exact static routes resolve with an allocation-free
483
+ `Map.get` fast path — **~26M ops/sec**.
427
484
  - Dynamic routes walk a trie, **O(path-segments)** regardless of route count.
428
485
  - Body parsing is lazy and only runs when a route declares a body schema.
429
- - No regex on the hot path.
486
+ - Path normalization and splitting use index/character scans rather than
487
+ regular expressions.
430
488
 
431
489
  ### Cold-start tip (serverless / edge)
432
490
 
@@ -465,13 +523,14 @@ Gate it in CI two ways: `daloy inspect --check <entry>` exits non-zero on any er
465
523
  const usersPlugin = {
466
524
  name: "users",
467
525
  register(app) {
468
- app.route({
469
- method: "GET",
470
- path: "/me",
471
- operationId: "me",
472
- responses: { 200: { description: "ok" } },
473
- handler: async () => ({ status: 200, body: { user: "alice" } }),
474
- });
526
+ app.get(
527
+ "/me",
528
+ {
529
+ operationId: "me",
530
+ responses: { 200: { description: "ok" } },
531
+ },
532
+ async () => ({ status: 200, body: { user: "alice" } }),
533
+ );
475
534
  },
476
535
  };
477
536
  app.register(usersPlugin, { prefix: "/users", tags: ["Users"] });
@@ -515,7 +574,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
515
574
 
516
575
  ## Status
517
576
 
518
- DaloyJS is at **`1.0.0-rc.3`**, a security-hardening release candidate. Because the framework has no external users yet, this RC makes a few intentional changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before GA rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` GA onward, the API follows SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
577
+ DaloyJS is at **`1.0.0-rc.5`**, a security-hardening release candidate. Because the framework has no external users yet, this RC makes a few intentional changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before the stable release rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` stable onward, the API follows SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
519
578
 
520
579
  **Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
521
580
 
@@ -523,7 +582,10 @@ DaloyJS is at **`1.0.0-rc.3`**, a security-hardening release candidate. Because
523
582
 
524
583
  - Contract-first routing with Standard Schema validation (Zod 4, Valibot, ArkType, TypeBox) and OpenAPI 3.1 generated from a single source of truth.
525
584
  - Live OpenAPI 3.1 spec served as both JSON (`GET /openapi.json`) and YAML (`GET /openapi.yaml`) when `docs: true`, with a choice of Scalar (default), Swagger UI, or Redoc via `docs.ui`, plus Scalar theming/custom CSS/auth defaults via `docs.scalar`, Swagger UI options via `docs.swagger` (including persisted Authorize credentials), Redoc options via `docs.redoc`, and a provider-neutral `docs.auth` launcher for local login routes or third-party OAuth2/OIDC providers.
526
- - Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
585
+ - Filesystem-free OpenAPI metadata: explicit `openapi.info` / top-level metadata wins, with portable `DaloyJS API` / `0.0.0` fallbacks on every runtime.
586
+ - Contract-required `get()` / `post()` / `put()` / `patch()` / `delete()` / `head()` shorthands with deterministic method+path operation IDs and the same validation, OpenAPI, response-exposure protection, and client typing as `route()`; there is no silent two-argument schema bypass.
587
+ - Multi-file contract composition through `defineRoute()` + `app.registerRoutes([...])`, plus a typed `createInProcessClient(app)` that traverses the real pipeline without a socket.
588
+ - Header/JWT/basic/mTLS authentication runs in the `preBody` phase before request-body I/O; body-aware WAF, idempotency, signature, and application middleware keep the validated `beforeHandle` phase.
527
589
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
528
590
  - AI-friendly route metadata via optional `meta: { examples, extensions, summary, description, tags }`; examples are validated against your schemas at build time, surfaced as OpenAPI `examples` + `x-daloy-*` extensions, and dumped as `routes.json` / `routes.yaml` via `daloy inspect --ai`.
529
591
  - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
@@ -532,7 +594,7 @@ DaloyJS is at **`1.0.0-rc.3`**, a security-hardening release candidate. Because
532
594
 
533
595
  ### Runtimes and deployment
534
596
 
535
- - Adapters for Node (Heroku, Railway, Render, Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs.
597
+ - Adapters for Node (Heroku, Railway, Render, Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs, including backpressure-safe Lambda response streaming via `toLambdaStreamHandler()`.
536
598
  - `daloy dev` watch loop delegates to the host runtime's native watcher (`node --watch`, `bun --hot`, or `deno run --watch`) with a `--runtime` override for cross-runtime `package.json` scripts.
537
599
  - `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening. The completion summary surfaces official install links (nodejs.org, pnpm.io, bun.sh) for any runtime or package manager your selections need but that is missing from `PATH`, and skips a doomed dependency install when the chosen package manager is absent.
538
600
  - Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
@@ -600,9 +662,9 @@ The framework refuses to start (or to construct) when configuration is unsafe:
600
662
  - `concurrencyLimit()` per-route / per-client concurrency limits + queueing at `@daloyjs/core/concurrency-limit`: HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies. HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies.
601
663
  - `requestDecompression()` inbound decompression-bomb guard at `@daloyjs/core/request-decompression`: core is safe by omission (it never decompresses request bodies), so this is the opt-in middleware for services that must accept compressed uploads. Inflates `gzip` / `deflate` bodies behind two caps enforced **during** inflation so a zip bomb is aborted before it is fully materialised: an absolute `maxDecompressedBytes` (required) and an expansion-ratio `maxRatio` (default `100`), both rejecting with `413`. The compressed upload itself is bounded by `maxCompressedBytes` (default 1 MiB) before a byte is inflated. Unknown, non-allowlisted, runtime-unsupported, or **layered** (`gzip, gzip`) encodings are refused `415`; malformed streams `400`; bodyless / uncompressed / `identity` / `GET` / `HEAD` traffic passes through untouched. Runs in `onRequest` and stashes the inflated bytes so schema-validated bodies and raw-body handlers both see the decompressed payload. `onBomb` observability hook, exported `decompressRequestBody()` for custom flows. Built on web-standard `DecompressionStream` (brotli excluded — not in the spec). Zero runtime dependencies.
602
664
  - `waf()` opt-in WAF-lite signature/anomaly inbound-inspection middleware at `@daloyjs/core/waf`: a first-party defense-in-depth layer for teams without an edge WAF (it does **not** replace ModSecurity / a CDN WAF). Wires DaloyJS' high-confidence injection signatures — SQLi, XSS, NoSQL-operator injection (reusing `hasMongoOperatorKeys` for a structural body check), and command injection — into a single scored inbound-inspection pass over the decoded path, the raw + decoded query string, an opt-in header allowlist, and the validated body. Each rule that fires adds an anomaly `score`; reaching `blockThreshold` (default `5`) rejects with a generic `403` (block mode) or merely reports via `onMatch` (log mode) so operators can tune against real traffic first. Per-rule enable/disable + score overrides, inspection-surface toggles, control-character-stripped log samples, and bounded scanning (`maxValueLength` / `maxBodyNodes`) keep a hostile payload from becoming CPU-DoS. The `403` body never names the rule that fired. Zero runtime dependencies.
603
- - Built-in docs UI Subresource Integrity (SRI): `DocsAssetOptions` lets `scalarHtml()` / `swaggerUiHtml()` / `redocHtml()` and the `docs: { assets }` auto-mount pin version-exact `*Integrity` hashes (`sha256`/`sha384`/`sha512`) plus a `crossOrigin` value (default `"anonymous"`) on the CDN-loaded Scalar / Swagger UI / Redoc `<script>` / `<link>` tags, so a poisoned jsDelivr asset can't execute. Malformed SRI values throw a `TypeError` at startup (browsers ignore unparseable `integrity`, so failing loud avoids a false sense of protection); self-hosting the assets via the same `assets` URLs stays supported. Zero runtime dependencies.
665
+ - Built-in docs UI Subresource Integrity (SRI): the default Scalar / Swagger UI / Redoc / AsyncAPI assets use version-exact URLs with matching SHA-384 hashes and `crossorigin="anonymous"`, so a poisoned CDN asset cannot execute. `DocsAssetOptions` supports validated URL/hash overrides or self-hosting; malformed SRI values throw a `TypeError` instead of silently weakening the page. Zero runtime dependencies.
604
666
  - HTTP Message Signatures (RFC 9421) at `@daloyjs/core/http-signatures`: first-party sign/verify for server-to-server request authentication via the standard `Signature` / `Signature-Input` headers — complements the inbound-only webhook HMAC and `clientCertAuth()` mTLS. `signMessage()` / `signRequest()` build an RFC 9421 signature base over derived components (`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@query-param`, `@status`) and HTTP fields with Structured-Fields header serialization; `verifyMessage()` / `verifyRequest()` and the `httpSignatureAuth()` middleware check them. Algorithms `hmac-sha256` / `ed25519` / `ecdsa-p256-sha256` / `ecdsa-p384-sha384` / `rsa-pss-sha512` / `rsa-v1_5-sha256` via WebCrypto (no `node:` imports). Secure-by-default verify: a **mandatory `algorithms` allowlist** (no implicit "any alg"), optional per-key alg pinning to defeat algorithm-confusion, a required `created` timestamp with a 300s freshness window, `created`-in-future / `expires` skew rejection, configurable `requiredComponents`, a 32-byte raw-HMAC floor, a 2048-bit RSA modulus floor (NIST SP 800-131A, parity with the JWT verifier), and `nonce` replay defense; the middleware answers a missing/invalid signature with `401` + `Cache-Control: no-store` and stamps the verified result on `ctx.state.httpSignature`. Ships RFC 9530 `contentDigest()` / `verifyContentDigest()` to bind the request body. Zero runtime dependencies.
605
- - `compression()` built on web-standard `CompressionStream` (prefers `br` > `gzip` > `deflate`), with BREACH-aware always-on guards (skips `Set-Cookie`, `Authorization`, session / CSRF cookies, already-compressed content types), `minimumSize: 1024`, negative-compression-ratio post-check, no configurable `compressLevel` knob (CPU-DoS defense — `level: 9` is refused at construction), always-on `Vary: Accept-Encoding`, and strong → weak ETag downgrade per RFC 9110 §8.8.3.
667
+ - `compression()` built on web-standard `CompressionStream` (prefers `br` > `gzip` > `deflate`), with BREACH-aware always-on guards (skips `Set-Cookie`, `Authorization`, session / CSRF cookies, already-compressed content types), `minimumSize: 1024`, a `maxCompressibleBytes` memory bound (default 1 MiB — larger responses stream uncompressed instead of buffering), negative-compression-ratio post-check, no configurable `compressLevel` knob (CPU-DoS defense — `level: 9` is refused at construction), always-on `Vary: Accept-Encoding`, and strong → weak ETag downgrade per RFC 9110 §8.8.3.
606
668
  - `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
607
669
  - `timing` / `timingSafeEqual` helpers.
608
670
  - `fileField({ magicBytes })` upload signature checks.
@@ -626,13 +688,13 @@ The framework refuses to start (or to construct) when configuration is unsafe:
626
688
 
627
689
  - Plugin encapsulation (Fastify-style), decorators, structured logging, request-id propagation.
628
690
  - Lifecycle events: `onPluginInstalled`, `onShutdown`, `onClose`.
629
- - Connection-draining graceful shutdown with `Connection: close` on `503` and in-flight responses.
691
+ - Connection-draining graceful shutdown with `Connection: close` on `503` and in-flight responses; the Node, Bun, and Deno adapters all wire it to `SIGTERM`/`SIGINT` by default.
630
692
  - `crashOnUnhandledRejection` default-on in production.
631
693
  - `app.healthcheck()` / `app.readinesscheck()` primitives with bearer-token auth and per-IP rate limit.
632
694
  - `disconnectStatusCode: 499` default for client-aborted requests.
633
695
  - `defineConfig({ schema, source })` boot-time typed configuration validation.
634
696
  - `app({ behindProxy })` declarative model (replaces `trustProxy`); `behindProxy.hops` collapses to the `(N+1)`-from-rightmost slot.
635
- - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort`.
697
+ - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort` — populated by the Node, Bun, Deno, and Lambda adapters from the real peer socket / event source, never from spoofable headers.
636
698
  - `daloy doctor` production-posture validator with `--audit-secrets` and `--audit-defaults` (flags wildcard-credentials CORS, > 24h CORS `maxAge`, > 25 MiB blanket body limits, zero `idleTimeoutMs` in production, and unsafe opt-ins).
637
699
  - PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
638
700
  - Secure-by-default multitenancy via `tenancy()` + `tenantScope()`: pluggable tenant resolution (subdomain / header / path / JWT claim / custom), refuse-unresolved + format-validated ids + no-enumeration `404` by default, and a key helper that partitions `rateLimit` / `concurrencyLimit` / `idempotency` / `responseCache` per tenant.
@@ -2,7 +2,8 @@
2
2
  * Bun adapter — `Bun.serve` already speaks web-standard fetch,
3
3
  * so this is the smallest possible wrapper. The adapter passes through the
4
4
  * commonly-needed modern `Bun.serve` options (`idleTimeout`, `tls`,
5
- * `development`, `unix`) and exposes the server's `url` for ergonomic logging.
5
+ * `development`, `unix`), exposes the server's `url` for ergonomic logging,
6
+ * and wires graceful shutdown to SIGTERM/SIGINT like the Node adapter.
6
7
  */
7
8
  import type { App } from "../app.js";
8
9
  /** TLS material accepted by Bun's `serve({ tls })` option. */
@@ -32,6 +33,23 @@ export interface BunServeOptions {
32
33
  unix?: string;
33
34
  /** When supplied, Bun.serve listens on HTTPS. */
34
35
  tls?: BunTLSOptions;
36
+ /**
37
+ * Drain timeout in ms passed to {@link App.shutdown} during graceful
38
+ * shutdown (signal-triggered or via `stop()`). Default: 10000.
39
+ *
40
+ * @since 1.0.0
41
+ */
42
+ shutdownTimeoutMs?: number;
43
+ /**
44
+ * Listen for SIGTERM/SIGINT and shut down gracefully (drain
45
+ * {@link App.shutdown} hooks, then stop the Bun server and exit). Matches
46
+ * the Node and Deno adapters so rolling deploys under Kubernetes/systemd
47
+ * do not hard-kill in-flight requests. Set `false` to manage signals
48
+ * yourself. Default: true.
49
+ *
50
+ * @since 1.0.0
51
+ */
52
+ handleSignals?: boolean;
35
53
  }
36
54
  /** Handle returned by {@link serve} for shutdown and listener introspection. */
37
55
  export interface BunServerHandle {
@@ -39,7 +57,7 @@ export interface BunServerHandle {
39
57
  port: number;
40
58
  /** Server URL as reported by `Bun.serve` (e.g. for startup logging), if available. */
41
59
  url: URL | undefined;
42
- /** Graceful stop: drains {@link App.shutdown} hooks first, then force-stops the Bun server. */
60
+ /** Graceful stop: drains {@link App.shutdown} hooks first, then force-stops the Bun server. Idempotent. */
43
61
  stop: () => Promise<void>;
44
62
  }
45
63
  /**
@@ -1,3 +1,4 @@
1
+ import { setConnInfo } from "../conn-info.js";
1
2
  import { WS_READY_STATE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, encodeSendPayload, parseSubprotocols, validateSelectedSubprotocol, checkWebSocketOrigin, WebSocketProtocolError, } from "../websocket.js";
2
3
  /**
3
4
  * Start `Bun.serve` bound to the given {@link App}, wiring HTTP and WebSocket routes.
@@ -12,16 +13,34 @@ export function serve(app, opts = {}) {
12
13
  if (!Bun?.serve)
13
14
  throw new Error("Bun runtime not detected");
14
15
  const hasWs = app.webSocketRoutes.size > 0;
16
+ const servesTls = opts.tls !== undefined;
17
+ // Fulfil the conn-info contract with the immediate TCP peer from Bun's
18
+ // native `server.requestIP()`, so `getConnInfo` / `resolveClientIp` /
19
+ // `behindProxy` work on Bun. Never derived from spoofable headers.
20
+ const tagConnInfo = (req, server) => {
21
+ const ip = server?.requestIP?.(req);
22
+ if (ip) {
23
+ setConnInfo(req, {
24
+ remoteAddress: ip.address,
25
+ remotePort: ip.port,
26
+ tls: servesTls,
27
+ });
28
+ }
29
+ };
15
30
  const cfg = {
16
31
  maxRequestBodySize: opts.maxRequestBodySize ?? 16 * 1024 * 1024,
17
32
  fetch: hasWs
18
33
  ? (req, server) => {
34
+ tagConnInfo(req, server);
19
35
  if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
20
36
  return tryBunUpgrade(app, req, server);
21
37
  }
22
38
  return app.fetch(req);
23
39
  }
24
- : (req) => app.fetch(req),
40
+ : (req, server) => {
41
+ tagConnInfo(req, server);
42
+ return app.fetch(req);
43
+ },
25
44
  error: (err) => {
26
45
  // Last-resort handler reached only if app.fetch itself throws (it
27
46
  // normally catches everything). Log the error server-side but never
@@ -54,13 +73,30 @@ export function serve(app, opts = {}) {
54
73
  if (opts.tls)
55
74
  cfg.tls = opts.tls;
56
75
  const server = Bun.serve(cfg);
76
+ let stopped = false;
77
+ const stop = async () => {
78
+ if (stopped)
79
+ return;
80
+ stopped = true;
81
+ await app.shutdown(opts.shutdownTimeoutMs ?? 10_000);
82
+ server.stop(true);
83
+ };
84
+ if (opts.handleSignals !== false) {
85
+ // Parity with the Node/Deno adapters: without this, SIGTERM on a rolling
86
+ // deploy hard-kills the process — in-flight requests are dropped and
87
+ // `onShutdown`/`onClose` hooks never run. Bun implements Node's
88
+ // `process` signal events, so the same wiring works.
89
+ const onSignal = (sig) => {
90
+ app.log.info({ sig }, "DaloyJS received signal, shutting down");
91
+ void stop().then(() => process.exit(0));
92
+ };
93
+ process.once("SIGTERM", () => onSignal("SIGTERM"));
94
+ process.once("SIGINT", () => onSignal("SIGINT"));
95
+ }
57
96
  return {
58
97
  port: server.port,
59
98
  url: server.url,
60
- stop: async () => {
61
- await app.shutdown();
62
- server.stop(true);
63
- },
99
+ stop,
64
100
  };
65
101
  }
66
102
  async function tryBunUpgrade(app, req, server) {
@@ -1,3 +1,4 @@
1
+ import { setConnInfo } from "../conn-info.js";
1
2
  /**
2
3
  * Start `Deno.serve` bound to the given {@link App} with graceful-shutdown wiring.
3
4
  *
@@ -25,7 +26,21 @@ export function serve(app, opts = {}) {
25
26
  init.onListen = opts.onListen;
26
27
  if (opts.onError)
27
28
  init.onError = opts.onError;
28
- const server = denoServe(init, (req) => app.fetch(req));
29
+ const servesTls = Boolean(opts.cert && opts.key);
30
+ const server = denoServe(init, (req, info) => {
31
+ // Fulfil the conn-info contract with the immediate TCP peer from
32
+ // `Deno.serve`'s handler info, so `getConnInfo` / `resolveClientIp` /
33
+ // `behindProxy` work on Deno. Never derived from spoofable headers.
34
+ const addr = info?.remoteAddr;
35
+ if (addr?.hostname !== undefined) {
36
+ setConnInfo(req, {
37
+ remoteAddress: addr.hostname,
38
+ remotePort: addr.port,
39
+ tls: servesTls,
40
+ });
41
+ }
42
+ return app.fetch(req);
43
+ });
29
44
  const onSignal = () => {
30
45
  void shutdown();
31
46
  };
@@ -46,13 +61,15 @@ export function serve(app, opts = {}) {
46
61
  }
47
62
  // Drain app-level hooks first (while the HTTP server can still respond),
48
63
  // then ask Deno to stop gracefully — `server.shutdown()` stops accepting
49
- // new connections and lets in-flight requests finish. Aborting the listen
50
- // signal happens last, purely as a safety net for runtimes that lack
51
- // `HttpServer.shutdown()`. Doing it the other way around (aborting first)
52
- // tears the listener down before in-flight requests can complete.
64
+ // new connections and lets in-flight requests finish. Abort only as a
65
+ // fallback for runtimes that lack `HttpServer.shutdown()`: Deno 2.9 closes
66
+ // the listener resource inside shutdown(), so aborting the same signal
67
+ // afterwards throws BadResource.
53
68
  await app.shutdown(opts.shutdownTimeoutMs ?? 10_000);
54
- await server.shutdown?.();
55
- controller.abort();
69
+ if (server.shutdown)
70
+ await server.shutdown();
71
+ else
72
+ controller.abort();
56
73
  };
57
74
  return { shutdown };
58
75
  }
@@ -29,10 +29,13 @@ export interface LambdaEventV1 {
29
29
  queryStringParameters?: Record<string, string | undefined> | null;
30
30
  /** Query parameters with every value per name; preferred over the single-value map. */
31
31
  multiValueQueryStringParameters?: Record<string, string[] | undefined> | null;
32
- /** Request context; `domainName` is the host fallback and `path` the path fallback. */
32
+ /** Request context; `domainName` is the host fallback, `path` the path fallback, and `identity.sourceIp` the caller address seen by API Gateway. */
33
33
  requestContext?: {
34
34
  domainName?: string;
35
35
  path?: string;
36
+ identity?: {
37
+ sourceIp?: string;
38
+ };
36
39
  };
37
40
  /** Raw request body; base64-encoded when {@link LambdaEventV1.isBase64Encoded} is true. */
38
41
  body?: string;
@@ -51,11 +54,12 @@ export interface LambdaEventV2 {
51
54
  headers?: Record<string, string | undefined>;
52
55
  /** Request cookies as individual strings; re-joined with `; ` into a `cookie` header. */
53
56
  cookies?: string[];
54
- /** Request context; `http.method`/`http.path` carry the method and path, `domainName` the host fallback. */
57
+ /** Request context; `http.method`/`http.path` carry the method and path, `http.sourceIp` the caller address, `domainName` the host fallback. */
55
58
  requestContext?: {
56
59
  http?: {
57
60
  method?: string;
58
61
  path?: string;
62
+ sourceIp?: string;
59
63
  };
60
64
  domainName?: string;
61
65
  };
@@ -100,10 +104,63 @@ export interface LambdaResponseV2 {
100
104
  export type LambdaResponse = LambdaResponseV1 | LambdaResponseV2;
101
105
  /** Async handler shape consumed by AWS Lambda / Netlify Functions runtimes. */
102
106
  export type LambdaHandler = (event: LambdaEvent) => Promise<LambdaResponse>;
107
+ /**
108
+ * Writable response stream supplied to a response-streaming AWS Lambda handler.
109
+ *
110
+ * The contract intentionally models only the Node.js writable-stream methods
111
+ * used by DaloyJS, keeping the adapter free of Node-only imports while still
112
+ * respecting backpressure.
113
+ */
114
+ export interface LambdaResponseStream {
115
+ /** Writes one response chunk and returns false when the producer must wait for `drain`. */
116
+ write(chunk: Uint8Array): boolean;
117
+ /** Ends the response after every previously written chunk has flushed. */
118
+ end(): void;
119
+ /** Registers a one-shot writable-stream event listener. */
120
+ once(event: "drain", listener: () => void): this;
121
+ /** Registers a one-shot writable-stream error listener. */
122
+ once(event: "error", listener: (error: Error) => void): this;
123
+ /** Removes a previously registered drain listener when supported. */
124
+ off?(event: "drain", listener: () => void): this;
125
+ /** Removes a previously registered error listener when supported. */
126
+ off?(event: "error", listener: (error: Error) => void): this;
127
+ /** Resolves when AWS has flushed the ended response stream, when provided by the runtime. */
128
+ finished?(): Promise<void>;
129
+ }
130
+ /** HTTP response metadata accepted by `awslambda.HttpResponseStream.from()`. */
131
+ export interface LambdaStreamMetadata {
132
+ /** HTTP response status code. */
133
+ statusCode: number;
134
+ /** Single-value response headers, excluding `set-cookie`. */
135
+ headers: Record<string, string>;
136
+ /** Multi-value response headers, used to preserve every `set-cookie` value. */
137
+ multiValueHeaders?: Record<string, string[]>;
138
+ }
139
+ /** Async response-streaming handler shape consumed by the AWS Lambda Node.js runtime. */
140
+ export type LambdaStreamHandler = (event: LambdaEvent, responseStream: LambdaResponseStream, context?: unknown) => Promise<void>;
103
141
  /**
104
142
  * Wrap an {@link App} as a Lambda/Netlify handler accepting either v1.0 or v2.0 event payloads.
105
143
  *
144
+ * A malformed event (e.g. a `Host`/path combination that cannot form a valid
145
+ * URL) is answered with a clean `400` problem+json instead of throwing out of
146
+ * the handler, which API Gateway would otherwise surface as an opaque `502`.
147
+ *
106
148
  * @param app - The DaloyJS {@link App} that serves each translated request.
107
149
  * @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.
108
150
  */
109
151
  export declare function toLambdaHandler(app: App): LambdaHandler;
152
+ /**
153
+ * Wrap an {@link App} as an AWS Lambda response-streaming handler.
154
+ *
155
+ * The returned handler is decorated with the managed Node.js runtime's
156
+ * `awslambda.streamifyResponse()` helper, attaches status/headers with
157
+ * `HttpResponseStream.from()`, and pumps the web-standard response body while
158
+ * honoring writable-stream backpressure. The function throws during startup
159
+ * outside an AWS Lambda Node.js runtime so an accidentally buffered or broken
160
+ * deployment cannot start silently.
161
+ *
162
+ * @param app - The DaloyJS {@link App} that serves each translated request.
163
+ * @returns A response-streaming Lambda handler for Function URLs, API Gateway streaming proxy integrations, or `InvokeWithResponseStream`.
164
+ * @throws {Error} If the AWS Lambda response-streaming globals are unavailable.
165
+ */
166
+ export declare function toLambdaStreamHandler(app: App): LambdaStreamHandler;