@daloyjs/core 0.36.0 → 0.38.0

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -3
  3. package/bin/daloy.mjs +2 -0
  4. package/dist/adapters/bun.js +16 -9
  5. package/dist/adapters/deno.js +7 -1
  6. package/dist/adapters/node.d.ts +25 -0
  7. package/dist/adapters/node.js +32 -0
  8. package/dist/app.d.ts +200 -6
  9. package/dist/app.js +235 -50
  10. package/dist/asyncapi.d.ts +98 -0
  11. package/dist/asyncapi.js +212 -0
  12. package/dist/auto-ban.d.ts +205 -0
  13. package/dist/auto-ban.js +222 -0
  14. package/dist/bot-guard.d.ts +209 -0
  15. package/dist/bot-guard.js +291 -0
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.js +113 -4
  18. package/dist/client.d.ts +23 -0
  19. package/dist/client.js +16 -0
  20. package/dist/concurrency-limit.d.ts +135 -0
  21. package/dist/concurrency-limit.js +254 -0
  22. package/dist/docs.d.ts +57 -6
  23. package/dist/docs.js +34 -3
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.js +57 -0
  26. package/dist/fetch-guard.js +4 -0
  27. package/dist/fetch-resilience.d.ts +295 -0
  28. package/dist/fetch-resilience.js +485 -0
  29. package/dist/geo-block.d.ts +184 -0
  30. package/dist/geo-block.js +153 -0
  31. package/dist/hashing.d.ts +2 -1
  32. package/dist/hashing.js +12 -1
  33. package/dist/http-signatures.d.ts +303 -0
  34. package/dist/http-signatures.js +782 -0
  35. package/dist/idempotency.d.ts +204 -0
  36. package/dist/idempotency.js +341 -0
  37. package/dist/index.d.ts +39 -5
  38. package/dist/index.js +19 -2
  39. package/dist/ip-reputation.d.ts +198 -0
  40. package/dist/ip-reputation.js +253 -0
  41. package/dist/jwk.d.ts +15 -0
  42. package/dist/jwk.js +24 -2
  43. package/dist/load-shedding.d.ts +5 -0
  44. package/dist/logger.js +6 -2
  45. package/dist/metrics.d.ts +208 -0
  46. package/dist/metrics.js +452 -0
  47. package/dist/middleware.js +0 -10
  48. package/dist/mtls.d.ts +266 -0
  49. package/dist/mtls.js +488 -0
  50. package/dist/multipart.js +1 -1
  51. package/dist/openapi-diff.d.ts +79 -0
  52. package/dist/openapi-diff.js +246 -0
  53. package/dist/openapi.js +4 -1
  54. package/dist/pagination.d.ts +210 -0
  55. package/dist/pagination.js +353 -0
  56. package/dist/rate-limit-redis.d.ts +8 -0
  57. package/dist/rate-limit-redis.js +8 -0
  58. package/dist/request-decompression.d.ts +200 -0
  59. package/dist/request-decompression.js +363 -0
  60. package/dist/response-cache.d.ts +205 -0
  61. package/dist/response-cache.js +374 -0
  62. package/dist/router.d.ts +22 -0
  63. package/dist/router.js +64 -7
  64. package/dist/safe-redirect.d.ts +2 -2
  65. package/dist/safe-redirect.js +3 -8
  66. package/dist/sbom.cdx.json +9 -9
  67. package/dist/sbom.spdx.json +5 -5
  68. package/dist/scheduler.d.ts +315 -0
  69. package/dist/scheduler.js +546 -0
  70. package/dist/security.d.ts +61 -7
  71. package/dist/security.js +75 -8
  72. package/dist/session.js +3 -3
  73. package/dist/types.d.ts +33 -0
  74. package/dist/waf.d.ts +213 -0
  75. package/dist/waf.js +334 -0
  76. package/dist/webhook-delivery.d.ts +263 -0
  77. package/dist/webhook-delivery.js +311 -0
  78. package/dist/websocket.d.ts +52 -0
  79. package/dist/websocket.js +13 -0
  80. package/package.json +79 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DaloyJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -9,11 +9,17 @@
9
9
 
10
10
  # DaloyJS
