@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.6

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 (65) hide show
  1. package/README.md +34 -22
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +42 -7
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +117 -46
  9. package/dist/app.d.ts +30 -4
  10. package/dist/app.js +187 -45
  11. package/dist/auto-ban.js +1 -3
  12. package/dist/bot-guard.js +30 -3
  13. package/dist/cli.js +9 -6
  14. package/dist/client.d.ts +36 -7
  15. package/dist/client.js +7 -0
  16. package/dist/compression.d.ts +9 -0
  17. package/dist/compression.js +72 -1
  18. package/dist/config.js +1 -3
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/errors.d.ts +12 -3
  22. package/dist/errors.js +14 -8
  23. package/dist/etag.js +12 -2
  24. package/dist/fetch-guard.d.ts +27 -19
  25. package/dist/fetch-guard.js +50 -8
  26. package/dist/geo-block.js +4 -9
  27. package/dist/hashing.js +1 -1
  28. package/dist/http-signatures.d.ts +4 -1
  29. package/dist/http-signatures.js +16 -9
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +3 -3
  32. package/dist/ip-reputation.js +1 -1
  33. package/dist/ip-restriction.js +3 -12
  34. package/dist/jwt.js +12 -14
  35. package/dist/logger.d.ts +45 -0
  36. package/dist/logger.js +135 -0
  37. package/dist/mcp.js +10 -9
  38. package/dist/middleware.js +33 -3
  39. package/dist/mtls.js +6 -1
  40. package/dist/multipart.js +9 -12
  41. package/dist/openapi.d.ts +1 -1
  42. package/dist/openapi.js +2 -2
  43. package/dist/rate-limit-redis.d.ts +4 -4
  44. package/dist/response-cache.d.ts +179 -21
  45. package/dist/response-cache.js +338 -29
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +5 -1
  49. package/dist/safe-redirect.js +27 -3
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security-schemes.js +1 -2
  53. package/dist/security.d.ts +41 -0
  54. package/dist/security.js +131 -15
  55. package/dist/session.d.ts +13 -2
  56. package/dist/session.js +111 -17
  57. package/dist/subdomains.js +1 -4
  58. package/dist/tenancy.d.ts +40 -0
  59. package/dist/tenancy.js +54 -3
  60. package/dist/time-claims.js +3 -1
  61. package/dist/waf.js +124 -32
  62. package/dist/webhook-delivery.js +19 -3
  63. package/dist/websocket.d.ts +8 -0
  64. package/dist/websocket.js +19 -4
  65. package/package.json +6 -5
package/README.md CHANGED
@@ -22,17 +22,27 @@
22
22
  [![Security Responsible
23
23
  Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/daloyjs/daloy/blob/main/SECURITY.md)
24
24
 
25
- > A **runtime-portable TypeScript web framework** with built-in **contract-first routing**, **validation**, **OpenAPI (Hey API)**, **typed client generation**, **large-scale maintainability**, and **security-focused runtime plus supply-chain posture**.
25
+ > The first TypeScript REST API framework built for secure AI-assisted services. DaloyJS combines runtime portability, contract-first OpenAPI, generated typed clients, zero-dependency runtime guardrails, and supply-chain-aware project defaults in one framework.
26
26
 
27
27
  **One-line API docs.** `new App({ openapi: { info: ... }, docs: true })` auto-mounts `GET /docs` (Scalar), `GET /openapi.json`, and `GET /openapi.yaml` — the same DX as FastAPI, without leaving TypeScript.
28
28
 
29
29
  DaloyJS is maintained in the GitHub organization at <https://github.com/daloyjs>; the canonical framework repository is <https://github.com/daloyjs/daloy>.
30
30
 
31
- ## Partners
31
+ ## Acknowledgements
32
32
 
33
- <a href="https://snyk.io">
34
- <img src="https://github.com/user-attachments/assets/da58db43-67cc-45d4-ade5-bdaa7b041465" alt="Snyk's Secure Developer Program" width="160">
35
- </a>
33
+ We are grateful to the following companies for supporting DaloyJS with free access to their tools and services.
34
+
35
+ <div align="center" style="background-color: #f5f5f5; padding: 25px; border-radius: 10px; margin: 20px 0;">
36
+
37
+ <a href="https://snyk.io"><img src="https://github.com/user-attachments/assets/da58db43-67cc-45d4-ade5-bdaa7b041465" height="75" width="auto" alt="Snyk"></a>
38
+    
39
+ <a href="https://socket.dev"><img src="https://github.com/user-attachments/assets/7d2dde1f-6b60-4f20-b05b-80ace5ae6862" height="75" width="auto" alt="Socket"></a>
40
+    
41
+ <a href="https://www.aikido.dev"><img src="https://github.com/user-attachments/assets/67e62dd1-b907-4246-a0aa-95ef13fa491c" height="75" width="auto" alt="Aikido"></a>
42
+    
43
+ <a href="https://www.coderabbit.ai"><img src="https://github.com/user-attachments/assets/0d9d8e68-eb21-41ec-978c-337b66ee34b6" height="75" width="auto" alt="CodeRabbit"></a>
44
+
45
+ </div>
36
46
 
37
47
  ---
38
48
 
@@ -66,7 +76,7 @@ DaloyJS exists to be the framework you'd build if you took the best ideas from e
66
76
  | **Portable supply-chain hardening** for the apps you build | [pnpm](https://pnpm.io/motivation) defaults + a zero-runtime-dep core | Hardened `.npmrc`, source-verified lockfiles, zero runtime deps, CycloneDX + SPDX SBOM, and npm provenance attestations. |
67
77
 
68
78
  ```
69
- framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 6 with `strict: true`
79
+ framework test suite passing · ≥90% line + function coverage / ≥92% branch coverage · typechecks on TypeScript 7 with `strict: true`
70
80
  runs on Node, Bun, Deno, Cloudflare, Vercel
71
81
  ~12.3M static-route ops/sec · ~1.5M dynamic-route ops/sec on M-class CPU
72
82
  ```
@@ -196,7 +206,7 @@ app.get(
196
206
  async ({ params }) => ({
197
207
  status: 200,
198
208
  body: { id: params.id, title: `Book ${params.id}` },
199
- }),
209
+ })
200
210
  );
