@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.
- package/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +57 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +39 -5
- package/dist/index.js +19 -2
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +61 -7
- package/dist/security.js +75 -8
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- 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)
|
|
12
13
|
[](https://github.com/daloyjs/daloy/actions/workflows/ci.yml)
|
|
13
14
|
[](https://github.com/daloyjs/daloy/actions/workflows/codeql.yml)
|
|
14
15
|
[](https://github.com/daloyjs/daloy/actions/workflows/release.yml)
|
|
15
|
-
[](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
|
|
16
16
|
[](https://github.com/daloyjs/daloy/actions/workflows/zizmor.yml)
|
|
17
|
+
[](https://github.com/daloyjs/daloy/commits/main)
|
|
18
|
+
[](https://jsr.io/@daloyjs/daloy)
|
|
19
|
+
[](https://www.bestpractices.dev/projects/13058)
|
|
20
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
|
|
21
|
+
[](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);
|
package/dist/adapters/bun.js
CHANGED
|
@@ -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) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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);
|
package/dist/adapters/deno.js
CHANGED
|
@@ -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
|
}
|
package/dist/adapters/node.d.ts
CHANGED
|
@@ -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
|
package/dist/adapters/node.js
CHANGED
|
@@ -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
|
-
/**
|
|
599
|
-
|
|
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>
|
|
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 {};
|