@daloyjs/core 1.0.0-beta.7 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/adapters/node.js +216 -11
- package/dist/app.d.ts +32 -8
- package/dist/app.js +418 -67
- package/dist/http-signatures.js +44 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/ip-reputation.d.ts +8 -2
- package/dist/ip-reputation.js +7 -1
- package/dist/jwk.js +6 -1
- package/dist/mcp.d.ts +80 -9
- package/dist/mcp.js +206 -4
- package/dist/middleware.d.ts +38 -0
- package/dist/middleware.js +45 -4
- package/dist/mtls.js +6 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -509,7 +509,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
|
|
|
509
509
|
|
|
510
510
|
## Status
|
|
511
511
|
|
|
512
|
-
DaloyJS is
|
|
512
|
+
DaloyJS is at **`1.0.0-rc.1`**, a security-hardening release candidate. Because the framework has no external users yet, this RC ships a few intentional breaking changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before GA rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` GA onward, breaking changes follow SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
|
|
513
513
|
|
|
514
514
|
**Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
|
|
515
515
|
|
|
@@ -520,7 +520,7 @@ DaloyJS is now in the **`1.0.0` beta** (`1.0.0-beta.7`). The public API is featu
|
|
|
520
520
|
- Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
|
|
521
521
|
- RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
|
|
522
522
|
- 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`.
|
|
523
|
-
- Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app.
|
|
523
|
+
- Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
524
524
|
- 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.
|
|
525
525
|
- In-process test client (`app.request()`), contract-test runner (gated in CI via `daloy inspect --check` and shipped as a default test in every `create-daloy` template), in-process typed client, and Hey API codegen via `pnpm gen`.
|
|
526
526
|
|
|
@@ -549,7 +549,9 @@ DaloyJS is now in the **`1.0.0` beta** (`1.0.0-beta.7`). The public API is featu
|
|
|
549
549
|
|
|
550
550
|
The framework refuses to start (or to construct) when configuration is unsafe:
|
|
551
551
|
|
|
552
|
-
- Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded
|
|
552
|
+
- Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured forwarded / client-IP headers (`X-Forwarded-*`, `X-Real-IP`, and vendor headers `CF-Connecting-IP` / `Fly-Client-IP` / `True-Client-IP`) in production.
|
|
553
|
+
- **Shadow auth**: a route that declares an `auth:` requirement (advertised as protected in the OpenAPI `security` list) but installs no authentication hook to enforce it. Built-in auth middlewares (`bearerAuth` / `basicAuth` / `jwk` / `httpSignatureAuth` / `clientCertAuth`) satisfy the guard automatically; mark a custom auth hook (or upstream-gateway-enforced auth) with `markAuthHook()`.
|
|
554
|
+
- **Unauthenticated MCP**: an `mcpRoutes()` endpoint with no auth hook — MCP tools are model-controlled and side-effecting. Opt out for a genuinely public server with `mcpRoutes(path, handler, { public: true })`.
|
|
553
555
|
- `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
|
|
554
556
|
- `preset: "internal-service"` topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (auto `secureHeaders`, `corsCrossOriginGuard`, `csrf` boot guard, unconfigured `X-Forwarded-*` guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot under `event: "security.preset.applied"`, and the live posture is auditable via `app.getSecurityPosture()`.
|
|
555
557
|
- `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, and refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2).
|
|
@@ -587,13 +589,13 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
587
589
|
- `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.
|
|
588
590
|
- `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.
|
|
589
591
|
- `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.
|
|
590
|
-
- `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.
|
|
592
|
+
- `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; **SSRF-hardened by default** — the outbound fetch runs through `fetchGuard()`, so a compromised feed host can't redirect it into cloud-metadata / internal space; override via `fetchImpl`) 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.
|
|
591
593
|
- `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.
|
|
592
594
|
- `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.
|
|
593
595
|
- `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.
|
|
594
596
|
- `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.
|
|
595
597
|
- Built-in docs UI Subresource Integrity (SRI): `DocsAssetOptions` lets `scalarHtml()` / `swaggerUiHtml()` / `redocHtml()` and the `docs: { assets }` auto-mount pin version-exact `*Integrity` hashes (`sha256`/`sha384`/`sha512`) plus a `crossOrigin` value (default `"anonymous"`) on the CDN-loaded Scalar / Swagger UI / Redoc `<script>` / `<link>` tags, so a poisoned jsDelivr asset can't execute. Malformed SRI values throw a `TypeError` at startup (browsers ignore unparseable `integrity`, so failing loud avoids a false sense of protection); self-hosting the assets via the same `assets` URLs stays supported. Zero runtime dependencies.
|
|
596
|
-
- 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.
|
|
598
|
+
- HTTP Message Signatures (RFC 9421) at `@daloyjs/core/http-signatures`: first-party sign/verify for server-to-server request authentication via the standard `Signature` / `Signature-Input` headers — complements the inbound-only webhook HMAC and `clientCertAuth()` mTLS. `signMessage()` / `signRequest()` build an RFC 9421 signature base over derived components (`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@query-param`, `@status`) and HTTP fields with Structured-Fields header serialization; `verifyMessage()` / `verifyRequest()` and the `httpSignatureAuth()` middleware check them. Algorithms `hmac-sha256` / `ed25519` / `ecdsa-p256-sha256` / `ecdsa-p384-sha384` / `rsa-pss-sha512` / `rsa-v1_5-sha256` via WebCrypto (no `node:` imports). Secure-by-default verify: a **mandatory `algorithms` allowlist** (no implicit "any alg"), optional per-key alg pinning to defeat algorithm-confusion, a required `created` timestamp with a 300s freshness window, `created`-in-future / `expires` skew rejection, configurable `requiredComponents`, a 32-byte raw-HMAC floor, a 2048-bit RSA modulus floor (NIST SP 800-131A, parity with the JWT verifier), and `nonce` replay defense; the middleware answers a missing/invalid signature with `401` + `Cache-Control: no-store` and stamps the verified result on `ctx.state.httpSignature`. Ships RFC 9530 `contentDigest()` / `verifyContentDigest()` to bind the request body. Zero runtime dependencies.
|
|
597
599
|
- `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.
|
|
598
600
|
- `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
|
|
599
601
|
- `timing` / `timingSafeEqual` helpers.
|
package/dist/adapters/node.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer, } from "node:http";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
|
-
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY } from "../app.js";
|
|
7
|
+
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, } from "../app.js";
|
|
8
8
|
import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
|
|
9
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";
|
|
10
10
|
/**
|
|
@@ -291,6 +291,209 @@ function writeAdapterError(res, e) {
|
|
|
291
291
|
res.destroy(e);
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Lazily-materializing stand-in for an incoming WHATWG `Request`.
|
|
296
|
+
*
|
|
297
|
+
* Constructing a real (undici) `Request` costs ~4µs for GET and far more for
|
|
298
|
+
* POST-with-body (the constructor wraps the body bytes in a WHATWG
|
|
299
|
+
* `ReadableStream` that DaloyJS then bypasses anyway via the
|
|
300
|
+
* `DALOY_REQUEST_RAW_BODY` fast path). The dispatch hot path only ever reads
|
|
301
|
+
* `url`, `method`, `headers`, `signal`, and the raw-body symbol — so this
|
|
302
|
+
* shell carries those directly and defers the real `Request` until one of the
|
|
303
|
+
* less common surfaces (`json()`, `clone()`, `body`, `formData()`, …) is
|
|
304
|
+
* actually touched.
|
|
305
|
+
*
|
|
306
|
+
* Fidelity notes:
|
|
307
|
+
* - `instanceof Request` holds (prototype chain is re-rooted onto
|
|
308
|
+
* `Request.prototype`), and every WHATWG method/getter is overridden here,
|
|
309
|
+
* so nothing hits undici's brand-checked prototype accessors.
|
|
310
|
+
* - `signal` is an inert per-instance `AbortSignal`. This matches the real
|
|
311
|
+
* adapter behaviour today: the Node adapter never wires socket aborts into
|
|
312
|
+
* the request signal, so the signal never fires in either implementation.
|
|
313
|
+
* - Passing this object directly to `fetch()` is not supported (undici
|
|
314
|
+
* brand-checks its input) — forward with `request.clone()` instead, which
|
|
315
|
+
* returns a real `Request`. This mirrors @hono/node-server's shim.
|
|
316
|
+
*
|
|
317
|
+
* Security parity: the `Headers` instance is built eagerly from `rawHeaders`
|
|
318
|
+
* exactly as before, so the duplicate-singleton / reserved-header /
|
|
319
|
+
* header-count guards in `App.dispatch` see the identical view they saw with
|
|
320
|
+
* a real `Request`.
|
|
321
|
+
*/
|
|
322
|
+
/** Shared decoder for LightRequest's direct body reads. */
|
|
323
|
+
const LIGHT_TEXT_DECODER = new TextDecoder();
|
|
324
|
+
class LightRequest {
|
|
325
|
+
#url;
|
|
326
|
+
#method;
|
|
327
|
+
#headers;
|
|
328
|
+
#bodyBytes;
|
|
329
|
+
#real;
|
|
330
|
+
#signal;
|
|
331
|
+
constructor(url, method, headers, bodyBytes) {
|
|
332
|
+
this.#url = url;
|
|
333
|
+
this.#method = method;
|
|
334
|
+
this.#headers = headers;
|
|
335
|
+
this.#bodyBytes = bodyBytes;
|
|
336
|
+
}
|
|
337
|
+
/** Build (once) and return the real undici `Request` for rare surfaces. */
|
|
338
|
+
#materialize() {
|
|
339
|
+
return (this.#real ??=
|
|
340
|
+
this.#bodyBytes !== undefined
|
|
341
|
+
? new Request(this.#url, {
|
|
342
|
+
method: this.#method,
|
|
343
|
+
headers: this.#headers,
|
|
344
|
+
body: this.#bodyBytes,
|
|
345
|
+
})
|
|
346
|
+
: new Request(this.#url, { method: this.#method, headers: this.#headers }));
|
|
347
|
+
}
|
|
348
|
+
get url() {
|
|
349
|
+
return this.#url;
|
|
350
|
+
}
|
|
351
|
+
get method() {
|
|
352
|
+
return this.#method;
|
|
353
|
+
}
|
|
354
|
+
get headers() {
|
|
355
|
+
return this.#headers;
|
|
356
|
+
}
|
|
357
|
+
get signal() {
|
|
358
|
+
// Inert, lazily created: the Node adapter has never wired socket
|
|
359
|
+
// teardown into the request signal, so a never-firing signal is
|
|
360
|
+
// behaviourally identical to the one a real `Request` would carry.
|
|
361
|
+
return (this.#signal ??= new AbortController().signal);
|
|
362
|
+
}
|
|
363
|
+
get body() {
|
|
364
|
+
return this.#materialize().body;
|
|
365
|
+
}
|
|
366
|
+
get bodyUsed() {
|
|
367
|
+
if (this.#real !== undefined)
|
|
368
|
+
return this.#real.bodyUsed;
|
|
369
|
+
return this.#directlyConsumed;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Set when a body method served the pre-buffered bytes directly (no real
|
|
373
|
+
* `Request` ever existed). Subsequent body reads reject with a `TypeError`
|
|
374
|
+
* exactly like a consumed WHATWG body would.
|
|
375
|
+
*/
|
|
376
|
+
#directlyConsumed = false;
|
|
377
|
+
/**
|
|
378
|
+
* Serve a body read straight from the pre-buffered bytes when possible.
|
|
379
|
+
* Returns `undefined` when the caller must delegate to the materialized
|
|
380
|
+
* real `Request` (no buffered bytes, or a real `Request` already owns the
|
|
381
|
+
* body state). Enforces single-read semantics via {@link #directlyConsumed}.
|
|
382
|
+
*/
|
|
383
|
+
#consumeBytes() {
|
|
384
|
+
if (this.#real !== undefined || this.#bodyBytes === undefined)
|
|
385
|
+
return undefined;
|
|
386
|
+
if (this.#directlyConsumed) {
|
|
387
|
+
throw new TypeError("Body is unusable: Body has already been read");
|
|
388
|
+
}
|
|
389
|
+
this.#directlyConsumed = true;
|
|
390
|
+
return this.#bodyBytes;
|
|
391
|
+
}
|
|
392
|
+
// Spec-constant getters: these are exactly the values undici assigns to a
|
|
393
|
+
// server-side `new Request(url, { method, headers, body })`, hardcoded so
|
|
394
|
+
// reading them does not force materialization.
|
|
395
|
+
get cache() {
|
|
396
|
+
return "default";
|
|
397
|
+
}
|
|
398
|
+
get credentials() {
|
|
399
|
+
return "same-origin";
|
|
400
|
+
}
|
|
401
|
+
get destination() {
|
|
402
|
+
return "";
|
|
403
|
+
}
|
|
404
|
+
get integrity() {
|
|
405
|
+
return "";
|
|
406
|
+
}
|
|
407
|
+
get keepalive() {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
get mode() {
|
|
411
|
+
return "cors";
|
|
412
|
+
}
|
|
413
|
+
get redirect() {
|
|
414
|
+
return "follow";
|
|
415
|
+
}
|
|
416
|
+
get referrer() {
|
|
417
|
+
return "about:client";
|
|
418
|
+
}
|
|
419
|
+
get referrerPolicy() {
|
|
420
|
+
return "";
|
|
421
|
+
}
|
|
422
|
+
arrayBuffer() {
|
|
423
|
+
try {
|
|
424
|
+
const bytes = this.#consumeBytes();
|
|
425
|
+
if (bytes === undefined)
|
|
426
|
+
return this.#materialize().arrayBuffer();
|
|
427
|
+
// Copy: the underlying buffer is also the framework's raw-body cache.
|
|
428
|
+
return Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
return Promise.reject(e);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
blob() {
|
|
435
|
+
return this.#materialize().blob();
|
|
436
|
+
}
|
|
437
|
+
bytes() {
|
|
438
|
+
try {
|
|
439
|
+
const bytes = this.#consumeBytes();
|
|
440
|
+
if (bytes === undefined)
|
|
441
|
+
return this.#materialize().bytes();
|
|
442
|
+
return Promise.resolve(new Uint8Array(bytes));
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
return Promise.reject(e);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
formData() {
|
|
449
|
+
return this.#materialize().formData();
|
|
450
|
+
}
|
|
451
|
+
json() {
|
|
452
|
+
try {
|
|
453
|
+
const bytes = this.#consumeBytes();
|
|
454
|
+
if (bytes === undefined)
|
|
455
|
+
return this.#materialize().json();
|
|
456
|
+
// JSON.parse (not the framework's safeJsonParse): request.json() is the
|
|
457
|
+
// raw WHATWG surface, and its error semantics (SyntaxError rejection)
|
|
458
|
+
// must match a real Request exactly. Framework-parsed bodies go through
|
|
459
|
+
// readBody()/safeJsonParse and never hit this method.
|
|
460
|
+
return Promise.resolve(JSON.parse(LIGHT_TEXT_DECODER.decode(bytes)));
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
return Promise.reject(e);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
text() {
|
|
467
|
+
try {
|
|
468
|
+
const bytes = this.#consumeBytes();
|
|
469
|
+
if (bytes === undefined)
|
|
470
|
+
return this.#materialize().text();
|
|
471
|
+
return Promise.resolve(LIGHT_TEXT_DECODER.decode(bytes));
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
return Promise.reject(e);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Returns a real, fully-branded `Request` clone — safe to pass to `fetch()`.
|
|
479
|
+
* Throws a `TypeError` if the body has already been read, per spec.
|
|
480
|
+
*/
|
|
481
|
+
clone() {
|
|
482
|
+
if (this.#directlyConsumed) {
|
|
483
|
+
throw new TypeError("Request body is already used");
|
|
484
|
+
}
|
|
485
|
+
return this.#materialize().clone();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// `instanceof Request` must hold for handler code and the framework's own
|
|
489
|
+
// checks. Every own getter/method above shadows the brand-checked undici
|
|
490
|
+
// accessors, so the re-rooted chain is only consulted for identity.
|
|
491
|
+
Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
|
|
492
|
+
// This adapter consumes responses via status/headers/DALOY_RAW_BODY only
|
|
493
|
+
// (see sendWebResponse), so serializeResult may skip the undici Response
|
|
494
|
+
// construction for requests dispatched through this shim. Set once on the
|
|
495
|
+
// prototype: zero per-request cost.
|
|
496
|
+
LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
|
|
294
497
|
function toWebRequest(req, trustProxy, bufferedBody) {
|
|
295
498
|
const reqHeaders = req.headers;
|
|
296
499
|
const forwardedHost = trustProxy
|
|
@@ -323,18 +526,20 @@ function toWebRequest(req, trustProxy, bufferedBody) {
|
|
|
323
526
|
const headers = new Headers(headerPairs);
|
|
324
527
|
const method = req.method ?? "GET";
|
|
325
528
|
if (method === "GET" || method === "HEAD") {
|
|
326
|
-
|
|
529
|
+
// LightRequest: skips the ~4µs undici Request constructor on the GET
|
|
530
|
+
// hot path. Headers are still built eagerly above, so every header
|
|
531
|
+
// guard sees the same view as before.
|
|
532
|
+
return new LightRequest(url, method, headers, undefined);
|
|
327
533
|
}
|
|
328
534
|
if (bufferedBody !== undefined) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
// here; readBodyLimited re-checks against the caller's limit.
|
|
535
|
+
// LightRequest with the pre-buffered bytes: skips the (much more
|
|
536
|
+
// expensive) body-wrapping undici Request constructor. The bytes are
|
|
537
|
+
// stashed via DALOY_REQUEST_RAW_BODY so readBodyLimited (and any other
|
|
538
|
+
// internal body reader) skips the WHATWG ReadableStream reader loop.
|
|
539
|
+
// The adapter has already enforced BUFFERED_BODY_MAX_BYTES +
|
|
540
|
+
// Content-Length here; readBodyLimited re-checks against the caller's
|
|
541
|
+
// limit.
|
|
542
|
+
const req2 = new LightRequest(url, method, headers, bufferedBody);
|
|
338
543
|
req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
|
|
339
544
|
return req2;
|
|
340
545
|
}
|
package/dist/app.d.ts
CHANGED
|
@@ -756,6 +756,17 @@ export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
|
|
|
756
756
|
* opt in; userland code should not depend on it.
|
|
757
757
|
*/
|
|
758
758
|
export declare const DALOY_RAW_STREAM: unique symbol;
|
|
759
|
+
/**
|
|
760
|
+
* Internal Symbol an adapter sets (once, on its request shim's prototype) to
|
|
761
|
+
* declare: "the object that ultimately consumes this request's `Response`
|
|
762
|
+
* only reads `status` / `headers` / {@link DALOY_RAW_BODY} — it never needs a
|
|
763
|
+
* branded WHATWG `Response`". When present on the incoming request,
|
|
764
|
+
* {@link serializeResult} may return a {@link LightResponse} and skip the
|
|
765
|
+
* ~2µs undici `Response` construction per request. Requests without the
|
|
766
|
+
* marker (Bun / Deno / Workers adapters, tests, direct `app.fetch()` callers)
|
|
767
|
+
* always get a real `Response`, so the public contract is unchanged.
|
|
768
|
+
*/
|
|
769
|
+
export declare const DALOY_LIGHT_RESPONSE_OK: unique symbol;
|
|
759
770
|
/**
|
|
760
771
|
* Contract-first HTTP application.
|
|
761
772
|
*
|
|
@@ -1096,19 +1107,32 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1096
1107
|
/**
|
|
1097
1108
|
* First-request boot guard. Verifies that the assembled hook
|
|
1098
1109
|
* chain + route table is internally consistent before any user handler
|
|
1099
|
-
* runs. Currently checks
|
|
1100
|
-
*
|
|
1101
|
-
* a `
|
|
1102
|
-
*
|
|
1103
|
-
* hook
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1110
|
+
* runs. Currently checks (production + `secureDefaults` only):
|
|
1111
|
+
*
|
|
1112
|
+
* 1. **Shadow auth** — a route that declares an `auth:` requirement (so it is
|
|
1113
|
+
* advertised as protected in the OpenAPI `security` list) must have an
|
|
1114
|
+
* authentication hook ({@link AUTH_HOOK_MARKER}) in its effective chain.
|
|
1115
|
+
* Otherwise it accepts unauthenticated requests while claiming protection.
|
|
1116
|
+
* 2. **Unauthenticated MCP** — a route from {@link mcpRoutes} must have an
|
|
1117
|
+
* auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
1118
|
+
* MCP tools are model-controlled and side-effecting, so a public one is a
|
|
1119
|
+
* high-impact default.
|
|
1120
|
+
* 3. **Missing CSRF** — when `session()` is installed and any route accepts a
|
|
1121
|
+
* state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
|
|
1122
|
+
* (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
|
|
1123
|
+
* also be present. Skipped when `app({ csrf: "off" })`.
|
|
1124
|
+
*
|
|
1125
|
+
* Opt out of all guards with `app({ secureDefaults: false })`. Runs once per
|
|
1126
|
+
* App between registration changes; the result is cached so the fast path is a
|
|
1127
|
+
* single boolean check.
|
|
1106
1128
|
*/
|
|
1107
1129
|
private assertBootGuards;
|
|
1108
1130
|
/**
|
|
1109
1131
|
* Per-request guard for spoofed proxy headers. When the App was
|
|
1110
1132
|
* constructed without an explicit {@link AppOptions.trustProxy} value
|
|
1111
|
-
* and a request arrives carrying
|
|
1133
|
+
* and a request arrives carrying a spoofable forwarded / client-IP header
|
|
1134
|
+
* (`X-Forwarded-*`, `X-Real-IP`, or a vendor header like `CF-Connecting-IP`,
|
|
1135
|
+
* `Fly-Client-IP`, `True-Client-IP`), refuse to
|
|
1112
1136
|
* dispatch it: the rate limiter, audit log, and request-id propagation
|
|
1113
1137
|
* would otherwise honour the attacker-supplied IP. Returns a structured
|
|
1114
1138
|
* `500 problem+json` so the failure is loud at the network boundary.
|