201
211
 
202
212
  serve(app, { port: 3000 });
@@ -474,15 +484,17 @@ for the full picture, plus the per-provider guides under [`/docs/auth`](https://
474
484
 
475
485
  ```text
476
486
  $ pnpm bench
477
- static route lookup 12,363,799 ops/sec
478
- dynamic 4-segment lookup 1,513,983 ops/sec
479
- miss 4,763,878 ops/sec
487
+ static route lookup 25,810,420 ops/sec
488
+ dynamic 4-segment lookup 2,105,121 ops/sec
489
+ miss 7,742,635 ops/sec
480
490
  ```
481
491
 
482
- - Static (no-param) routes resolve via a single `Map.get` **~12M ops/sec**.
492
+ - After traversal checks, exact static routes resolve with an allocation-free
493
+ `Map.get` fast path — **~26M ops/sec**.
483
494
  - Dynamic routes walk a trie, **O(path-segments)** regardless of route count.
484
495
  - Body parsing is lazy and only runs when a route declares a body schema.
485
- - No regex on the hot path.
496
+ - Path normalization and splitting use index/character scans rather than
497
+ regular expressions.
486
498
 
487
499
  ### Cold-start tip (serverless / edge)
488
500
 
@@ -527,7 +539,7 @@ const usersPlugin = {
527
539
  operationId: "me",
528
540
  responses: { 200: { description: "ok" } },
529
541
  },
530
- async () => ({ status: 200, body: { user: "alice" } }),
542
+ async () => ({ status: 200, body: { user: "alice" } })
531
543
  );
532
544
  },
533
545
  };
@@ -572,9 +584,9 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
572
584
 
573
585
  ## Status
574
586
 
575
- DaloyJS is at **`1.0.0-rc.4`**, a security-hardening release candidate. Because the framework has no external users yet, this RC makes a few intentional changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before the stable release rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` stable onward, the API follows SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
587
+ DaloyJS is at **`1.0.0-rc.6`**, a security-hardening release candidate carrying the remediations from a live over-the-wire engagement against a realistic multi-tenant app — cross-principal response-cache disclosure (`SECURITY-AUDIT.md` F-4 … F-9), SQLi signature evasions in `waf()`, and an unbounded WebSocket frame buffer. Because the framework has no external users yet, this RC makes a few intentional changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before the stable release rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` stable onward, the API follows SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
576
588
 
577
- **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.
589
+ **Release quality bar.** Every release ships with **≥90% line + function coverage** (`pnpm coverage`) **and ≥92% branch coverage** (`pnpm coverage:branches` on compiled JS), 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.
578
590
 
579
591
  ### Routing, validation, and docs
580
592
 
@@ -592,7 +604,7 @@ DaloyJS is at **`1.0.0-rc.4`**, a security-hardening release candidate. Because
592
604
 
593
605
  ### Runtimes and deployment
594
606
 
