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

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 CHANGED
@@ -22,7 +22,7 @@
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
 
@@ -66,7 +66,7 @@ DaloyJS exists to be the framework you'd build if you took the best ideas from e
66
66
  | **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
67
 
68
68
  ```
69
- framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 6 with `strict: true`
69
+ framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 7 with `strict: true`
70
70
  runs on Node, Bun, Deno, Cloudflare, Vercel
71
71
  ~12.3M static-route ops/sec · ~1.5M dynamic-route ops/sec on M-class CPU
72
72
  ```
@@ -474,15 +474,17 @@ for the full picture, plus the per-provider guides under [`/docs/auth`](https://
474
474
 
475
475
  ```text
476
476
  $ 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
477
+ static route lookup 25,810,420 ops/sec
478
+ dynamic 4-segment lookup 2,105,121 ops/sec
479
+ miss 7,742,635 ops/sec
480
480
  ```
481
481
 
482
- - Static (no-param) routes resolve via a single `Map.get` **~12M ops/sec**.
482
+ - After traversal checks, exact static routes resolve with an allocation-free
483
+ `Map.get` fast path — **~26M ops/sec**.
483
484
  - Dynamic routes walk a trie, **O(path-segments)** regardless of route count.
484
485
  - Body parsing is lazy and only runs when a route declares a body schema.
485
- - No regex on the hot path.
486
+ - Path normalization and splitting use index/character scans rather than
487
+ regular expressions.
486
488
 
487
489
  ### Cold-start tip (serverless / edge)
488
490
 
@@ -572,7 +574,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
572
574
 
573
575
  ## Status
574
576
 
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.
577
+ DaloyJS is at **`1.0.0-rc.5`**, 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.
576
578
 
577
579
  **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.
578
580
 
@@ -592,7 +594,7 @@ DaloyJS is at **`1.0.0-rc.4`**, a security-hardening release candidate. Because
592
594
 
593
595
  ### Runtimes and deployment
594
596
 
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.
597
+ - 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
598
  - `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
599
  - `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
600
  - Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
@@ -662,7 +664,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
662
664
  - `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
665
  - 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
666
  - 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.
667
+ - `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
668
  - `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
667
669
  - `timing` / `timingSafeEqual` helpers.
668
670
  - `fileField({ magicBytes })` upload signature checks.
@@ -686,13 +688,13 @@ The framework refuses to start (or to construct) when configuration is unsafe:
686
688
 
687
689
  - Plugin encapsulation (Fastify-style), decorators, structured logging, request-id propagation.
688
690
  - Lifecycle events: `onPluginInstalled`, `onShutdown`, `onClose`.
689
- - Connection-draining graceful shutdown with `Connection: close` on `503` and in-flight responses.
691
+ - 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
692
  - `crashOnUnhandledRejection` default-on in production.
691
693
  - `app.healthcheck()` / `app.readinesscheck()` primitives with bearer-token auth and per-IP rate limit.
692
694
  - `disconnectStatusCode: 499` default for client-aborted requests.
693
695
  - `defineConfig({ schema, source })` boot-time typed configuration validation.
694
696
  - `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`.
697
+ - 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
698
  - `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
699
  - PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
698
700
  - 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.
@@ -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) {
@@ -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);
@@ -76,7 +76,14 @@ export interface NodeServerOptions {
76
76
  export interface NodeServerHandle {
77
77
  /** The underlying `node:http` `Server` instance, for advanced wiring (extra listeners, address introspection). */
78
78
  server: Server;
79
- /** Port the server was asked to listen on ({@link NodeServerOptions.port}, default `3000`). */
79
+ /**
80
+ * Bound TCP port once the server emits `listening`.
81
+ *
82
+ * Before the listener is ready, this is the requested
83
+ * {@link NodeServerOptions.port} (default `3000`). In particular, callers
84
+ * using `port: 0` must await the server's `listening` event before reading
85
+ * this property to receive the OS-assigned ephemeral port.
86
+ */
80
87
  port: number;
81
88
  /** Graceful shutdown: drains {@link App.shutdown} hooks, destroys WebSocket sockets, then closes the server. Idempotent. */
82
89
  close(): Promise<void>;