@daloyjs/core 0.35.0 → 0.35.1

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 (188) hide show
  1. package/README.md +145 -29
  2. package/dist/adapters/bun.d.ts +0 -1
  3. package/dist/adapters/bun.js +0 -1
  4. package/dist/adapters/cloudflare.d.ts +0 -1
  5. package/dist/adapters/cloudflare.js +0 -1
  6. package/dist/adapters/deno.d.ts +0 -1
  7. package/dist/adapters/deno.js +0 -1
  8. package/dist/adapters/fastly.d.ts +0 -1
  9. package/dist/adapters/fastly.js +0 -1
  10. package/dist/adapters/lambda.d.ts +0 -1
  11. package/dist/adapters/lambda.js +0 -1
  12. package/dist/adapters/node.d.ts +0 -1
  13. package/dist/adapters/node.js +175 -35
  14. package/dist/adapters/vercel.d.ts +0 -1
  15. package/dist/adapters/vercel.js +0 -1
  16. package/dist/app.d.ts +18 -1
  17. package/dist/app.js +396 -118
  18. package/dist/banner.d.ts +0 -1
  19. package/dist/banner.js +0 -1
  20. package/dist/cli.d.ts +0 -1
  21. package/dist/cli.js +0 -1
  22. package/dist/client.d.ts +0 -1
  23. package/dist/client.js +0 -1
  24. package/dist/combine.d.ts +0 -1
  25. package/dist/combine.js +0 -1
  26. package/dist/compression.d.ts +0 -1
  27. package/dist/compression.js +0 -1
  28. package/dist/config.d.ts +0 -1
  29. package/dist/config.js +0 -1
  30. package/dist/conn-info.d.ts +0 -1
  31. package/dist/conn-info.js +0 -1
  32. package/dist/contract.d.ts +0 -1
  33. package/dist/contract.js +0 -1
  34. package/dist/cookie.d.ts +0 -1
  35. package/dist/cookie.js +0 -1
  36. package/dist/dependency.d.ts +0 -1
  37. package/dist/dependency.js +0 -1
  38. package/dist/discriminator.d.ts +0 -1
  39. package/dist/discriminator.js +0 -1
  40. package/dist/docs.d.ts +0 -1
  41. package/dist/docs.js +0 -1
  42. package/dist/errors.d.ts +0 -1
  43. package/dist/errors.js +0 -1
  44. package/dist/etag.d.ts +0 -1
  45. package/dist/etag.js +0 -1
  46. package/dist/fetch-guard.d.ts +0 -1
  47. package/dist/fetch-guard.js +0 -1
  48. package/dist/hashing.d.ts +0 -1
  49. package/dist/hashing.js +0 -1
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.js +0 -1
  52. package/dist/ip-restriction.d.ts +0 -1
  53. package/dist/ip-restriction.js +0 -1
  54. package/dist/jwk.d.ts +0 -1
  55. package/dist/jwk.js +0 -1
  56. package/dist/jwt.d.ts +0 -1
  57. package/dist/jwt.js +0 -1
  58. package/dist/load-shedding.d.ts +0 -1
  59. package/dist/load-shedding.js +0 -1
  60. package/dist/logger.d.ts +0 -1
  61. package/dist/logger.js +0 -1
  62. package/dist/middleware.d.ts +0 -1
  63. package/dist/middleware.js +16 -13
  64. package/dist/multipart.d.ts +0 -1
  65. package/dist/multipart.js +0 -1
  66. package/dist/openapi.d.ts +0 -1
  67. package/dist/openapi.js +0 -1
  68. package/dist/rate-limit-redis.d.ts +0 -1
  69. package/dist/rate-limit-redis.js +0 -1
  70. package/dist/router.d.ts +0 -1
  71. package/dist/router.js +0 -1
  72. package/dist/safe-redirect.d.ts +2 -3
  73. package/dist/safe-redirect.js +2 -3
  74. package/dist/sbom.cdx.json +9 -9
  75. package/dist/sbom.spdx.json +5 -5
  76. package/dist/schema.d.ts +0 -1
  77. package/dist/schema.js +0 -1
  78. package/dist/security-schemes.d.ts +0 -1
  79. package/dist/security-schemes.js +0 -1
  80. package/dist/security.d.ts +4 -5
  81. package/dist/security.js +4 -5
  82. package/dist/session.d.ts +0 -1
  83. package/dist/session.js +0 -1
  84. package/dist/streaming.d.ts +0 -1
  85. package/dist/streaming.js +0 -1
  86. package/dist/subdomains.d.ts +0 -1
  87. package/dist/subdomains.js +0 -1
  88. package/dist/time-claims.d.ts +0 -1
  89. package/dist/time-claims.js +0 -1
  90. package/dist/tracing.d.ts +0 -1
  91. package/dist/tracing.js +0 -1
  92. package/dist/types.d.ts +0 -1
  93. package/dist/types.js +0 -1
  94. package/dist/websocket.d.ts +0 -1
  95. package/dist/websocket.js +0 -1
  96. package/package.json +6 -2
  97. package/dist/adapters/bun.d.ts.map +0 -1
  98. package/dist/adapters/bun.js.map +0 -1
  99. package/dist/adapters/cloudflare.d.ts.map +0 -1
  100. package/dist/adapters/cloudflare.js.map +0 -1
  101. package/dist/adapters/deno.d.ts.map +0 -1
  102. package/dist/adapters/deno.js.map +0 -1
  103. package/dist/adapters/fastly.d.ts.map +0 -1
  104. package/dist/adapters/fastly.js.map +0 -1
  105. package/dist/adapters/lambda.d.ts.map +0 -1
  106. package/dist/adapters/lambda.js.map +0 -1
  107. package/dist/adapters/node.d.ts.map +0 -1
  108. package/dist/adapters/node.js.map +0 -1
  109. package/dist/adapters/vercel.d.ts.map +0 -1
  110. package/dist/adapters/vercel.js.map +0 -1
  111. package/dist/app.d.ts.map +0 -1
  112. package/dist/app.js.map +0 -1
  113. package/dist/banner.d.ts.map +0 -1
  114. package/dist/banner.js.map +0 -1
  115. package/dist/cli.d.ts.map +0 -1
  116. package/dist/cli.js.map +0 -1
  117. package/dist/client.d.ts.map +0 -1
  118. package/dist/client.js.map +0 -1
  119. package/dist/combine.d.ts.map +0 -1
  120. package/dist/combine.js.map +0 -1
  121. package/dist/compression.d.ts.map +0 -1
  122. package/dist/compression.js.map +0 -1
  123. package/dist/config.d.ts.map +0 -1
  124. package/dist/config.js.map +0 -1
  125. package/dist/conn-info.d.ts.map +0 -1
  126. package/dist/conn-info.js.map +0 -1
  127. package/dist/contract.d.ts.map +0 -1
  128. package/dist/contract.js.map +0 -1
  129. package/dist/cookie.d.ts.map +0 -1
  130. package/dist/cookie.js.map +0 -1
  131. package/dist/dependency.d.ts.map +0 -1
  132. package/dist/dependency.js.map +0 -1
  133. package/dist/discriminator.d.ts.map +0 -1
  134. package/dist/discriminator.js.map +0 -1
  135. package/dist/docs.d.ts.map +0 -1
  136. package/dist/docs.js.map +0 -1
  137. package/dist/errors.d.ts.map +0 -1
  138. package/dist/errors.js.map +0 -1
  139. package/dist/etag.d.ts.map +0 -1
  140. package/dist/etag.js.map +0 -1
  141. package/dist/fetch-guard.d.ts.map +0 -1
  142. package/dist/fetch-guard.js.map +0 -1
  143. package/dist/hashing.d.ts.map +0 -1
  144. package/dist/hashing.js.map +0 -1
  145. package/dist/index.d.ts.map +0 -1
  146. package/dist/index.js.map +0 -1
  147. package/dist/ip-restriction.d.ts.map +0 -1
  148. package/dist/ip-restriction.js.map +0 -1
  149. package/dist/jwk.d.ts.map +0 -1
  150. package/dist/jwk.js.map +0 -1
  151. package/dist/jwt.d.ts.map +0 -1
  152. package/dist/jwt.js.map +0 -1
  153. package/dist/load-shedding.d.ts.map +0 -1
  154. package/dist/load-shedding.js.map +0 -1
  155. package/dist/logger.d.ts.map +0 -1
  156. package/dist/logger.js.map +0 -1
  157. package/dist/middleware.d.ts.map +0 -1
  158. package/dist/middleware.js.map +0 -1
  159. package/dist/multipart.d.ts.map +0 -1
  160. package/dist/multipart.js.map +0 -1
  161. package/dist/openapi.d.ts.map +0 -1
  162. package/dist/openapi.js.map +0 -1
  163. package/dist/rate-limit-redis.d.ts.map +0 -1
  164. package/dist/rate-limit-redis.js.map +0 -1
  165. package/dist/router.d.ts.map +0 -1
  166. package/dist/router.js.map +0 -1
  167. package/dist/safe-redirect.d.ts.map +0 -1
  168. package/dist/safe-redirect.js.map +0 -1
  169. package/dist/schema.d.ts.map +0 -1
  170. package/dist/schema.js.map +0 -1
  171. package/dist/security-schemes.d.ts.map +0 -1
  172. package/dist/security-schemes.js.map +0 -1
  173. package/dist/security.d.ts.map +0 -1
  174. package/dist/security.js.map +0 -1
  175. package/dist/session.d.ts.map +0 -1
  176. package/dist/session.js.map +0 -1
  177. package/dist/streaming.d.ts.map +0 -1
  178. package/dist/streaming.js.map +0 -1
  179. package/dist/subdomains.d.ts.map +0 -1
  180. package/dist/subdomains.js.map +0 -1
  181. package/dist/time-claims.d.ts.map +0 -1
  182. package/dist/time-claims.js.map +0 -1
  183. package/dist/tracing.d.ts.map +0 -1
  184. package/dist/tracing.js.map +0 -1
  185. package/dist/types.d.ts.map +0 -1
  186. package/dist/types.js.map +0 -1
  187. package/dist/websocket.d.ts.map +0 -1
  188. package/dist/websocket.js.map +0 -1