595
- - Adapters for Node (Heroku, Railway, Render, Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs.
607
+ - Adapters for Node (Heroku, Railway, Render, Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs, including backpressure-safe Lambda response streaming via `toLambdaStreamHandler()`.
596
608
  - `daloy dev` watch loop delegates to the host runtime's native watcher (`node --watch`, `bun --hot`, or `deno run --watch`) with a `--runtime` override for cross-runtime `package.json` scripts.
597
609
  - `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening. The completion summary surfaces official install links (nodejs.org, pnpm.io, bun.sh) for any runtime or package manager your selections need but that is missing from `PATH`, and skips a doomed dependency install when the chosen package manager is absent.
598
610
  - Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
@@ -644,11 +656,11 @@ The framework refuses to start (or to construct) when configuration is unsafe:
644
656
  - `requireScopes()` with RFC-6750 `WWW-Authenticate: Bearer` challenge and per-request scope aggregation.
645
657
  - `session()` with signed cookies and pluggable stores.
646
658
  - `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`.
647
- - `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.
659
+ - `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, proactive `varyHeaders` keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` whose in-memory default is bounded on both entry count and retained bytes) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie`, `private`/`no-store`/`no-cache`, or `Vary: *` responses, and strips `Age`/hop-by-hop/`X-Request-Id` from stored entries so a hit never replays another request's correlation id. **Fail-closed on every principal dimension (CWE-524):** the key is the full _effective request URI_ including the authority (RFC 9111 §4), so hostnames never share entries; requests carrying `Authorization` **or** `Cookie` bypass the shared cache unless a `principal` names the caller (then each gets its own entry) or the header is explicitly declared shareable; a tenant resolved by `tenancy()` is folded into the key automatically — with a boot guard that refuses to start if the cache is mounted ahead of `tenancy()`; and the response's **own `Vary` header** is honoured as a secondary key (RFC 9111 §4.1), so the `Vary: Origin` written by `cors()` and the `Vary: Accept-Encoding` written by `compression()` keep one caller's allowed origin — or their gzipped bytes — from being served to the next, with each variant stored separately so they all stay warm. Complements `etag()`/`compression()`, which do not cache bodies.
648
660
  - `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()`.
649
661
  - `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). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
650
662
  - `otelTracing()` OpenTelemetry-compatible distributed tracing at `@daloyjs/core/tracing`: a dependency-free `Hooks` bundle that opens one `SERVER` span per request, attaches HTTP semantic-convention attributes (`http.request.method`, `url.path`, `server.address` / `server.port`, `http.response.status_code`, …), records exceptions + escalates `5xx` to `ERROR`, guarantees a single `span.end()`, and exposes the live span on `ctx.state.otelSpan`. Bring any tracer matching the small `TracingTracer` interface (the real `@opentelemetry/api` SDK on Node, or a custom exporter on Workers/Deno) plus your own propagator via `contextFromRequest` for `traceparent` continuation — no OTel SDK is forced into your install. The `examples/observability/` stack also runs **Jaeger**, and `examples/otel-tracing-demo.ts` ships a ~120-line dependency-free OTLP/HTTP exporter that streams spans straight to it.
651
- - `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` / `responseCache` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense). Runnable `examples/multitenancy-demo.ts`.
663
+ - `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense); `responseCache()` needs no wiring at all — it reads the resolved tenant itself and refuses to boot if mounted ahead of `tenancy()`. Runnable `examples/multitenancy-demo.ts`.
652
664
  - `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.
653
665
  - `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.
654
666
  - `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.
