@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.
- package/README.md +34 -22
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +42 -7
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +117 -46
- package/dist/app.d.ts +30 -4
- package/dist/app.js +187 -45
- package/dist/auto-ban.js +1 -3
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +9 -6
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +14 -8
- package/dist/etag.js +12 -2
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/geo-block.js +4 -9
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +16 -9
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/ip-reputation.js +1 -1
- package/dist/ip-restriction.js +3 -12
- package/dist/jwt.js +12 -14
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +135 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +27 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +124 -32
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -22,17 +22,27 @@
|
|
|
22
22
|
[](https://github.com/daloyjs/daloy/blob/main/SECURITY.md)
|
|
24
24
|
|
|
25
|
-
>
|
|
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
|
-
##
|
|
31
|
+
## Acknowledgements
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 / ≥
|
|
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
|
|
478
|
-
dynamic 4-segment lookup
|
|
479
|
-
miss
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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 ≥
|
|
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, `
|
|
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`
|
|
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,
|
|
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
|
|
package/dist/adapters/bun.d.ts
CHANGED
|
@@ -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`)
|
|
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
|
/**
|
package/dist/adapters/bun.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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
|
}
|
package/dist/adapters/deno.js
CHANGED
|
@@ -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
|
|
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.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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;
|
package/dist/adapters/lambda.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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" ||
|
|
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);
|