package/README.md CHANGED
@@ -27,12 +27,16 @@ DaloyJS exists to be the framework you'd build if you took the best ideas from e
27
27
 
28
28
  | You want | Today's best-of | What DaloyJS gives you |
29
29
  | ------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
30
- | Best **OpenAPI ergonomics** | [FastAPI](https://fastapi.tiangolo.com) | First-class OpenAPI 3.1 generation from a single route definition; one-line `docs: true` auto-mounts `/docs` and `/openapi.json`. |
31
- | Best **Vercel / serverless / edge fit** | [Hono](https://hono.dev/docs/) | Web-standard `Request → Response` core, multi-runtime adapters. |
32
- | Mature **Swagger / docs / ops** in Node | [Fastify](https://fastify.dev/docs/latest/Reference/) | Encapsulated plugins, structured logger, graceful shutdown, request ids, hooks. |
33
- | Modern **TS-first DX**, Bun acceptable | [Elysia](https://elysiajs.com/at-glance.html) | End-to-end typed handlers, typed context, typed client. |
34
- | Best-in-class **typed client codegen** for any consumer | [Hey API](https://heyapi.dev/openapi-ts/get-started) | One command (`pnpm gen`) emits a fully-typed fetch SDK from your spec. |
35
- | **Portable supply-chain hardening for the apps you build** | [pnpm](https://pnpm.io/motivation) defaults + a zero-runtime-dep core | pnpm scaffolds keep the hardened `.npmrc` (`ignore-scripts`, 24h release-age cooldown, explicit build allowlist), source-verified lockfiles, zero runtime deps, CycloneDX + SPDX SBOM, and an opt-in hardened GitHub Actions bundle for teams that ship on GitHub. npm/yarn/bun scaffolds still get the runtime guardrails and generated CI/Docker install commands that disable lifecycle scripts, but pnpm's release-age and workspace checks are pnpm features. |
30
+ | Best **OpenAPI ergonomics** | [FastAPI](https://fastapi.tiangolo.com) | OpenAPI 3.1 from a single route definition; `docs: true` mounts `/docs` and `/openapi.json`. |
31
+ | Best **Vercel / serverless / edge fit** | [Hono](https://hono.dev/docs/) | Web-standard `Request → Response` core with adapters for Node, Bun, Deno, Cloudflare, Vercel, Fastly, and Lambda. |
32
+ | Mature **Swagger / docs / ops** in Node | [Fastify](https://fastify.dev/docs/latest/Reference/) | Encapsulated plugins, structured logger, graceful shutdown, request ids, and lifecycle hooks — all first-party. |
33
+ | Modern **TS-first DX**, Bun acceptable | [Elysia](https://elysiajs.com/at-glance.html) | End-to-end typed handlers, typed context, and a typed in-process client — no codegen step required. |
34
+ | Best-in-class **typed client codegen** for any consumer | [Hey API](https://heyapi.dev/openapi-ts/get-started) | One `pnpm gen` command emits a fully-typed fetch SDK from your live OpenAPI spec. |
35
+ | Opinionated **DI / module architecture** for large teams | [NestJS](https://docs.nestjs.com/) | Plugin encapsulation, `register()` prefixes, and `defineDependency()` typed-DI with per-request dedup no decorators. |
36
+ | Minimalist **async middleware cascade** | [Koa](https://koajs.com/) | Koa-style `Context` on a web-standard core, with validation, OpenAPI, errors, and security headers in-box. |
37
+ | **Services + real-time** API framework | [FeathersJS](https://feathersjs.com/) | First-party `app.ws()` with CSWSH refuse-to-boot guards, plus SSE / NDJSON streaming over explicit OpenAPI routes. |
38
+ | Battle-tested **Node middleware compatibility** | [Express v5](https://expressjs.com/en/blog/2024-10-15-v5-release) | Regex-free trie router, schema-validated routes, RFC 9457 problem+json, and refuse-to-boot guards on every runtime. |
39
+ | **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. |
36
40
 
37
41
  ```
38
42
  framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 6 with `strict: true`
@@ -338,6 +342,19 @@ miss 4,763,878 ops/sec
338
342
  - Body parsing is lazy and only runs when a route declares a body schema.
339
343
  - No regex on the hot path.
340
344
 
345
+ ### Cold-start tip (serverless / edge)
346
+
347
+ For deployments where every millisecond of startup matters (Lambda, Vercel Edge, Cloudflare Workers, Fastly Compute), import `App` from the deep entry point instead of the barrel:
348
+
349
+ ```ts
350
+ import { App } from "@daloyjs/core/app"; // ~13 ms faster cold start than "@daloyjs/core"
351
+ import { serve } from "@daloyjs/core/node";
352
+ ```
353
+
354
+ `@daloyjs/core/app` resolves to the **same `App` class with the same secure-by-default constructor** — `secureHeaders`, `requestId`, body limits, request timeouts, `fetchGuard`, prototype-pollution guards, problem+json redaction, and every other guardrail are still wired automatically. The deep import only skips loading unrelated peripheral modules (`jwk`, `jwt`, `multipart`, `websocket`, `streaming`, `compression`, `subdomains`, etc.) that the barrel re-exports for convenience. If you use any of those, import them directly from their own subpaths (`@daloyjs/core/jwk`, `@daloyjs/core/multipart`, …) so each one is paid for only when used.
355
+
356
+ Long-lived Node servers will not notice the difference. This is purely a cold-start optimization for serverless.
357
+
341
358
  ---
342
359
 
343
360
  ## Test client + contract tests
@@ -409,32 +426,131 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
409
426
 
410
427
  ## Status
411
428
 
412
- DaloyJS is in **public preview** (`0.x`). The public API may still change between minor versions; deprecations will get at least one minor cycle once `1.0.0` ships. The framework is already in use for production trials — 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 that 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.
429
+ DaloyJS is in **public preview** (`0.x`). The public API may still change between minor versions; deprecations will get at least one minor cycle once `1.0.0` ships. The framework is already in use for production trials.
413
430
 
414
- What works today, at a glance:
431
+ **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.
415
432
 
416
- - Contract-first routing, Standard Schema validation (Zod 4 / Valibot / ArkType / TypeBox), and OpenAPI 3.1 from a single source of truth.
417
- - 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.
418
- - Built-in security primitives (body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards, **duplicate `Host` / `Content-Length` rejection**, **stripped `Server` / `X-Powered-By` headers by default**, **structured-log redaction defaults** for authorization / cookie / password / token / JWT-shaped values, **`secureHeaders()` auto-applied since `0.16.0`** with user-installed overrides automatically replacing the auto instance, **cross-origin state-changing requests rejected with `403` since `0.16.0`** unless a route's `cors()` policy allows the request origin, **refuse-to-boot on weak session secrets, `cors({ origin: "*" })`, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded-*` in production since `0.17.0`**, **connection-draining shutdown with `Connection: close` on `503` and in-flight responses, `crashOnUnhandledRejection` default-on in production, and `app.healthcheck()` / `app.readinesscheck()` primitives with bearer-token auth + per-IP rate limit since `0.18.0`**, **`rateLimit({ groupId })` shared buckets, `combine` primitives `every` / `some` / `except`, `ipRestriction()` with CIDR-aware IPv4/IPv6 allow/deny lists, and `internal: true` route flag + `app.inject()` since `0.19.0`**, **`loadShedding()` event-loop-pressure middleware (auto-`503` + `Retry-After`), `app.cspReportRoute()` rate-limited CSP violation receiver + `secureHeaders({ reportingEndpoints, reportTo })` wiring, `disconnectStatusCode: 499` default for client-aborted requests, and `defineConfig({ schema, source })` boot-time typed configuration validation since `0.20.0`**, **`createJwtSigner()` / `createJwtVerifier()` with `alg`-discipline (no `alg: "none"`, explicit allowlist, HS+JWK refused at construction) + `exp`-required sign refusal, `requireScopes()` with RFC-6750 `WWW-Authenticate: Bearer` challenge + per-request scope aggregation, and `etag()` helper with `Set-Cookie` / `Cache-Control: private | no-store | no-cache` auto-skip (cross-tenant fingerprinting defense) since `0.21.0`**, **`jwk()` asymmetric-only JWKS middleware (refuses `HS*` at construction, `kid` + JWT-vs-JWK `alg` cross-check, `https://` JWKS URL with TTL caching + in-flight-promise dedup, normalizes `scope` / `scp` / `scopes` claims), per-scheme `verify(credentials, ctx)` revalidation hook on `bearerAuth()` / `jwk()`, `basicAuth({ onAuthSuccess })` typed-context callback, and `Cache-Control: no-store` on every first-party auth helper 401 challenge since `0.22.0`**, **`wsRateLimit()` for WebSocket upgrades, `loginThrottle()` credential-entry preset, `rotateSession()` privilege-change session rotation, `fileField({ magicBytes })` upload signature checks, `requirePayloadAuth` security-scheme guard, and WebSocket safe defaults since `0.23.0`**, **`app({ behindProxy })` declarative model (replaces `trustProxy`), adapter-independent `ConnInfo` abstraction (`getConnInfo()` / lazy `ctx.remoteAddress` / `ctx.remotePort`), `daloy doctor` production-posture validator (with `--audit-secrets` + `--no-audit-defaults`), container-first `create-daloy` templates (`HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` PID 1), PSL-aware `subdomains()` helper with `≤ 90 days` snapshot guard, plugin `dependencies: string[]` refuse-to-boot, namespace-protected `decorate({ override })`, plugin extension ordering with `before` / `after` + cycle detection, `behindProxy.hops` collapses to the `(N+1)`-from-rightmost slot, `defineDependency()` typed-DI helper with per-request deduplication, scheme-aware `ctx.state.auth` typed contract, plugin lifecycle encapsulation default of `local`, and required `name` + optional `seed` for stateful plugins since `0.24.0`**, **`compression()` middleware (built on the web-standard `CompressionStream`, prefers `br` > `gzip` > `deflate` and probes runtime support once) with BREACH-aware always-on guards (skip `Set-Cookie` / `Authorization` / session-or-CSRF cookie / 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 since `0.25.0`**, **`secureDefaults: false` refuse-to-construct in production (unless `acknowledgeInsecureDefaults: true`) + once-per-process `error` log naming every disabled default, `createJwtSigner()` / `createJwtVerifier()` refuse HS-shaped secrets `< 32` bytes (RFC 7518 §3.2), `secureHeaders()` refuses to construct with both `frameOptions: false` AND no CSP `frame-ancestors` directive (no clickjacking defense), and mandatory hardware-backed 2FA for every contributor with publish access (documented in `SECURITY.md`) since `0.26.0`**, and **single-source-of-truth cookie and temporal-claim helpers (`@daloyjs/core/cookie`, `@daloyjs/core/time-claims`), `session()` / `csrf()` `__Secure-` cookie refuse-to-boot, zero-runtime-dependency CI governance, and secret-comparison CI grep gate since `0.27.0`**, and **pattern-agnostic-framework parity audit suite since `0.28.0`** (`scripts/verify-parity-audits.ts` static gates wired into CI as `pnpm verify:parity-audits` — refuses public setters on `request.url` / `request.path` / `request.method`, refuses `ctx.respond = false`-style response-bypass switches, refuses `Referer`-based redirect targets, refuses `AES-CBC` / `SHA-1` / third-party crypto reach inside the cookie helper module, refuses adapter dispatch via `allowInternal: true` / `app.inject()`, and reaffirms the zero-runtime-dependency posture — plus `daloy doctor --audit-defaults` live-config checks that flag wildcard-credentials CORS, > 24 h CORS `maxAge`, > 25 MiB blanket body limits, zero `idleTimeoutMs` in production, `allowUnsafeValidationDetails` / `exposeFrameworkIdentity` / `enableServerTimingInProduction` opt-ins that the public type does not expose), and **zero-runtime-dependency batteries-included parity & governance audit suite since `0.29.0`** (`SECURITY-CONTACTS.md` rotation file with machine-readable ACTIVE block + `<!-- last-exercise: -->` marker, `scripts/verify-governance-audits.ts` static gates wired into CI as `pnpm verify:governance-audits` — refuses a missing/stale rotation file, refuses runtime deps on `@daloyjs/core/package.json`, refuses removal of the plugin-prerequisite refuse-to-boot path or the `topoSortExtensions` cycle-detection throw from `src/app.ts`, and reaffirms the governance floor: top-level `permissions:` on every workflow, `persist-credentials: false` on every `actions/checkout`, 40-hex SHA pinning on every third-party `uses:`, `step-security/harden-runner` on every workflow that uses third-party actions, and `.github/CODEOWNERS` on privileged files — plus a release-workflow contributor-rotation refusal step that exits non-zero when `github.actor` is not on the `SECURITY-CONTACTS` ACTIVE rotation), and **multi-runtime web-standard ergonomic-framework parity bake-ins since `0.30.0`** (`Cache-Control: no-store` baked into `UnauthorizedError` / `ForbiddenError` / `TooManyRequestsError` so every first-party auth helper 401 / 403 / 429 response is uncacheable, `cspReportRoute()` refuses `application/json` with `415` and refuses `maxBodyBytes > 64 KiB` at construction with the default production logger sink omitting the parsed report body unless `logCspReportBodies: true` is set explicitly (PII defense — CSP reports include the violated source URL), `cors()` `allowMethods` default narrowed to `[GET, HEAD, POST]` with `methods: ['*']` refused at construction (`PUT` / `PATCH` / `DELETE` are now explicit opt-ins), and `scripts/verify-runtime-parity-audits.ts` wired into CI as `pnpm verify:runtime-parity-audits` covering all of the above plus the reverse-proxy-helper absence audit and the compression skip-already-encoded reaffirm), and **mature-Node ergonomic-framework second-pass bake-ins since `0.31.0`** (`useSemicolonDelimiter: false` router-level audit so `/users/42;admin=true` cannot smuggle attacker-controlled query data past auth / CSRF / rate-limit middleware via a reverse-proxy / origin disagreement on RFC 3986 path-segment delimiters, `allowErrorHandlerOverride: false` audit so the framework never ships a standalone `setErrorHandler()` / `onError()` class method that could silently overwrite previously-registered error handlers, `requestId()` `trustIncoming: false` default audit so client-supplied `X-Request-ID` headers cannot poison framework logs by default, RFC 7231 + RFC 5789 HTTP-method allowlist now runtime-enforced inside `app.route()` (WebDAV / `TRACE` / `CONNECT` rejected at the framework boundary), `Connection: close` on every response produced during graceful shutdown reaffirmed by audit, and `scripts/verify-routing-hardening-audits.ts` wired into CI as `pnpm verify:routing-hardening-audits` covering all of the above), and **leftover focused slice since `0.32.0`** (`app.ws()` now scans the effective hook stack for header-mutating middleware — `secureHeaders()` / `cors()` / `csrf()` / `compression()` — and refuses-at-registration with a structured error naming both the WebSocket route AND every conflicting middleware unless the handler opts in via `acknowledgeHeaderMutatingMiddleware: true`, new `httpError({ status, problem, headers?, res? })` factory in `@daloyjs/core` extracts headers from a custom `Response` and refuses-at-construction with `MessageLeakError` when the response would leak request-scoped state — `Set-Cookie`, `Server-Timing`, `X-*-Token`, or `Cache-Control` other than `no-store` / `no-cache` all trip the gate, leaving only the `WWW-Authenticate` / `Proxy-Authenticate` / `Retry-After` / `Content-Type` / `Content-Language` allowlist (with `Content-Length` accepted for safety validation but not forwarded), plus a new `ProblemRenderOptions.contextHeaders` extension so direct callers of `HttpError.toResponse()` get the same Context-merge as the framework boundary, and the `PluginExtension` contract now accepts `responseHeaders?: readonly string[]` — `topoSortExtensions()` refuses-at-call when two extensions declare overlapping `responseHeaders` without declaring a `before` / `after` relationship between them so plugin-registration order can no longer non-deterministically pick a winner on a shared response header) — WebSocket public-route exposure can be acknowledged explicitly, while middleware/header-order checks remain registration invariants) plus first-party middleware (`secureHeaders` with CSP nonce + Trusted Types, `cors`, `rateLimit`, `requestId`, `bearerAuth`, `basicAuth`, `csrf` with **double-submit cookie** + **Fetch-Metadata** strategies, `session`, `timing` / `timingSafeEqual`) and **zero-knob crypto helpers** (`passwordHash` / `passwordVerify` at `@daloyjs/core/hashing`, `verifyWebhookSignature` / `signWebhookPayload`).
419
- - Streaming helpers (SSE + NDJSON), multipart ergonomics, OpenTelemetry-compatible tracing, signed-cookie sessions with pluggable stores, and a Redis-backed rate-limit store at `@daloyjs/core/rate-limit-redis`.
420
- - Request-smuggling singleton-header rejection now covers duplicate `Transfer-Encoding` in addition to duplicate `Host` and `Content-Length`.
421
- - WebSocket primitives with the same Bun-style handler shape (`open`/`message`/`close`/`drain`/`error`) running on both Node and Bun adapters, plus typed `app.ws(path, handler)` registration and route-table awareness so the upgrade listener is only installed when WS routes exist.
422
- - Production WebSocket routes under `secureDefaults` require a pre-upgrade `beforeUpgrade` decision hook or an explicit `acknowledgeUnauthenticated: true` marker, **AND** an Origin policy (`allowedOrigins: "same-origin"` / `string[]` / predicate) or `acknowledgeCrossOriginUpgrade: true` since `0.33.0` to close the Cross-Site WebSocket Hijacking (CSWSH) class of bug — Storybook's [CVE-2026-27148](https://www.aikido.dev/blog/storybooks-websockets-attack) is the representative case: cookie auth alone does not stop a malicious site from opening an authenticated WS handshake from a victim's browser, because browsers always attach cookies on the upgrade. The Origin check runs **before** `beforeUpgrade` in both Node and Bun adapters; `httpError({ res })` also uses case-insensitive header merging and never forwards a custom response's `Content-Length` onto the rendered `problem+json` body.
423
- - Registration-conflict guards are deliberate startup invariants: use `acknowledgeHeaderMutatingMiddleware` for reviewed WebSocket/middleware scopes and explicit plugin `before` / `after` ordering for shared `responseHeaders`, rather than expecting `secureDefaults: false` to suppress those checks.
424
- - Pretty `printStartupBanner()` / `formatStartupBanner()` startup helpers at `@daloyjs/core/banner`, used by every starter template so `pnpm dev` greets you with a colorized boxed panel (TTY + `NO_COLOR` / `FORCE_COLOR` aware, with an ASCII fallback for dumb terminals).
433
+ ### Routing, validation, and docs
434
+
435
+ - Contract-first routing with Standard Schema validation (Zod 4, Valibot, ArkType, TypeBox) and OpenAPI 3.1 generated from a single source of truth.
436
+ - Live OpenAPI 3.1 spec served as both JSON (`GET /openapi.json`) and YAML (`GET /openapi.yaml`) when `docs: true`, with Scalar UI theming and custom CSS via `docs.scalar`.
437
+ - Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
438
+ - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
439
+ - 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`.
425
440
  - In-process test client (`app.request()`), contract-test runner, in-process typed client, and Hey API codegen via `pnpm gen`.
426
- - One-command watch loop: `daloy dev` delegates to the host runtime's native watcher (`node --import tsx --watch`, `bun --hot`, or `deno run --watch`) with a `--runtime` override for cross-runtime `package.json` scripts.
427
- - AI-friendly route metadata via an optional `meta: { examples, extensions, summary, description, tags }` field on `route()`; examples are validated against your Standard Schemas at build time, surfaced into OpenAPI as `examples` + `x-daloy-*` extensions, and dumped as a sibling `routes.json` (or `routes.yaml` via `--yaml` / `--format yaml`, ~30% fewer LLM tokens) through `daloy inspect --ai` for LLM / Hey API / codegen consumption.
428
- - Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno) — explicit `openapi.info` values always win.
429
- - Live OpenAPI 3.1 spec served as both JSON (`GET /openapi.json`) and YAML (`GET /openapi.yaml`) when `docs: true`, with Scalar UI theming/custom CSS via `docs.scalar` covers Swagger UI's `swagger.yaml` convention out of the box.
430
- - `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel Edge templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening. The generated `deploy.yml` for container templates signs every pushed GHCR image with **Sigstore Cosign** (keyless OIDC) and attaches an **SPDX SBOM attestation** so consumers can `cosign verify` + `cosign verify-attestation --type spdxjson` instead of trusting the registry alone (Aikido [container-security checklist](https://www.aikido.dev/blog/container-security-best-practices) — "Use Signed Images" + "Generate an SBOM").
431
- - Plugin encapsulation, decorators, structured logging, request-id propagation, lifecycle events (`onPluginInstalled`, `onShutdown`, `onClose`), and graceful shutdown.
432
- - Integration guides for transactional email providers AWS SES, SendGrid, Resend, Postmark, Mailgun, and Mailtrap — with a common `EmailSender` plugin pattern and runtime-compatibility matrix.
433
- - Authentication & authorization guides for AWS Cognito, Microsoft Entra ID (MSAL), Auth0, Okta, and Clerk with a common bearer-auth plugin, scope/role enforcement, and runtime-compatibility matrix.
434
- - Since `0.34.0` the supply-chain verification suite that runs in CI was expanded beyond the parity / governance / runtime-parity / routing-hardening gates with a series of focused static checks — `pnpm verify:no-shrinkwrap`, `verify:no-bin-shadowing`, `verify:no-native-addons`, `verify:no-polyfill-cdns` (hijacked-CDN IOCs and typosquats), `verify:no-redos-patterns`, `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills`, `verify:runtime-eol` (refuses to release on a Node line past its EOL date), plus IOC additions to `verify:no-registry-exfiltration` / `verify:no-lockfile-sources` covering the Beamglea phishing-CDN campaign, the `naya-flore` / `nvlore-hsc` WhatsApp remote-kill-switch campaign, the Toptal GitHub-org hijack, the `xuxingfeng` and `xlsx-to-json-lh` destructive-payload campaigns, `react-login-page` keylogger, `@crypto-exploit` wallet drainers, the Vietnam-Telegram-ban Fastlane typosquats, surveillance-malware packages, the Discord-webhook reconnaissance campaign, and npm-package-aliasing dependency-confusion patterns. The release workflow itself moved to `npm stage publish ... --provenance` so the protected `npm-publish` GitHub Environment approval is now followed by an out-of-band `npm stage approve` step with maintainer MFA before any version becomes installable.
435
- - A self-paced [workshop](./workshop/README.md) (4-hour and 8-hour tracks) for senior TypeScript/Node developers: contract-first routes, validation, errors, middleware composition, JWT/JWK, sessions, WebSocket upgrades, CSRF/CORS, `fetchGuard()` SSRF defaults, OpenAPI tuning, and contract testing — every exercise is a single self-contained `tsx --watch` file with ordered coding-steps and reference solutions.
436
-
437
- Roadmap, version-by-version plan, and shipped/in-progress checklists live in [ROADMAP.md](./ROADMAP.md).
441
+
442
+ ### Runtimes and deployment
443
+
444
+ - 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.
445
+ - `daloy dev` watch loop delegates to the host runtime's native watcher (`node --import tsx --watch`, `bun --hot`, or `deno run --watch`) with a `--runtime` override for cross-runtime `package.json` scripts.
446
+ - `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel Edge templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening.
447
+ - Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
448
+ - Generated `deploy.yml` for container templates signs every pushed GHCR image with **Sigstore Cosign** (keyless OIDC) and attaches an **SPDX SBOM attestation** so consumers can `cosign verify` and `cosign verify-attestation --type spdxjson` instead of trusting the registry alone.
449
+ - Pretty `printStartupBanner()` / `formatStartupBanner()` helpers at `@daloyjs/core/banner`, used by every starter template (TTY + `NO_COLOR` / `FORCE_COLOR` aware, ASCII fallback for dumb terminals).
450
+
451
+ ### Core security primitives
452
+
453
+ - Body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards.
454
+ - Request-smuggling defense: duplicate `Host`, `Content-Length`, and `Transfer-Encoding` headers are rejected.
455
+ - `Server` and `X-Powered-By` headers stripped by default.
456
+ - Structured-log redaction defaults for authorization, cookie, password, token, and JWT-shaped values.
457
+ - `secureHeaders()` auto-applied; user-installed instances automatically replace the auto one.
458
+ - Cross-origin state-changing requests rejected with `403` unless a route's `cors()` policy allows the origin.
459
+ - Production mode strips `detail` from 5xx problem+json automatically.
460
+ - Real **405** with `Allow` header instead of a misleading 404.
461
+ - `Cache-Control: no-store` baked into `UnauthorizedError` / `ForbiddenError` / `TooManyRequestsError` so every first-party auth 401 / 403 / 429 response is uncacheable.
462
+
463
+ ### Refuse-to-boot guardrails
464
+
465
+ The framework refuses to start (or to construct) when configuration is unsafe:
466
+
467
+ - Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded-*` in production.
468
+ - `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
469
+ - `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, and refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2).
470
+ - `secureHeaders()` refuses to construct with `frameOptions: false` AND no CSP `frame-ancestors` directive (no clickjacking defense).
471
+ - `cors()` refuses `methods: ['*']` at construction; default `allowMethods` narrowed to `[GET, HEAD, POST]` so `PUT` / `PATCH` / `DELETE` become explicit opt-ins.
472
+ - `cspReportRoute()` refuses non-`application/json` (415) and refuses `maxBodyBytes > 64 KiB` at construction. The default production logger sink omits the parsed report body unless `logCspReportBodies: true` is set explicitly.
473
+ - `session()` and `csrf()` refuse cookies that violate the `__Secure-` prefix policy.
474
+ - Plugin `dependencies: string[]` refuse-to-boot when a prerequisite is missing; `topoSortExtensions()` refuses cycles, and refuses two extensions declaring overlapping `responseHeaders` without a `before` / `after` relationship.
475
+ - `app.ws()` scans the effective hook stack and refuses-at-registration when header-mutating middleware (`secureHeaders()`, `cors()`, `csrf()`, `compression()`) is present, unless the handler opts in via `acknowledgeHeaderMutatingMiddleware: true`.
476
+
477
+ ### First-party middleware
478
+
479
+ - `secureHeaders` with strict CSP baseline, per-request **nonces**, **Trusted Types** (`require-trusted-types-for 'script'`), `frame-ancestors`, `cross-origin-opener-policy` / `cross-origin-resource-policy`, and reporting endpoints.
480
+ - `cors` with explicit-allowlist enforcement.
481
+ - `csrf` with **double-submit cookie** (default) and **Fetch-Metadata** (`Sec-Fetch-Site`-based, tokenless) strategies; timing-safe verification.
482
+ - `rateLimit` with token-bucket + `Retry-After`, shared `groupId` buckets, and a Redis-backed store at `@daloyjs/core/rate-limit-redis`.
483
+ - `loadShedding()` event-loop-pressure middleware (auto-`503` + `Retry-After`).
484
+ - `loginThrottle()` credential-entry preset and `rotateSession()` privilege-change session rotation.
485
+ - `ipRestriction()` with CIDR-aware IPv4 / IPv6 allow / deny lists.
486
+ - `combine` primitives: `every`, `some`, `except`.
487
+ - `requestId()` with cryptographic ids; `trustIncoming: false` by default so client-supplied `X-Request-ID` headers cannot poison logs.
488
+ - `bearerAuth()` and `basicAuth()` with per-scheme `verify(credentials, ctx)` revalidation hooks, typed-context `onAuthSuccess` callback, and `Cache-Control: no-store` on every 401 challenge.
489
+ - `jwk()` asymmetric-only JWKS middleware: refuses `HS*` at construction, cross-checks `kid` and JWT-vs-JWK `alg`, requires `https://` JWKS URLs with TTL caching + in-flight-promise dedup, normalizes `scope` / `scp` / `scopes` claims.
490
+ - `requireScopes()` with RFC-6750 `WWW-Authenticate: Bearer` challenge and per-request scope aggregation.
491
+ - `session()` with signed cookies and pluggable stores.
492
+ - `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.
493
+ - `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
494
+ - `timing` / `timingSafeEqual` helpers.
495
+ - `fileField({ magicBytes })` upload signature checks.
496
+ - `ipRestriction()`, `wsRateLimit()`, `requirePayloadAuth` security-scheme guard.
497
+ - Zero-knob crypto helpers: `passwordHash` / `passwordVerify` at `@daloyjs/core/hashing`, `verifyWebhookSignature` / `signWebhookPayload`.
498
+ - `fetchGuard()` SSRF defaults.
499
+
500
+ ### WebSockets
501
+
502
+ - WebSocket primitives with the Bun-style handler shape (`open` / `message` / `close` / `drain` / `error`) running on both Node and Bun adapters.
503
+ - Typed `app.ws(path, handler)` registration; the upgrade listener is only installed when WS routes exist.
504
+ - Production WebSocket routes under `secureDefaults` require:
505
+ - a pre-upgrade `beforeUpgrade` decision hook or an explicit `acknowledgeUnauthenticated: true`, **AND**
506
+ - an Origin policy (`allowedOrigins: "same-origin"` / `string[]` / predicate) or `acknowledgeCrossOriginUpgrade: true`.
507
+
508
+ This closes the Cross-Site WebSocket Hijacking (CSWSH) class of bug — Storybook's [CVE-2026-27148](https://www.aikido.dev/blog/storybooks-websockets-attack) is the representative case: cookie auth alone does not stop a malicious site from opening an authenticated WS handshake from a victim's browser. The Origin check runs **before** `beforeUpgrade` in both adapters.
509
+
510
+ ### Lifecycle and ops
511
+
512
+ - Plugin encapsulation (Fastify-style), decorators, structured logging, request-id propagation.
513
+ - Lifecycle events: `onPluginInstalled`, `onShutdown`, `onClose`.
514
+ - Connection-draining graceful shutdown with `Connection: close` on `503` and in-flight responses.
515
+ - `crashOnUnhandledRejection` default-on in production.
516
+ - `app.healthcheck()` / `app.readinesscheck()` primitives with bearer-token auth and per-IP rate limit.
517
+ - `disconnectStatusCode: 499` default for client-aborted requests.
518
+ - `defineConfig({ schema, source })` boot-time typed configuration validation.
519
+ - `app({ behindProxy })` declarative model (replaces `trustProxy`); `behindProxy.hops` collapses to the `(N+1)`-from-rightmost slot.
520
+ - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort`.
521
+ - `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).
522
+ - PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
523
+ - `defineDependency()` typed-DI helper with per-request deduplication.
524
+ - Scheme-aware `ctx.state.auth` typed contract; named, optionally seeded stateful plugins.
525
+
526
+ ### Streaming and integrations
527
+
528
+ - Streaming helpers (SSE + NDJSON), multipart ergonomics, OpenTelemetry-compatible tracing.
529
+ - Integration guides for transactional email — AWS SES, SendGrid, Resend, Postmark, Mailgun, Mailtrap — with a common `EmailSender` plugin pattern and runtime-compatibility matrix.
530
+ - Authentication & authorization guides for AWS Cognito, Microsoft Entra ID (MSAL), Auth0, Okta, and Clerk — with a common bearer-auth plugin, scope / role enforcement, and runtime-compatibility matrix.
531
+
532
+ ### Supply-chain hardening (CI)
533
+
534
+ A growing suite of static gates runs on every push and PR:
535
+
536
+ - Parity / governance / runtime-parity / routing-hardening audits: `verify:parity-audits`, `verify:governance-audits`, `verify:runtime-parity-audits`, `verify:routing-hardening-audits`.
537
+ - Source-tree gates: `verify:no-shrinkwrap`, `verify:no-bin-shadowing`, `verify:no-native-addons`, `verify:no-polyfill-cdns` (hijacked-CDN IOCs and typosquats), `verify:no-redos-patterns`, `verify:no-encoded-payloads`, `verify:no-invisible-unicode`, `verify:no-weak-random`, `verify:no-unsafe-buffer`, `verify:no-leaked-credentials`, `verify:no-vulnerable-sandboxes`.
538
+ - Agent-skill gates: `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills`.
539
+ - Dependency gates: `verify:no-runtime-deps`, `verify:dep-licenses`, `verify:known-dep-names`, `verify:lockfile-sources`, `verify:no-registry-exfiltration`, `verify:no-remote-exec`, `verify:no-lifecycle-scripts`, `verify:runtime-eol` (refuses to release on a Node line past its EOL date).
540
+ - IOC coverage in `verify:no-registry-exfiltration` and `verify:lockfile-sources` for active campaigns including Beamglea phishing-CDN, `naya-flore` / `nvlore-hsc` WhatsApp remote-kill-switch, the Toptal GitHub-org hijack, `xuxingfeng` and `xlsx-to-json-lh` destructive payloads, `react-login-page` keylogger, `@crypto-exploit` wallet drainers, Vietnam-Telegram-ban Fastlane typosquats, surveillance-malware packages, the Discord-webhook reconnaissance campaign, and npm-package-aliasing dependency-confusion patterns.
541
+ - `SECURITY-CONTACTS.md` rotation file with a machine-readable ACTIVE block and `<!-- last-exercise: -->` marker; the release workflow refuses to publish when `github.actor` is not on the ACTIVE rotation.
542
+ - Governance floor reaffirmed by audit: top-level `permissions:` on every workflow, `persist-credentials: false` on every `actions/checkout`, 40-hex SHA pinning on every third-party `uses:`, `step-security/harden-runner` on every workflow using third-party actions, and `.github/CODEOWNERS` on privileged files.
543
+ - Mandatory hardware-backed 2FA for every contributor with publish access (documented in `SECURITY.md`).
544
+ - `@daloyjs/core` is published with CycloneDX 1.5 + SPDX 2.3 SBOMs and npm `--provenance`; the release workflow uses `npm stage publish` so the protected `npm-publish` GitHub Environment approval is followed by an out-of-band `npm stage approve` step with maintainer MFA before any version is installable.
545
+
546
+ ### Other helpers
547
+
548
+ - Single-source-of-truth cookie and temporal-claim helpers at `@daloyjs/core/cookie` and `@daloyjs/core/time-claims`.
549
+ - `httpError({ status, problem, headers?, res? })` factory extracts headers from a custom `Response` and refuses-at-construction with `MessageLeakError` if the response would leak request-scoped state (`Set-Cookie`, `Server-Timing`, `X-*-Token`, or any `Cache-Control` other than `no-store` / `no-cache`). The allowlist is `WWW-Authenticate` / `Proxy-Authenticate` / `Retry-After` / `Content-Type` / `Content-Language` (with `Content-Length` accepted for safety validation but not forwarded).
550
+ - `ProblemRenderOptions.contextHeaders` lets direct callers of `HttpError.toResponse()` get the same Context-merge as the framework boundary.
551
+ - A self-paced [workshop](./workshop/README.md) (4-hour and 8-hour tracks) for senior TypeScript / Node developers: contract-first routes, validation, errors, middleware composition, JWT / JWK, sessions, WebSocket upgrades, CSRF / CORS, `fetchGuard()` SSRF defaults, OpenAPI tuning, and contract testing. Every exercise is a single self-contained `tsx --watch` file with ordered coding steps and reference solutions.
552
+
553
+ Roadmap and shipped / in-progress checklists live in [ROADMAP.md](./ROADMAP.md).
438
554
 
439
555
  ## Contributing
440
556
 
@@ -39,4 +39,3 @@ export interface BunServerHandle {
39
39
  }
40
40
  /** Start `Bun.serve` bound to the given {@link App}, wiring HTTP and WebSocket routes. */
41
41
  export declare function serve(app: App, opts?: BunServeOptions): BunServerHandle;
42
- //# sourceMappingURL=bun.d.ts.map
@@ -246,4 +246,3 @@ function toBunBinary(data) {
246
246
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
247
247
  return new Uint8Array(data);
248
248
  }
249
- //# sourceMappingURL=bun.js.map
@@ -27,4 +27,3 @@ interface ExecutionContextLike {
27
27
  /** Wrap an {@link App} in the `{ fetch }` object expected by Cloudflare Workers and other web-standard hosts. */
28
28
  export declare function toFetchHandler<Env = unknown>(app: App): ExportedFetchHandler<Env>;
29
29
  export {};
30
- //# sourceMappingURL=cloudflare.d.ts.map
@@ -4,4 +4,3 @@ export function toFetchHandler(app) {
4
4
  fetch: (req) => app.fetch(req),
5
5
  };
6
6
  }
7
- //# sourceMappingURL=cloudflare.js.map
@@ -35,4 +35,3 @@ export interface DenoServerHandle {
35
35
  }
36
36
  /** Start `Deno.serve` bound to the given {@link App} with graceful-shutdown wiring. */
37
37
  export declare function serve(app: App, opts?: DenoServeOptions): DenoServerHandle;
38
- //# sourceMappingURL=deno.d.ts.map
@@ -43,4 +43,3 @@ export function serve(app, opts = {}) {
43
43
  };
44
44
  return { shutdown };
45
45
  }
46
- //# sourceMappingURL=deno.js.map
@@ -22,4 +22,3 @@ import type { App } from "../app.js";
22
22
  export declare function toFastlyHandler(app: App): (req: Request) => Promise<Response>;
23
23
  /** Register a Fastly Compute `fetch` event listener that delegates to the given {@link App}. */
24
24
  export declare function installFastlyListener(app: App): void;
25
- //# sourceMappingURL=fastly.d.ts.map
@@ -10,4 +10,3 @@ export function installFastlyListener(app) {
10
10
  }
11
11
  g.addEventListener("fetch", (event) => event.respondWith(app.fetch(event.request)));
12
12
  }
13
- //# sourceMappingURL=fastly.js.map
@@ -71,4 +71,3 @@ export type LambdaResponse = LambdaResponseV1 | LambdaResponseV2;
71
71
  export type LambdaHandler = (event: LambdaEvent) => Promise<LambdaResponse>;
72
72
  /** Wrap an {@link App} as a Lambda/Netlify handler accepting either v1.0 or v2.0 event payloads. */
73
73
  export declare function toLambdaHandler(app: App): LambdaHandler;
74
- //# sourceMappingURL=lambda.d.ts.map
@@ -115,4 +115,3 @@ function bytesToBase64(bytes) {
115
115
  binary += String.fromCharCode(bytes[i]);
116
116
  return btoa(binary);
117
117
  }
118
- //# sourceMappingURL=lambda.js.map
@@ -32,4 +32,3 @@ export interface NodeServerHandle {
32
32
  }
33
33
  /** Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}. */
34
34
  export declare function serve(app: App, opts?: NodeServerOptions): NodeServerHandle;
35
- //# sourceMappingURL=node.d.ts.map
@@ -4,30 +4,30 @@
4
4
  */
5
5
  import { createServer, } from "node:http";
6
6
  import { Readable } from "node:stream";
7
+ import { DALOY_RAW_BODY } from "../app.js";
7
8
  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";
8
9
  /** Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}. */
9
10
  export function serve(app, opts = {}) {
10
11
  const trustProxy = opts.trustProxy === true;
11
- const server = createServer({ maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024 }, async (req, res) => {
12
- try {
13
- const request = await toWebRequest(req, trustProxy);
14
- const response = await app.fetch(request);
15
- await sendWebResponse(response, res);
12
+ const server = createServer({ maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024 }, (req, res) => {
13
+ // GET/HEAD: no body work, dispatch directly. Keep this first so the GET
14
+ // hot path doesn't pay for any of the buffering bookkeeping below.
15
+ const method = req.method;
16
+ if (method === "GET" || method === "HEAD" || method === undefined) {
17
+ dispatchToApp(app, req, res, trustProxy, undefined);
18
+ return;
16
19
  }
17
- catch (e) {
18
- if (!res.headersSent) {
19
- res.statusCode = 500;
20
- res.setHeader("content-type", "application/problem+json");
21
- res.end(JSON.stringify({
22
- type: "https://daloyjs.dev/errors/internal",
23
- title: "Internal Server Error",
24
- status: 500,
25
- }));
26
- }
27
- else {
28
- res.destroy(e);
29
- }
20
+ // POST/PUT/PATCH/DELETE with a small known content-length: pre-buffer
21
+ // bytes from the Node socket directly so the Request constructor gets a
22
+ // Uint8Array body instead of `Readable.toWeb(req)`. This skips the
23
+ // WHATWG-stream adapter that dominates POST throughput on Node.
24
+ const cl = req.headers["content-length"];
25
+ const n = cl ? Number(cl) : NaN;
26
+ if (Number.isFinite(n) && n >= 0 && n <= BUFFERED_BODY_MAX_BYTES) {
27
+ bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes), (e) => writeAdapterError(res, e));
28
+ return;
30
29
  }
30
+ dispatchToApp(app, req, res, trustProxy, undefined);
31
31
  });
32
32
  server.requestTimeout = opts.connectionTimeoutMs ?? 30_000;
33
33
  server.headersTimeout = opts.connectionTimeoutMs ?? 30_000;
@@ -69,33 +69,145 @@ export function serve(app, opts = {}) {
69
69
  }
70
70
  return { server, port, close };
71
71
  }
72
- async function toWebRequest(req, trustProxy) {
72
+ /**
73
+ * Maximum content-length (in bytes) that the Node adapter will pre-buffer
74
+ * before constructing the `Request`. Bodies above this fall back to the
75
+ * streaming `Readable.toWeb` path so unbounded uploads can't exhaust
76
+ * adapter memory. 1 MiB matches the default `App.bodyLimitBytes`.
77
+ */
78
+ const BUFFERED_BODY_MAX_BYTES = 1024 * 1024;
79
+ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
80
+ let request;
81
+ try {
82
+ request = toWebRequest(req, trustProxy, bufferedBody);
83
+ }
84
+ catch (e) {
85
+ writeAdapterError(res, e);
86
+ return;
87
+ }
88
+ const responseOrPromise = app.fetch(request);
89
+ if (responseOrPromise instanceof Promise) {
90
+ responseOrPromise.then((response) => {
91
+ try {
92
+ const sent = sendWebResponse(response, res);
93
+ if (sent instanceof Promise)
94
+ sent.catch((e) => writeAdapterError(res, e));
95
+ }
96
+ catch (e) {
97
+ writeAdapterError(res, e);
98
+ }
99
+ }, (e) => writeAdapterError(res, e));
100
+ }
101
+ else {
102
+ try {
103
+ const sent = sendWebResponse(responseOrPromise, res);
104
+ if (sent instanceof Promise)
105
+ sent.catch((e) => writeAdapterError(res, e));
106
+ }
107
+ catch (e) {
108
+ writeAdapterError(res, e);
109
+ }
110
+ }
111
+ }
112
+ function bufferRequestBody(req, expected) {
113
+ return new Promise((resolve, reject) => {
114
+ const chunks = [];
115
+ let received = 0;
116
+ let settled = false;
117
+ const onData = (chunk) => {
118
+ if (settled)
119
+ return;
120
+ received += chunk.length;
121
+ if (received > expected) {
122
+ settled = true;
123
+ cleanup();
124
+ req.destroy();
125
+ reject(new Error("Request body exceeded declared Content-Length"));
126
+ return;
127
+ }
128
+ chunks.push(chunk);
129
+ };
130
+ const onEnd = () => {
131
+ if (settled)
132
+ return;
133
+ settled = true;
134
+ cleanup();
135
+ if (received === 0) {
136
+ resolve(new Uint8Array(0));
137
+ return;
138
+ }
139
+ const buf = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, received);
140
+ resolve(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
141
+ };
142
+ const onErr = (err) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ cleanup();
147
+ reject(err);
148
+ };
149
+ const cleanup = () => {
150
+ req.off("data", onData);
151
+ req.off("end", onEnd);
152
+ req.off("error", onErr);
153
+ req.off("aborted", onErr);
154
+ };
155
+ req.on("data", onData);
156
+ req.on("end", onEnd);
157
+ req.on("error", onErr);
158
+ req.on("aborted", onErr);
159
+ });
160
+ }
161
+ function writeAdapterError(res, e) {
162
+ if (!res.headersSent) {
163
+ res.statusCode = 500;
164
+ res.setHeader("content-type", "application/problem+json");
165
+ res.end(JSON.stringify({
166
+ type: "https://daloyjs.dev/errors/internal",
167
+ title: "Internal Server Error",
168
+ status: 500,
169
+ }));
170
+ }
171
+ else {
172
+ res.destroy(e);
173
+ }
174
+ }
175
+ function toWebRequest(req, trustProxy, bufferedBody) {
176
+ const reqHeaders = req.headers;
73
177
  const forwardedHost = trustProxy
74
- ? firstHeader(req.headers["x-forwarded-host"])
178
+ ? firstHeader(reqHeaders["x-forwarded-host"])
75
179
  : undefined;
76
- const host = forwardedHost ?? req.headers.host ?? "localhost";
180
+ const host = forwardedHost ?? reqHeaders.host ?? "localhost";
77
181
  const forwardedProto = trustProxy
78
- ? firstHeader(req.headers["x-forwarded-proto"])
182
+ ? firstHeader(reqHeaders["x-forwarded-proto"])
79
183
  : undefined;
80
184
  const proto = forwardedProto ??
81
185
  (req.socket.encrypted ? "https" : "http");
82
186
  const url = `${proto}://${host}${req.url ?? "/"}`;
83
187
  const headers = new Headers();
84
- for (const [k, v] of Object.entries(req.headers)) {
188
+ for (const k in reqHeaders) {
189
+ const v = reqHeaders[k];
85
190
  if (v === undefined)
86
191
  continue;
87
- if (Array.isArray(v))
88
- headers.set(k, v.join(", "));
89
- else
90
- headers.set(k, String(v));
192
+ headers.set(k, Array.isArray(v) ? v.join(", ") : v);
91
193
  }
92
194
  const method = req.method ?? "GET";
93
- const init = { method, headers };
94
- if (method !== "GET" && method !== "HEAD") {
95
- init.body = Readable.toWeb(req);
96
- init.duplex = "half";
195
+ if (method === "GET" || method === "HEAD") {
196
+ return new Request(url, { method, headers });
97
197
  }
98
- return new Request(url, init);
198
+ if (bufferedBody !== undefined) {
199
+ return new Request(url, {
200
+ method,
201
+ headers,
202
+ body: bufferedBody,
203
+ });
204
+ }
205
+ return new Request(url, {
206
+ method,
207
+ headers,
208
+ body: Readable.toWeb(req),
209
+ duplex: "half",
210
+ });
99
211
  }
100
212
  function firstHeader(v) {
101
213
  if (v === undefined)
@@ -106,14 +218,43 @@ function firstHeader(v) {
106
218
  const comma = raw.indexOf(",");
107
219
  return (comma === -1 ? raw : raw.slice(0, comma)).trim() || undefined;
108
220
  }
109
- async function sendWebResponse(res, out) {
221
+ function sendWebResponse(res, out) {
110
222
  out.statusCode = res.status;
111
223
  res.headers.forEach((v, k) => out.setHeader(k, v));
224
+ // Fast-path: response was produced by serializeResult and carries the raw
225
+ // body bytes via the DALOY_RAW_BODY Symbol. Skip arrayBuffer() and the
226
+ // reader-loop microtask churn entirely for buffer-backed responses.
227
+ const raw = res[DALOY_RAW_BODY];
228
+ if (raw !== undefined) {
229
+ if (raw === null) {
230
+ out.end();
231
+ }
232
+ else {
233
+ out.end(raw);
234
+ }
235
+ return;
236
+ }
112
237
  if (!res.body) {
113
238
  out.end();
114
239
  return;
115
240
  }
116
- const reader = res.body.getReader();
241
+ const ct = out.getHeader("content-type");
242
+ const isStream = ct && typeof ct === "string" && ct.startsWith("text/event-stream");
243
+ const rawLength = out.getHeader("content-length");
244
+ const contentLength = typeof rawLength === "number"
245
+ ? rawLength
246
+ : typeof rawLength === "string"
247
+ ? Number(rawLength)
248
+ : Number.NaN;
249
+ if (!isStream && Number.isFinite(contentLength) && contentLength <= 64 * 1024) {
250
+ return res.arrayBuffer().then((ab) => {
251
+ out.end(Buffer.from(ab));
252
+ });
253
+ }
254
+ return pumpBody(res.body, out);
255
+ }
256
+ async function pumpBody(body, out) {
257
+ const reader = body.getReader();
117
258
  while (true) {
118
259
  const { done, value } = await reader.read();
119
260
  if (done)
@@ -390,4 +531,3 @@ class NodeWebSocketConnection {
390
531
  }
391
532
  }
392
533
  }
393
- //# sourceMappingURL=node.js.map
@@ -43,4 +43,3 @@ export declare const toEdgeHandler: typeof toWebHandler;
43
43
  */
44
44
  export declare function toRouteHandlers(app: App): RouteHandlers;
45
45
  export {};
46
- //# sourceMappingURL=vercel.d.ts.map
@@ -23,4 +23,3 @@ export function toRouteHandlers(app) {
23
23
  out[method] = handler;
24
24
  return out;
25
25
  }
26
- //# sourceMappingURL=vercel.js.map