11
11
 
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
13
  [![CI](https://github.com/daloyjs/daloy/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/daloyjs/daloy/actions/workflows/ci.yml)
13
14
  [![CodeQL](https://github.com/daloyjs/daloy/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/daloyjs/daloy/actions/workflows/codeql.yml)
14
15
  [![Publish](https://github.com/daloyjs/daloy/actions/workflows/release.yml/badge.svg)](https://github.com/daloyjs/daloy/actions/workflows/release.yml)
15
- [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/daloyjs/daloy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
16
16
  [![Zizmor](https://github.com/daloyjs/daloy/actions/workflows/zizmor.yml/badge.svg?branch=main)](https://github.com/daloyjs/daloy/actions/workflows/zizmor.yml)
17
+ [![GitHub last commit](https://img.shields.io/github/last-commit/daloyjs/daloy)](https://github.com/daloyjs/daloy/commits/main)
18
+ [![JSR](https://jsr.io/badges/@daloyjs/daloy)](https://jsr.io/@daloyjs/daloy)
19
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13058/badge)](https://www.bestpractices.dev/projects/13058)
20
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/daloyjs/daloy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
21
+ [![Security Responsible
22
+ Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/daloyjs/daloy/blob/main/SECURITY.md)
17
23
 
18
24
  > 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**.
19
25
 
@@ -221,6 +227,10 @@ const r = await client.getBookById({ params: { id: "1" } });
221
227
  // ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }
222
228
  ```
223
229
 
230
+ > Method inference relies on **chaining** your `app.route(...)` calls (`new App().route(a).route(b)`) and letting
231
+ > TypeScript infer the variable's type. A widening `const app: App` annotation, a `: App` factory return type, or
232
+ > registering routes as separate statements erases the per-route types and collapses the client to an untyped surface.
233
+
224
234
  ---
225
235
 
226
236
  ## Built-in docs UI (Scalar / Swagger UI)
@@ -311,6 +321,7 @@ deployment.
311
321
  | **Header / response splitting** | Core header sanitizers reject CRLF + NUL. |
312
322
  | **Path traversal** | Core router rejects `..` segments and `//` before walking. |
313
323
  | **Slow-loris / hung handlers** | Core `requestTimeoutMs` aborts handlers (default 30 s); Node adapter sets `requestTimeout` + `headersTimeout` + `maxHeaderSize`. |
324
+ | **HTTP/2 Bomb / header-count flood** | Core `maxHeaderCount` rejects requests with more than 100 header fields (`431`) before routing; Node adapter sets `server.maxHeadersCount`. See [SECURITY.md](SECURITY.md) for the upstream HTTP/2 mitigations. |
314
325
  | **MIME sniffing** | First-party `secureHeaders()` sets `X-Content-Type-Options: nosniff`; scaffolded apps enable it. |
315
326
  | **Clickjacking** | First-party `secureHeaders()` sets `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`; scaffolded apps enable it. |
316
327
  | **XSS via injected scripts** | First-party `secureHeaders()` provides a strict CSP `default-src 'self'` baseline; the directives-object form supports per-request **nonces** and **Trusted Types** (`require-trusted-types-for 'script'`). |
@@ -437,6 +448,7 @@ DaloyJS is in **public preview** (`0.x`). The public API may still change betwee
437
448
  - Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
438
449
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
439
450
  - 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`.
451
+ - API lifecycle and breaking-change detection: mark routes `deprecated` or give them a `sunset` date to emit RFC 8594 `Deprecation` / `Sunset` headers and an `x-sunset` OpenAPI extension, then gate CI with `diffOpenAPI()` / the `daloy diff` command, which fail on a breaking change versus the last published spec.
440
452
  - In-process test client (`app.request()`), contract-test runner, in-process typed client, and Hey API codegen via `pnpm gen`.
441
453
 
442
454
  ### Runtimes and deployment
@@ -490,6 +502,23 @@ The framework refuses to start (or to construct) when configuration is unsafe:
490
502
  - `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.
491
503
  - `requireScopes()` with RFC-6750 `WWW-Authenticate: Bearer` challenge and per-request scope aggregation.
492
504
  - `session()` with signed cookies and pluggable stores.
505
+ - `idempotency()` with `Idempotency-Key` fingerprinting + byte-for-byte response replay, in-flight `409`, `422` on key reuse with a different payload, and a pluggable `IdempotencyStore` (in-memory default) at `@daloyjs/core/idempotency`.
506
+ - `responseCache()` server-side body cache (cache-key + TTL with `s-maxage`/`max-age` orchestration, request `no-store`/`no-cache` directives, recursion-safe stale-while-revalidate, `Vary`-aware keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` in-memory default) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie` or `private`/`no-store`/`no-cache` responses. Complements `etag()`/`compression()`, which do not cache bodies.
507
+ - `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
508
+ - `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production).
509
+ - `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
510
+ - `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
511
+ - `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
512
+ - `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
513
+ - `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator` or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
514
+ - `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
515
+ - `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
516
+ - `geoBlock()` GeoIP / geo-blocking at `@daloyjs/core/geo-block`: country allow/deny middleware that maps the client IP to an ISO 3166-1 alpha-2 country and rejects (or logs) traffic from countries you don't serve. **No bundled GeoIP database and no runtime dependency** — supply either an operator-owned `lookupCountry(ip)` (a MaxMind / `ip2location` reader, or your own table, reusing the trusted-proxy `X-Forwarded-For` / `X-Real-IP` IP resolution) or a `resolveCountry(ctx)` that reads an edge-injected header (`CF-IPCountry`, `CloudFront-Viewer-Country`, `x-vercel-ip-country`). Deny wins over allow (least privilege); **allow-lists fail closed** on an unknown country while deny-only fails open (overridable via `allowUnknownCountry`). Country codes are validated at construction so typos throw instead of silently never matching. `mode: "log"` monitor mode with an `onBlock` decision hook (`denied_country` / `not_in_allowlist` / `unknown_country`), the resolved country stamped on `ctx.state.geo` for allowed requests, and a `403` problem+json rejection that never echoes the country/IP. Zero runtime dependencies.
517
+ - `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.
518
+ - `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.
519
+ - `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.
520
+ - Built-in docs UI Subresource Integrity (SRI): `DocsAssetOptions` lets `scalarHtml()` / `swaggerUiHtml()` 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 `<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.
521
+ - 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, 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.
493
522
  - `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.
494
523
  - `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
495
524
  - `timing` / `timingSafeEqual` helpers.
@@ -507,6 +536,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
507
536
  - an Origin policy (`allowedOrigins: "same-origin"` / `string[]` / predicate) or `acknowledgeCrossOriginUpgrade: true`.
508
537
 
509
538
  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.
539
+ - Contract-first **AsyncAPI 3.0** generation for `app.ws()` surfaces via `@daloyjs/core/asyncapi` (`generateAsyncAPI()` / `asyncapiToYAML()`) and `daloy inspect --asyncapi`. Each route becomes a channel (address + path params) with a `receive` operation for inbound client messages and an optional `send` operation for outbound messages, described via an optional handler `meta` block (`summary` / `description` / `tags` / `send` / `receive` / `operationId`).
510
540
 
511
541
  ### Lifecycle and ops
512
542
 
@@ -536,9 +566,10 @@ A growing suite of static gates runs on every push and PR:
536
566
 
537
567
  - Parity / governance / runtime-parity / routing-hardening audits: `verify:parity-audits`, `verify:governance-audits`, `verify:runtime-parity-audits`, `verify:routing-hardening-audits`.
538
568
  - 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`.
539
- - Agent-skill gates: `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills`.
569
+ - Agent-skill gates: `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills` — scanning every agent-instruction surface (`SKILL.md`, `AGENTS.md`, `copilot-instructions.md`, `.cursorrules`, `CLAUDE.md`, `*.instructions.md`, `*.prompt.md`); the `.cursorrules` / `CLAUDE.md` filenames cover the **TrapDoor** crypto-stealer's AI-agent-config prompt-injection persistence ([Socket, 2026-05-24](https://socket.dev/blog/trapdoor-crypto-stealer)).
570
+ - Agent / editor config-autorun gate: `verify:no-agent-config-autorun` — refuses editor / AI-coding-agent config files that auto-execute a command on folder open or session start (VS Code `folderOpen` task, Claude/Gemini `"type": "command"` hook, Cursor `alwaysApply` run-a-script rule, a `package.json` `"test": "node .github/setup.js"` hijack, or a loose `.github/` dropper), covering the **Miasma** worm's config-injection detonation surface ([SafeDep, 2026-06-05](https://safedep.io/miasma-worm-ai-coding-agent-config-injection/)).
540
571
  - 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).
541
- - 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.
572
+ - 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, the `codexui-android` AI-coding-agent token theft (reads of `~/.codex/auth.json` / `~/.claude/`), and npm-package-aliasing dependency-confusion patterns.
542
573
  - `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.
543
574
  - 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.
544
575
  - Mandatory hardware-backed 2FA for every contributor with publish access (documented in `SECURITY.md`).
package/bin/daloy.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { pathToFileURL, fileURLToPath } from "node:url";
11
11
  import { resolve, dirname } from "node:path";
12
12
  import { existsSync, readFileSync } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
13
14
  import { spawn } from "node:child_process";
14
15
  import { runCli } from "../dist/cli.js";
15
16
 
@@ -83,6 +84,7 @@ const result = await runCli(process.argv.slice(2), {
83
84
  importEntry,
84
85
  version: PKG.version,
85
86
  spawn: spawnDev,
87
+ readTextFile: (path) => readFile(resolve(process.cwd(), path), "utf8"),
86
88
  });
87
89
 
88
90
  process.exit(result.exitCode);
@@ -15,15 +15,22 @@ export function serve(app, opts = {}) {
15
15
  return app.fetch(req);
16
16
  }
17
17
  : (req) => app.fetch(req),
18
- error: (err) => new Response(JSON.stringify({
19
- type: "https://daloyjs.dev/errors/internal",
20
- title: "Internal Server Error",
21
- status: 500,
22
- detail: err.message,
23
- }), {
24
- status: 500,
25
- headers: { "content-type": "application/problem+json" },
26
- }),
18
+ error: (err) => {
19
+ // Last-resort handler reached only if app.fetch itself throws (it
20
+ // normally catches everything). Log the error server-side but never
21
+ // echo `err.message` to the client — that would leak internal details
22
+ // and bypass the framework's prod-mode error redaction. Mirrors the
23
+ // redacted problem+json body the Node adapter emits in writeAdapterError.
24
+ app.log.error({ err }, "Unhandled error in Bun fetch handler");
25
+ return new Response(JSON.stringify({
26
+ type: "https://daloyjs.dev/errors/internal",
27
+ title: "Internal Server Error",
28
+ status: 500,
29
+ }), {
30
+ status: 500,
31
+ headers: { "content-type": "application/problem+json" },
32
+ });
33
+ },
27
34
  };
28
35
  if (hasWs)
29
36
  cfg.websocket = buildBunWebSocketConfig(app);
@@ -32,14 +32,20 @@ export function serve(app, opts = {}) {
32
32
  if (stopped)
33
33
  return;
34
34
  stopped = true;
35
- controller.abort();
36
35
  opts.signal?.removeEventListener("abort", onSignal);
37
36
  if (opts.handleSignals !== false && typeof D?.removeSignalListener === "function") {
38
37
  D.removeSignalListener("SIGTERM", onSignal);
39
38
  D.removeSignalListener("SIGINT", onSignal);
40
39
  }
40
+ // Drain app-level hooks first (while the HTTP server can still respond),
41
+ // then ask Deno to stop gracefully — `server.shutdown()` stops accepting
42
+ // new connections and lets in-flight requests finish. Aborting the listen
43
+ // signal happens last, purely as a safety net for runtimes that lack
44
+ // `HttpServer.shutdown()`. Doing it the other way around (aborting first)
45
+ // tears the listener down before in-flight requests can complete.
41
46
  await app.shutdown(opts.shutdownTimeoutMs ?? 10_000);
42
47
  await server.shutdown?.();
48
+ controller.abort();
43
49
  };
44
50
  return { shutdown };
45
51
  }
@@ -16,6 +16,31 @@ export interface NodeServerOptions {
16
16
  handleSignals?: boolean;
17
17
  /** Maximum HTTP header size bytes (DoS protection). Default: 16 KiB. */
18
18
  maxHeaderBytes?: number;
19
+ /**
20
+ * Maximum number of incoming HTTP header fields, forwarded to Node's
21
+ * `server.maxHeadersCount`. This is the native, parser-level counterpart to
22
+ * the framework's portable {@link "../app.js".AppOptions.maxHeaderCount}
23
+ * guard: a header-count flood is dropped by the HTTP parser before it ever
24
+ * becomes a `Request`, which is the cheapest place to shed header-count
25
+ * amplification (the dimension abused by the "HTTP/2 Bomb"). Node's own
26
+ * default is `2000`; this adapter tightens it to `100` to mirror the
27
+ * application-tier cap. Set `0` to disable (use Node's unbounded default).
28
+ * Default: 100.
29
+ *
30
+ * @since 0.38.0
31
+ */
32
+ maxHeaderCount?: number;
33
+ /**
34
+ * Maximum number of concurrent sockets the server will accept, forwarded to
35
+ * Node's `server.maxConnections`. Acts as connection-layer admission
36
+ * control: once the limit is reached, additional incoming connections are
37
+ * rejected at accept time instead of being queued into the event loop,
38
+ * where they would otherwise inflate tail latency for everyone under
39
+ * overload. Pair it with an upstream load balancer / API gateway that
40
+ * translates the rejection into a `503 Retry-After` for clients. Leave
41
+ * unset for Node's default (unbounded). Default: unset.
42
+ */
43
+ maxConnections?: number;
19
44
  /**
20
45
  * When true, honor `x-forwarded-proto` and `x-forwarded-host` headers when
21
46
  * constructing the request URL. Enable this only when running behind a
@@ -5,6 +5,7 @@
5
5
  import { createServer, } from "node:http";
6
6
  import { Readable } from "node:stream";
7
7
  import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY } from "../app.js";
8
+ import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
8
9
  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";
9
10
  /** Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}. */
10
11
  export function serve(app, opts = {}) {
@@ -35,6 +36,19 @@ export function serve(app, opts = {}) {
35
36
  server.requestTimeout = opts.connectionTimeoutMs ?? 30_000;
36
37
  server.headersTimeout = opts.connectionTimeoutMs ?? 30_000;
37
38
  server.keepAliveTimeout = 5_000;
39
+ // Native parser-level header-count cap. Drops header-count floods (the
40
+ // "HTTP/2 Bomb" amplification dimension) before they become a Request.
41
+ // `0` opts out and restores Node's unbounded-ish default (2000).
42
+ const maxHeaderCount = opts.maxHeaderCount;
43
+ server.maxHeadersCount =
44
+ typeof maxHeaderCount === "number" && maxHeaderCount >= 0
45
+ ? maxHeaderCount
46
+ : 100;
47
+ // Connection-layer admission control. Reject overflow sockets at accept time
48
+ // rather than queuing them into the event loop under overload.
49
+ if (typeof opts.maxConnections === "number" && opts.maxConnections > 0) {
50
+ server.maxConnections = opts.maxConnections;
51
+ }
38
52
  const wsSockets = new Set();
39
53
  if (app.webSocketRoutes.size > 0) {
40
54
  server.on("upgrade", (req, socket, head) => {
@@ -90,6 +104,7 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
90
104
  writeAdapterError(res, e);
91
105
  return;
92
106
  }
107
+ attachClientCertificate(req, request);
93
108
  const responseOrPromise = app.fetch(request);
94
109
  if (responseOrPromise instanceof Promise) {
95
110
  responseOrPromise.then((response) => {
@@ -175,6 +190,23 @@ function bufferRequestBody(req, expected) {
175
190
  req.on("aborted", onErr);
176
191
  });
177
192
  }
193
+ /**
194
+ * Attach the TLS client certificate (if any) to the web `Request` so
195
+ * `clientCertAuth()` can enforce a mutual-TLS identity. The read is deferred
196
+ * behind a lazy thunk and only happens when a guarded route actually inspects
197
+ * the certificate, so plain-HTTP and ordinary TLS requests pay nothing beyond a
198
+ * single `encrypted` boolean check. Only runs when the peer socket is a
199
+ * `TLSSocket` exposing `getPeerCertificate`.
200
+ */
201
+ function attachClientCertificate(req, request) {
202
+ const sock = req.socket;
203
+ if (!sock.encrypted || typeof sock.getPeerCertificate !== "function")
204
+ return;
205
+ setClientCertificate(request, () => {
206
+ const raw = sock.getPeerCertificate(true);
207
+ return normalizePeerCertificate(raw, sock.authorized === true);
208
+ });
209
+ }
178
210
  function writeAdapterError(res, e) {
179
211
  if (!res.headersSent) {
180
212
  res.statusCode = 500;
package/dist/app.d.ts CHANGED
@@ -2,9 +2,11 @@ import { WebSocketRegistry, type WebSocketHandler } from "./websocket.js";
2
2
  import { type Logger } from "./logger.js";
3
3
  import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
4
4
  import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
5
- import { type DocsContentSecurityPolicyOptions, type ScalarReferenceConfiguration } from "./docs.js";
5
+ import { type DocsAssetOptions, type DocsContentSecurityPolicyOptions, type ScalarReferenceConfiguration } from "./docs.js";
6
6
  import { type SecureHeadersOptions } from "./middleware.js";
7
7
  import { type LoadSheddingOptions } from "./load-shedding.js";
8
+ import { MetricsRegistry, type HttpMetricsOptions } from "./metrics.js";
9
+ import { Scheduler, type TaskDefinition, type TaskHandler } from "./scheduler.js";
8
10
  import { type BehindProxyConfig } from "./conn-info.js";
9
11
  /** @internal Test-only helper to reset the latch between tests. */
10
12
  export declare function _resetCrashHandlersForTests(): void;
@@ -73,6 +75,19 @@ export interface AppOptions {
73
75
  allowedContentTypes?: string[];
74
76
  /** Per-request timeout in ms (handler + hooks). Default: 30000. Set 0 to disable. */
75
77
  requestTimeoutMs?: number;
78
+ /**
79
+ * Maximum number of distinct request header fields accepted before the
80
+ * request is rejected with `431 Request Header Fields Too Large`. This is
81
+ * the runtime-portable, application-tier defence against header-*count*
82
+ * amplification (the dimension abused by the "HTTP/2 Bomb", where
83
+ * per-header server-side bookkeeping — not header size — is the
84
+ * amplifier). It complements the native header-count caps a runtime/proxy
85
+ * terminating HTTP/2 must apply (NGINX `max_headers`, Node
86
+ * `server.maxHeadersCount`). Set `0` to disable. Default: 100.
87
+ *
88
+ * @since 0.38.0
89
+ */
90
+ maxHeaderCount?: number;
76
91
  /**
77
92
  * Per-request limits applied when parsing `multipart/form-data` bodies.
78
93
  * These run in addition to `bodyLimitBytes`. Use them to cap the size of
@@ -359,6 +374,15 @@ export interface DocsRouteOptions {
359
374
  * Forwarded to {@link docsContentSecurityPolicy}.
360
375
  */
361
376
  csp?: DocsContentSecurityPolicyOptions;
377
+ /**
378
+ * Override the docs UI asset URLs and pin Subresource Integrity (SRI)
379
+ * hashes so the browser refuses to execute a CDN asset whose bytes don't
380
+ * match. Pair each `*Integrity` hash with a version-pinned `*Url`, or point
381
+ * the URLs at self-hosted copies. See {@link DocsAssetOptions}.
382
+ *
383
+ * @since 0.37.0
384
+ */
385
+ assets?: DocsAssetOptions;
362
386
  }
363
387
  /** Information passed to {@link App.onPluginInstalled} listeners. */
364
388
  export interface PluginInstalledEvent {
@@ -434,6 +458,68 @@ export interface HealthRouteOptions {
434
458
  */
435
459
  acknowledgeUnauthenticated?: boolean;
436
460
  }
461
+ /**
462
+ * Configuration accepted by {@link App.metrics}. Every field is optional.
463
+ *
464
+ * The `/metrics` route inherits the same hardened posture as
465
+ * {@link App.healthcheck}: an optional `Authorization: Bearer <token>`
466
+ * compared via {@link timingSafeEqual}, a per-IP fixed-window rate limit,
467
+ * and a refuse-to-boot guard that blocks an unauthenticated scrape endpoint
468
+ * in production (metrics leak internal route names, latency, and request
469
+ * volume) unless a token is set or {@link MetricsRouteOptions.acknowledgeUnauthenticated}
470
+ * is `true`.
471
+ *
472
+ * @since 0.37.0
473
+ */
474
+ export interface MetricsRouteOptions {
475
+ /** Override the default path (`/metrics`). */
476
+ path?: PathString;
477
+ /**
478
+ * Require `Authorization: Bearer <token>` on the scrape request, compared
479
+ * via {@link timingSafeEqual}. When set in production with
480
+ * `secureDefaults: true`, no further opt-in is required.
481
+ */
482
+ token?: string;
483
+ /**
484
+ * Per-IP fixed-window rate limit. Defaults to `{ limit: 60, windowMs:
485
+ * 60_000 }` (in-memory, per-process). Pass `false` to disable.
486
+ */
487
+ rateLimit?: {
488
+ limit?: number;
489
+ windowMs?: number;
490
+ } | false;
491
+ /**
492
+ * Acknowledge that the scrape endpoint is intentionally reachable without
493
+ * credentials in production. Required when `secureDefaults` is on and
494
+ * `token` is omitted; otherwise registration throws.
495
+ */
496
+ acknowledgeUnauthenticated?: boolean;
497
+ /**
498
+ * Registry the RED metrics are recorded into and rendered from. Defaults
499
+ * to a fresh {@link MetricsRegistry}. Pass your own to register custom
500
+ * application metrics alongside the built-in HTTP series.
501
+ */
502
+ registry?: MetricsRegistry;
503
+ /**
504
+ * Resolve the low-cardinality `route` label. Strongly recommended: return
505
+ * the route template (e.g. `/books/:id`) instead of the raw path.
506
+ * Forwarded to {@link httpMetrics}.
507
+ */
508
+ route?: HttpMetricsOptions["route"];
509
+ /**
510
+ * Maximum distinct values for the default pathname-derived `route` label
511
+ * before further values collapse to `"<other>"`. Forwarded to
512
+ * {@link httpMetrics}. Default `100`.
513
+ */
514
+ maxRouteCardinality?: number;
515
+ /** Latency histogram buckets, in seconds. Forwarded to {@link httpMetrics}. */
516
+ buckets?: readonly number[];
517
+ /**
518
+ * Skip RED instrumentation for matching request paths, in addition to the
519
+ * scrape path itself (always excluded). Forwarded to {@link httpMetrics}.
520
+ */
521
+ exclude?: (path: string) => boolean;
522
+ }
437
523
  /**
438
524
  * Configuration accepted by {@link App.cspReportRoute}. Every field is
439
525
  * optional.
@@ -590,13 +676,37 @@ export declare const DALOY_RAW_STREAM: unique symbol;
590
676
  * serve(app, { port: 3000 });
591
677
  * ```
592
678
  *
679
+ /**
680
+ * Append a freshly-registered route to an `App`'s accumulated route tuple.
681
+ *
682
+ * A newly-constructed `App` starts with the permissive default
683
+ * `readonly RouteDefinition<any, any, any, any>[]` so that bare `App`
684
+ * annotations (e.g. `serve(app: App)`) accept any instance. The first
685
+ * {@link App.route} call resets that wide default to a clean single-element
686
+ * tuple, so the typed client (`createClient(app)`) is keyed only by the
687
+ * routes the caller actually registered; subsequent calls append precisely.
688
+ *
689
+ * @typeParam Routes - The current accumulated route tuple.
690
+ * @typeParam R - The route definition being registered.
691
+ */
692
+ type AppendRoute<Routes extends readonly RouteDefinition<any, any, any, any>[], R extends RouteDefinition<any, any, any, any>> = readonly RouteDefinition<any, any, any, any>[] extends Routes ? readonly [R] : readonly [...Routes, R];
693
+ /**
593
694
  * @since 0.1.0
594
695
  */
595
- export declare class App {
696
+ export declare class App<Routes extends readonly RouteDefinition<any, any, any, any>[] = readonly RouteDefinition<any, any, any, any>[]> {
596
697
  readonly options: Required<Pick<AppOptions, "validateResponses" | "bodyLimitBytes" | "requestTimeoutMs">> & AppOptions;
597
698
  readonly log: Logger;
598
- /** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
599
- readonly routes: RouteDefinition<any, any, any, any>[];
699
+ /**
700
+ * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
701
+ *
702
+ * Statically the property is typed as the `Routes` tuple so that
703
+ * {@link App.route} can accumulate each registered route's literal
704
+ * `operationId`, request, and response types. The typed client
705
+ * (`createClient(app)`) reads this tuple to derive a precisely-typed method
706
+ * per route. At runtime it is an ordinary growable array — the tuple typing
707
+ * is a compile-time view only.
708
+ */
709
+ readonly routes: Routes;
600
710
  private router;
601
711
  /**
602
712
  * Memoized result of `isProduction()`. The inputs (`options.env`,
@@ -626,6 +736,12 @@ export declare class App {
626
736
  private installedPlugins;
627
737
  private closeHooks;
628
738
  private closeHooksRun;
739
+ /**
740
+ * Lazily-created in-process scheduler backing {@link App.cron}. Started on
741
+ * the first `cron()` call and stopped from an `onClose` hook so its lifecycle
742
+ * is tied to graceful shutdown.
743
+ */
744
+ private scheduler?;
629
745
  /** Idle-connection close hooks (adapter-registered, sync). */
630
746
  private idleConnectionCloseHooks;
631
747
  private pluginInstalledListeners;
@@ -718,6 +834,7 @@ export declare class App {
718
834
  trustProxy: true | false | "unconfigured";
719
835
  bodyLimitBytes: number;
720
836
  requestTimeoutMs: number;
837
+ maxHeaderCount: number;
721
838
  stripServerHeaders: boolean;
722
839
  production: boolean;
723
840
  };
@@ -842,10 +959,22 @@ export declare class App {
842
959
  * });
843
960
  * ```
844
961
  *
962
+ * The return type widens `Routes` with the freshly-registered route so
963
+ * that chained registration (`new App().route(a).route(b)`) accumulates a
964
+ * precise tuple. The typed client (`createClient(app)`) consumes that tuple
965
+ * to expose a method per `operationId` with parameters and responses
966
+ * inferred from the route's own schemas. Non-chained calls
967
+ * (`app.route(a); app.route(b);`) keep the variable's original type, so
968
+ * chain the calls when you want the inferred client surface.
969
+ *
845
970
  * @param def - The route definition.
846
- * @returns This `App` instance for chaining.
971
+ * @returns This `App` instance (widened with the new route) for chaining.
847
972
  */
848
- route<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap>(def: RouteDefinition<P, M, Req, Res>): this;
973
+ route<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(def: RouteDefinition<P, M, Req, Res> & {
974
+ operationId?: Op;
975
+ }): App<AppendRoute<Routes, RouteDefinition<P, M, Req, Res> & {
976
+ operationId: Op;
977
+ }>>;
849
978
  /**
850
979
  * Register a WebSocket route. The handler runs when an HTTP client sends an
851
980
  * `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
@@ -888,6 +1017,70 @@ export declare class App {
888
1017
  * @since 0.18.0
889
1018
  */
890
1019
  readinesscheck(opts?: HealthRouteOptions): this;
1020
+ /**
1021
+ * Register an opt-in, auth-guarded Prometheus / OpenMetrics scrape route
1022
+ * and install RED (Rate / Errors / Duration) instrumentation for every
1023
+ * route registered **after** this call. The third observability pillar
1024
+ * alongside the structured logger and the OpenTelemetry tracer.
1025
+ *
1026
+ * Exposes, in the Prometheus text exposition format:
1027
+ * - `<prefix>http_requests_total{method,route,status}` — request counter,
1028
+ * - `<prefix>http_request_duration_seconds{method,route}` — latency histogram,
1029
+ * - `<prefix>http_requests_in_flight` — concurrency gauge,
1030
+ * - process gauges (resident memory, heap used, uptime) on Node-like runtimes.
1031
+ *
1032
+ * The scrape route inherits the same hardened posture as
1033
+ * {@link App.healthcheck}: optional bearer token compared via
1034
+ * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
1035
+ * refuse-to-boot guard in production (an unauthenticated `/metrics`
1036
+ * endpoint leaks internal route names, latency, and traffic volume) unless
1037
+ * a token is supplied or `acknowledgeUnauthenticated: true` is passed.
1038
+ *
1039
+ * Call this **before** registering the routes you want measured — like any
1040
+ * `app.use(...)` middleware, the instrumentation only wraps routes added
1041
+ * afterwards. Pass `opts.registry` to register custom application metrics
1042
+ * that are rendered alongside the built-in HTTP series.
1043
+ *
1044
+ * @param opts - Path, auth, rate-limit, registry, and label configuration.
1045
+ * @returns `this` for chaining.
1046
+ * @since 0.37.0
1047
+ */
1048
+ metrics(opts?: MetricsRouteOptions): this;
1049
+ /**
1050
+ * Register an in-process scheduled task (cron). The first call lazily creates
1051
+ * an app-managed {@link Scheduler}, wires it to the app logger, starts it,
1052
+ * and registers an `onClose` hook so it is drained on graceful shutdown
1053
+ * (in-flight runs are awaited, then aborted if they outlast the shutdown
1054
+ * grace period).
1055
+ *
1056
+ * The schedule is **queue-agnostic** — it runs work in *this* process on a
1057
+ * fixed interval or cron expression. Use it for periodic maintenance
1058
+ * (cache sweeps, token refresh, reconciliation) rather than as a distributed
1059
+ * job queue. Each task is **single-flight**: if a tick fires while the
1060
+ * previous run is still in progress, the tick is skipped and counted, so a
1061
+ * slow task can never pile up overlapping runs.
1062
+ *
1063
+ * @example
1064
+ * ```ts
1065
+ * app.cron({ name: "sweep", cron: "0 * * * *" }, async ({ signal }) => {
1066
+ * await purgeExpiredSessions({ signal });
1067
+ * });
1068
+ * ```
1069
+ *
1070
+ * @param def - The task definition. Exactly one of `intervalMs` or `cron`.
1071
+ * @param handler - The function to run on each tick.
1072
+ * @returns This `App` instance for chaining.
1073
+ * @throws {RangeError} on invalid options (see {@link Scheduler.define}).
1074
+ * @throws {@link CronParseError} if a `cron` expression is malformed.
1075
+ */
1076
+ cron(def: TaskDefinition, handler: TaskHandler): this;
1077
+ /**
1078
+ * The app-managed {@link Scheduler} backing {@link App.cron}, or `undefined`
1079
+ * if no scheduled task has been registered. Exposed for inspection
1080
+ * (`getState()` / `list()`) and manual triggering (`runNow()`); the lifecycle
1081
+ * is owned by the app.
1082
+ */
1083
+ get scheduledTasks(): Scheduler | undefined;
891
1084
  private registerHealthRoute;
892
1085
  /**
893
1086
  * Register a built-in receiver for CSP / Reporting API
@@ -1189,3 +1382,4 @@ export declare function createApp(options?: AppOptions): App;
1189
1382
  * @internal
1190
1383
  */
1191
1384
  export declare function _resetPackageJsonCacheForTests(): void;
1385
+ export {};