@@ -662,7 +674,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
662
674
  - `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.
663
675
  - Built-in docs UI Subresource Integrity (SRI): the default Scalar / Swagger UI / Redoc / AsyncAPI assets use version-exact URLs with matching SHA-384 hashes and `crossorigin="anonymous"`, so a poisoned CDN asset cannot execute. `DocsAssetOptions` supports validated URL/hash overrides or self-hosting; malformed SRI values throw a `TypeError` instead of silently weakening the page. Zero runtime dependencies.
664
676
  - 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.
665
- - `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.
677
+ - `compression()` built on web-standard `CompressionStream` (prefers `br` > `gzip` > `deflate`), with BREACH-aware always-on guards (skips `Set-Cookie`, `Authorization`, session / CSRF cookies, already-compressed content types), `minimumSize: 1024`, a `maxCompressibleBytes` memory bound (default 1 MiB — larger responses stream uncompressed instead of buffering), negative-compression-ratio post-check, no configurable `compressLevel` knob (CPU-DoS defense — `level: 9` is refused at construction), always-on `Vary: Accept-Encoding`, and strong → weak ETag downgrade per RFC 9110 §8.8.3.
666
678
  - `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
667
679
  - `timing` / `timingSafeEqual` helpers.
668
680
  - `fileField({ magicBytes })` upload signature checks.
@@ -686,16 +698,16 @@ The framework refuses to start (or to construct) when configuration is unsafe:
686
698
 
687
699
  - Plugin encapsulation (Fastify-style), decorators, structured logging, request-id propagation.
688
700
  - Lifecycle events: `onPluginInstalled`, `onShutdown`, `onClose`.
689
- - Connection-draining graceful shutdown with `Connection: close` on `503` and in-flight responses.
701
+ - Connection-draining graceful shutdown with `Connection: close` on `503` and in-flight responses; the Node, Bun, and Deno adapters all wire it to `SIGTERM`/`SIGINT` by default.
690
702
  - `crashOnUnhandledRejection` default-on in production.
691
703
  - `app.healthcheck()` / `app.readinesscheck()` primitives with bearer-token auth and per-IP rate limit.
692
704
  - `disconnectStatusCode: 499` default for client-aborted requests.
693
705
  - `defineConfig({ schema, source })` boot-time typed configuration validation.
694
706
  - `app({ behindProxy })` declarative model (replaces `trustProxy`); `behindProxy.hops` collapses to the `(N+1)`-from-rightmost slot.
695
- - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort`.
707
+ - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort` — populated by the Node, Bun, Deno, and Lambda adapters from the real peer socket / event source, never from spoofable headers.
696
708
  - `daloy doctor` production-posture validator with `--audit-secrets` and `--audit-defaults` (flags wildcard-credentials CORS, > 24h CORS `maxAge`, > 25 MiB blanket body limits, zero `idleTimeoutMs` in production, and unsafe opt-ins).
697
709
  - PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
698
- - Secure-by-default multitenancy via `tenancy()` + `tenantScope()`: pluggable tenant resolution (subdomain / header / path / JWT claim / custom), refuse-unresolved + format-validated ids + no-enumeration `404` by default, and a key helper that partitions `rateLimit` / `concurrencyLimit` / `idempotency` / `responseCache` per tenant.
710
+ - Secure-by-default multitenancy via `tenancy()` + `tenantScope()`: pluggable tenant resolution (subdomain / header / path / JWT claim / custom), refuse-unresolved + format-validated ids + no-enumeration `404` by default, a key helper that partitions `rateLimit` / `concurrencyLimit` / `idempotency` per tenant, and automatic per-tenant `responseCache` partitioning backed by a boot guard.
699
711
  - `defineDependency()` typed-DI helper with per-request deduplication.
700
712
  - Scheme-aware `ctx.state.auth` typed contract; named, optionally seeded stateful plugins.
701
713
 
@@ -2,7 +2,8 @@
2
2
  * Bun adapter — `Bun.serve` already speaks web-standard fetch,
3
3
  * so this is the smallest possible wrapper. The adapter passes through the
4
4
  * commonly-needed modern `Bun.serve` options (`idleTimeout`, `tls`,
5
- * `development`, `unix`) and exposes the server's `url` for ergonomic logging.
5
+ * `development`, `unix`), exposes the server's `url` for ergonomic logging,
6
+ * and wires graceful shutdown to SIGTERM/SIGINT like the Node adapter.
6
7
  */
7
8
  import type { App } from "../app.js";
8
9
  /** TLS material accepted by Bun's `serve({ tls })` option. */
@@ -32,6 +33,23 @@ export interface BunServeOptions {
32
33
  unix?: string;
33
34
  /** When supplied, Bun.serve listens on HTTPS. */
34
35
  tls?: BunTLSOptions;
36
+ /**
37
+ * Drain timeout in ms passed to {@link App.shutdown} during graceful
38
+ * shutdown (signal-triggered or via `stop()`). Default: 10000.
39
+ *
40
+ * @since 1.0.0
41
+ */
42
+ shutdownTimeoutMs?: number;
43
+ /**
44
+ * Listen for SIGTERM/SIGINT and shut down gracefully (drain
45
+ * {@link App.shutdown} hooks, then stop the Bun server and exit). Matches
46
+ * the Node and Deno adapters so rolling deploys under Kubernetes/systemd
47
+ * do not hard-kill in-flight requests. Set `false` to manage signals
48
+ * yourself. Default: true.
49
+ *
50
+ * @since 1.0.0
51
+ */
52
+ handleSignals?: boolean;
35
53
  }
36
54
  /** Handle returned by {@link serve} for shutdown and listener introspection. */
37
55
  export interface BunServerHandle {
@@ -39,7 +57,7 @@ export interface BunServerHandle {
39
57
  port: number;
40
58
  /** Server URL as reported by `Bun.serve` (e.g. for startup logging), if available. */
41
59
  url: URL | undefined;
42
- /** Graceful stop: drains {@link App.shutdown} hooks first, then force-stops the Bun server. */
60
+ /** Graceful stop: drains {@link App.shutdown} hooks first, then force-stops the Bun server. Idempotent. */
43
61
  stop: () => Promise<void>;
44
62
  }
45
63
  /**
@@ -1,3 +1,4 @@
1
+ import { setConnInfo } from "../conn-info.js";
1
2
  import { WS_READY_STATE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, encodeSendPayload, parseSubprotocols, validateSelectedSubprotocol, checkWebSocketOrigin, WebSocketProtocolError, } from "../websocket.js";
2
3
  /**
3
4
  * Start `Bun.serve` bound to the given {@link App}, wiring HTTP and WebSocket routes.
@@ -12,16 +13,34 @@ export function serve(app, opts = {}) {
12
13
  if (!Bun?.serve)
13
14
  throw new Error("Bun runtime not detected");
14
15
  const hasWs = app.webSocketRoutes.size > 0;
16
+ const servesTls = opts.tls !== undefined;
17
+ // Fulfil the conn-info contract with the immediate TCP peer from Bun's
18
+ // native `server.requestIP()`, so `getConnInfo` / `resolveClientIp` /
19
+ // `behindProxy` work on Bun. Never derived from spoofable headers.
20
+ const tagConnInfo = (req, server) => {
21
+ const ip = server?.requestIP?.(req);
22
+ if (ip) {
23
+ setConnInfo(req, {
24
+ remoteAddress: ip.address,
25
+ remotePort: ip.port,
26
+ tls: servesTls,
27
+ });
28
+ }
29
+ };
15
30
  const cfg = {
16
31
  maxRequestBodySize: opts.maxRequestBodySize ?? 16 * 1024 * 1024,
17
32
  fetch: hasWs
18
33
  ? (req, server) => {
34
+ tagConnInfo(req, server);
19
35
  if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
20
36
  return tryBunUpgrade(app, req, server);
21
37
  }
22
38
  return app.fetch(req);
23
39
  }
24
- : (req) => app.fetch(req),
40
+ : (req, server) => {
41
+ tagConnInfo(req, server);
42
+ return app.fetch(req);
43
+ },
25
44
  error: (err) => {
26
45
  // Last-resort handler reached only if app.fetch itself throws (it
27
46
  // normally catches everything). Log the error server-side but never
@@ -54,13 +73,30 @@ export function serve(app, opts = {}) {
54
73
  if (opts.tls)
55
74
  cfg.tls = opts.tls;
56
75
  const server = Bun.serve(cfg);
76
+ let stopped = false;
77
+ const stop = async () => {
78
+ if (stopped)
79
+ return;
80
+ stopped = true;
81
+ await app.shutdown(opts.shutdownTimeoutMs ?? 10_000);
82
+ server.stop(true);
83
+ };
84
+ if (opts.handleSignals !== false) {
85
+ // Parity with the Node/Deno adapters: without this, SIGTERM on a rolling
86
+ // deploy hard-kills the process — in-flight requests are dropped and
87
+ // `onShutdown`/`onClose` hooks never run. Bun implements Node's
88
+ // `process` signal events, so the same wiring works.
89
+ const onSignal = (sig) => {
90
+ app.log.info({ sig }, "DaloyJS received signal, shutting down");
91
+ void stop().then(() => process.exit(0));
92
+ };
93
+ process.once("SIGTERM", () => onSignal("SIGTERM"));
94
+ process.once("SIGINT", () => onSignal("SIGINT"));
95
+ }
57
96
  return {
58
97
  port: server.port,
59
98
  url: server.url,
60
- stop: async () => {
61
- await app.shutdown();
62
- server.stop(true);
63
- },
99
+ stop,
64
100
  };
65
101
  }
66
102
  async function tryBunUpgrade(app, req, server) {
@@ -237,8 +273,7 @@ function reportBunHandlerFailure(app, data, label, err, notifyError) {
237
273
  }
238
274
  }
239
275
  function validateControlPayload(data) {
240
- if (data !== undefined &&
241
- encodeSendPayload(data).payload.length > WS_MAX_CONTROL_PAYLOAD) {
276
+ if (data !== undefined && encodeSendPayload(data).payload.length > WS_MAX_CONTROL_PAYLOAD) {
242
277
  throw new WebSocketProtocolError("Control frame payload exceeds 125 bytes");
243
278
  }
244
279
  }
@@ -1,3 +1,4 @@
1
+ import { setConnInfo } from "../conn-info.js";
1
2
  /**
2
3
  * Start `Deno.serve` bound to the given {@link App} with graceful-shutdown wiring.
3
4
  *
@@ -25,7 +26,21 @@ export function serve(app, opts = {}) {
25
26
  init.onListen = opts.onListen;
26
27
  if (opts.onError)
27
28
  init.onError = opts.onError;
28
- const server = denoServe(init, (req) => app.fetch(req));
29
+ const servesTls = Boolean(opts.cert && opts.key);
30
+ const server = denoServe(init, (req, info) => {
31
+ // Fulfil the conn-info contract with the immediate TCP peer from
32
+ // `Deno.serve`'s handler info, so `getConnInfo` / `resolveClientIp` /
33
+ // `behindProxy` work on Deno. Never derived from spoofable headers.
34
+ const addr = info?.remoteAddr;
35
+ if (addr?.hostname !== undefined) {
36
+ setConnInfo(req, {
37
+ remoteAddress: addr.hostname,
38
+ remotePort: addr.port,
39
+ tls: servesTls,
40
+ });
41
+ }
42
+ return app.fetch(req);
43
+ });
29
44
  const onSignal = () => {
30
45
  void shutdown();
31
46
  };
@@ -46,13 +61,15 @@ export function serve(app, opts = {}) {
46
61
  }
47
62
  // Drain app-level hooks first (while the HTTP server can still respond),
48
63
  // then ask Deno to stop gracefully — `server.shutdown()` stops accepting
49
- // new connections and lets in-flight requests finish. Aborting the listen
50
- // signal happens last, purely as a safety net for runtimes that lack
51
- // `HttpServer.shutdown()`. Doing it the other way around (aborting first)
52
- // tears the listener down before in-flight requests can complete.
64
+ // new connections and lets in-flight requests finish. Abort only as a
65
+ // fallback for runtimes that lack `HttpServer.shutdown()`: Deno 2.9 closes
66
+ // the listener resource inside shutdown(), so aborting the same signal
67
+ // afterwards throws BadResource.
53
68
  await app.shutdown(opts.shutdownTimeoutMs ?? 10_000);
54
- await server.shutdown?.();
55
- controller.abort();
69
+ if (server.shutdown)
70
+ await server.shutdown();
71
+ else
72
+ controller.abort();
56
73
  };
57
74
  return { shutdown };
58
75
  }
@@ -29,10 +29,13 @@ export interface LambdaEventV1 {
29
29
  queryStringParameters?: Record<string, string | undefined> | null;
30
30
  /** Query parameters with every value per name; preferred over the single-value map. */
31
31
  multiValueQueryStringParameters?: Record<string, string[] | undefined> | null;
32
- /** Request context; `domainName` is the host fallback and `path` the path fallback. */
32
+ /** Request context; `domainName` is the host fallback, `path` the path fallback, and `identity.sourceIp` the caller address seen by API Gateway. */
33
33
  requestContext?: {
34
34
  domainName?: string;
35
35
  path?: string;
36
+ identity?: {
37
+ sourceIp?: string;
38
+ };
36
39
  };
37
40
  /** Raw request body; base64-encoded when {@link LambdaEventV1.isBase64Encoded} is true. */
38
41
  body?: string;
@@ -51,11 +54,12 @@ export interface LambdaEventV2 {
51
54
  headers?: Record<string, string | undefined>;
52
55
  /** Request cookies as individual strings; re-joined with `; ` into a `cookie` header. */
53
56
  cookies?: string[];
54
- /** Request context; `http.method`/`http.path` carry the method and path, `domainName` the host fallback. */
57
+ /** Request context; `http.method`/`http.path` carry the method and path, `http.sourceIp` the caller address, `domainName` the host fallback. */
55
58
  requestContext?: {
56
59
  http?: {
57
60
  method?: string;
58
61
  path?: string;
62
+ sourceIp?: string;
59
63
  };
60
64
  domainName?: string;
61
65
  };
@@ -100,10 +104,63 @@ export interface LambdaResponseV2 {
100
104
  export type LambdaResponse = LambdaResponseV1 | LambdaResponseV2;
101
105
  /** Async handler shape consumed by AWS Lambda / Netlify Functions runtimes. */
102
106
  export type LambdaHandler = (event: LambdaEvent) => Promise<LambdaResponse>;
107
+ /**
108
+ * Writable response stream supplied to a response-streaming AWS Lambda handler.
109
+ *
110
+ * The contract intentionally models only the Node.js writable-stream methods
111
+ * used by DaloyJS, keeping the adapter free of Node-only imports while still
112
+ * respecting backpressure.
113
+ */
114
+ export interface LambdaResponseStream {
115
+ /** Writes one response chunk and returns false when the producer must wait for `drain`. */
116
+ write(chunk: Uint8Array): boolean;
117
+ /** Ends the response after every previously written chunk has flushed. */
118
+ end(): void;
119
+ /** Registers a one-shot writable-stream event listener. */
120
+ once(event: "drain", listener: () => void): this;
121
+ /** Registers a one-shot writable-stream error listener. */
122
+ once(event: "error", listener: (error: Error) => void): this;
123
+ /** Removes a previously registered drain listener when supported. */
124
+ off?(event: "drain", listener: () => void): this;
125
+ /** Removes a previously registered error listener when supported. */
126
+ off?(event: "error", listener: (error: Error) => void): this;
127
+ /** Resolves when AWS has flushed the ended response stream, when provided by the runtime. */
128
+ finished?(): Promise<void>;
129
+ }
130
+ /** HTTP response metadata accepted by `awslambda.HttpResponseStream.from()`. */
131
+ export interface LambdaStreamMetadata {
132
+ /** HTTP response status code. */
133
+ statusCode: number;
134
+ /** Single-value response headers, excluding `set-cookie`. */
135
+ headers: Record<string, string>;
136
+ /** Multi-value response headers, used to preserve every `set-cookie` value. */
137
+ multiValueHeaders?: Record<string, string[]>;
138
+ }
139
+ /** Async response-streaming handler shape consumed by the AWS Lambda Node.js runtime. */
140
+ export type LambdaStreamHandler = (event: LambdaEvent, responseStream: LambdaResponseStream, context?: unknown) => Promise<void>;
103
141
  /**
104
142
  * Wrap an {@link App} as a Lambda/Netlify handler accepting either v1.0 or v2.0 event payloads.
105
143
  *
144
+ * A malformed event (e.g. a `Host`/path combination that cannot form a valid
145
+ * URL) is answered with a clean `400` problem+json instead of throwing out of
146
+ * the handler, which API Gateway would otherwise surface as an opaque `502`.
147
+ *
106
148
  * @param app - The DaloyJS {@link App} that serves each translated request.
107
149
  * @returns A {@link LambdaHandler} that converts the event to a `Request`, calls {@link App.fetch}, and emits the matching v1.0/v2.0 response shape.
108
150
  */
109
151
  export declare function toLambdaHandler(app: App): LambdaHandler;
152
+ /**
153
+ * Wrap an {@link App} as an AWS Lambda response-streaming handler.
154
+ *
155
+ * The returned handler is decorated with the managed Node.js runtime's
156
+ * `awslambda.streamifyResponse()` helper, attaches status/headers with
157
+ * `HttpResponseStream.from()`, and pumps the web-standard response body while
158
+ * honoring writable-stream backpressure. The function throws during startup
159
+ * outside an AWS Lambda Node.js runtime so an accidentally buffered or broken
160
+ * deployment cannot start silently.
161
+ *
162
+ * @param app - The DaloyJS {@link App} that serves each translated request.
163
+ * @returns A response-streaming Lambda handler for Function URLs, API Gateway streaming proxy integrations, or `InvokeWithResponseStream`.
164
+ * @throws {Error} If the AWS Lambda response-streaming globals are unavailable.
165
+ */
166
+ export declare function toLambdaStreamHandler(app: App): LambdaStreamHandler;
@@ -1,17 +1,56 @@
1
+ import { setConnInfo } from "../conn-info.js";
1
2
  const TEXT_TYPE_RE = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|.*\+json|.*\+xml))/i;
2
3
  /**
3
4
  * Wrap an {@link App} as a Lambda/Netlify handler accepting either v1.0 or v2.0 event payloads.
4
5
  *
6
+ * A malformed event (e.g. a `Host`/path combination that cannot form a valid
7
+ * URL) is answered with a clean `400` problem+json instead of throwing out of
8
+ * the handler, which API Gateway would otherwise surface as an opaque `502`.
9
+ *
5
10
  * @param app - The DaloyJS {@link App} that serves each translated request.
6
11
  * @returns A {@link LambdaHandler} that converts the event to a `Request`, calls {@link App.fetch}, and emits the matching v1.0/v2.0 response shape.
7
12
  */
8
13
  export function toLambdaHandler(app) {
9
14
  return async (event) => {
10
- const request = eventToRequest(event);
15
+ let request;
16
+ try {
17
+ request = eventToRequest(event);
18
+ }
19
+ catch {
20
+ return responseToLambda(badRequestResponse(), isV2Event(event));
21
+ }
11
22
  const response = await app.fetch(request);
12
23
  return responseToLambda(response, isV2Event(event));
13
24
  };
14
25
  }
26
+ /**
27
+ * Wrap an {@link App} as an AWS Lambda response-streaming handler.
28
+ *
29
+ * The returned handler is decorated with the managed Node.js runtime's
30
+ * `awslambda.streamifyResponse()` helper, attaches status/headers with
31
+ * `HttpResponseStream.from()`, and pumps the web-standard response body while
32
+ * honoring writable-stream backpressure. The function throws during startup
33
+ * outside an AWS Lambda Node.js runtime so an accidentally buffered or broken
34
+ * deployment cannot start silently.
35
+ *
36
+ * @param app - The DaloyJS {@link App} that serves each translated request.
37
+ * @returns A response-streaming Lambda handler for Function URLs, API Gateway streaming proxy integrations, or `InvokeWithResponseStream`.
38
+ * @throws {Error} If the AWS Lambda response-streaming globals are unavailable.
39
+ */
40
+ export function toLambdaStreamHandler(app) {
41
+ const runtime = lambdaStreamingRuntime();
42
+ return runtime.streamifyResponse(async (event, rawStream) => {
43
+ let request;
44
+ try {
45
+ request = eventToRequest(event);
46
+ }
47
+ catch {
48
+ await streamLambdaResponse(badRequestResponse(), rawStream, runtime);
49
+ return;
50
+ }
51
+ await streamLambdaResponse(await app.fetch(request), rawStream, runtime);
52
+ });
53
+ }
15
54
  function eventToRequest(event) {
16
55
  const headers = new Headers();
17
56
  for (const [k, v] of Object.entries(event.headers ?? {})) {
@@ -28,35 +67,37 @@ function eventToRequest(event) {
28
67
  }
29
68
  if ("cookies" in event && event.cookies?.length)
30
69
  headers.set("cookie", event.cookies.join("; "));
31
- const method = isV2Event(event) ? event.requestContext?.http?.method ?? "GET" : event.httpMethod ?? "GET";
70
+ const method = isV2Event(event)
71
+ ? (event.requestContext?.http?.method ?? "GET")
72
+ : (event.httpMethod ?? "GET");
32
73
  const rawPath = isV2Event(event)
33
- ? event.rawPath ?? event.requestContext?.http?.path ?? "/"
34
- : event.path ?? event.requestContext?.path ?? "/";
74
+ ? (event.rawPath ?? event.requestContext?.http?.path ?? "/")
75
+ : (event.path ?? event.requestContext?.path ?? "/");
35
76
  const host = headers.get("host") ?? event.requestContext?.domainName ?? "localhost";
36
77
  const proto = headers.get("x-forwarded-proto") ?? "https";
37
- const rawQueryString = isV2Event(event) ? event.rawQueryString ?? "" : queryStringForV1(event);
78
+ const rawQueryString = isV2Event(event) ? (event.rawQueryString ?? "") : queryStringForV1(event);
38
79
  const qs = rawQueryString ? `?${rawQueryString}` : "";
39
80
  const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
40
81
  const url = `${proto}://${host}${path}${qs}`;
41
82
  const init = { method, headers };
42
83
  if (method !== "GET" && method !== "HEAD" && event.body != null) {
43
- init.body = event.isBase64Encoded
44
- ? base64ToBytes(event.body)
45
- : event.body;
84
+ init.body = event.isBase64Encoded ? base64ToBytes(event.body) : event.body;
85
+ }
86
+ const request = new Request(url, init);
87
+ // Fulfil the conn-info contract with the caller address API Gateway saw
88
+ // (v2: `requestContext.http.sourceIp`, v1: `requestContext.identity.sourceIp`),
89
+ // so `getConnInfo` / `resolveClientIp` work on Lambda. API Gateway and
90
+ // Function URLs only serve TLS.
91
+ const sourceIp = isV2Event(event)
92
+ ? event.requestContext?.http?.sourceIp
93
+ : event.requestContext?.identity?.sourceIp;
94
+ if (sourceIp) {
95
+ setConnInfo(request, { remoteAddress: sourceIp, tls: true });
46
96
  }
47
- return new Request(url, init);
97
+ return request;
48
98
  }
49
99
  async function responseToLambda(res, useV2Response) {
50
- const headers = {};
51
- const getSetCookie = res.headers.getSetCookie;
52
- const cookies = typeof getSetCookie === "function"
53
- ? getSetCookie.call(res.headers)
54
- : cookieFallback(res.headers);
55
- res.headers.forEach((value, key) => {
56
- if (key.toLowerCase() === "set-cookie")
57
- return;
58
- headers[key] = value;
59
- });
100
+ const { headers, cookies } = responseHeaders(res);
60
101
  const contentType = res.headers.get("content-type") ?? "";
61
102
  const isText = TEXT_TYPE_RE.test(contentType);
62
103
  let body = "";
@@ -87,7 +128,10 @@ async function responseToLambda(res, useV2Response) {
87
128
  }
88
129
  function isV2Event(event) {
89
130
  const requestContext = event.requestContext;
90
- return event.version === "2.0" || "rawPath" in event || "rawQueryString" in event || !!requestContext?.http;
131
+ return (event.version === "2.0" ||
132
+ "rawPath" in event ||
133
+ "rawQueryString" in event ||
134
+ !!requestContext?.http);
91
135
  }
92
136
  function queryStringForV1(event) {
93
137
  const values = new URLSearchParams();
@@ -107,6 +151,78 @@ function cookieFallback(headers) {
107
151
  const cookie = headers.get("set-cookie");
108
152
  return cookie ? [cookie] : [];
109
153
  }
154
+ function responseHeaders(res) {
155
+ const headers = {};
156
+ const getSetCookie = res.headers.getSetCookie;
157
+ const cookies = typeof getSetCookie === "function"
158
+ ? getSetCookie.call(res.headers)
159
+ : cookieFallback(res.headers);
160
+ res.headers.forEach((value, key) => {
161
+ if (key.toLowerCase() !== "set-cookie")
162
+ headers[key] = value;
163
+ });
164
+ return { headers, cookies };
165
+ }
166
+ function badRequestResponse() {
167
+ return Response.json({
168
+ type: "https://daloyjs.dev/errors/bad-request",
169
+ title: "Bad Request",
170
+ status: 400,
171
+ }, { status: 400, headers: { "content-type": "application/problem+json" } });
172
+ }
173
+ function lambdaStreamingRuntime() {
174
+ const runtime = globalThis
175
+ .awslambda;
176
+ if (!runtime ||
177
+ typeof runtime.streamifyResponse !== "function" ||
178
+ typeof runtime.HttpResponseStream?.from !== "function") {
179
+ throw new Error("AWS Lambda response streaming runtime not detected; toLambdaStreamHandler requires the managed Node.js awslambda globals");
180
+ }
181
+ return runtime;
182
+ }
183
+ async function streamLambdaResponse(response, rawStream, runtime) {
184
+ const { headers, cookies } = responseHeaders(response);
185
+ const metadata = { statusCode: response.status, headers };
186
+ if (cookies.length)
187
+ metadata.multiValueHeaders = { "set-cookie": cookies };
188
+ const responseStream = runtime.HttpResponseStream.from(rawStream, metadata);
189
+ if (response.body) {
190
+ const reader = response.body.getReader();
191
+ try {
192
+ for (;;) {
193
+ const chunk = await reader.read();
194
+ if (chunk.done)
195
+ break;
196
+ if (!responseStream.write(chunk.value))
197
+ await waitForDrain(responseStream);
198
+ }
199
+ }
200
+ catch (error) {
201
+ await reader.cancel(error).catch(() => undefined);
202
+ throw error;
203
+ }
204
+ finally {
205
+ reader.releaseLock();
206
+ }
207
+ }
208
+ responseStream.end();
209
+ if (responseStream.finished)
210
+ await responseStream.finished();
211
+ }
212
+ function waitForDrain(stream) {
213
+ return new Promise((resolve, reject) => {
214
+ const onDrain = () => {
215
+ stream.off?.("error", onError);
216
+ resolve();
217
+ };
218
+ const onError = (error) => {
219
+ stream.off?.("drain", onDrain);
220
+ reject(error);
221
+ };
222
+ stream.once("drain", onDrain);
223
+ stream.once("error", onError);
224
+ });
225
+ }
110
226
  function base64ToBytes(b64) {
111
227
  const binary = atob(b64);
112
228
  const bytes = new Uint8Array(binary.length);