@daloyjs/core 1.0.0-rc.3 → 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 +103 -41
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +41 -5
- 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 +104 -19
- package/dist/app.d.ts +131 -11
- package/dist/app.js +305 -217
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +41 -1
- package/dist/client.d.ts +64 -18
- package/dist/client.js +36 -6
- package/dist/combine.d.ts +11 -11
- package/dist/combine.js +90 -47
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/docs.d.ts +5 -9
- package/dist/docs.js +36 -14
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +12 -3
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +13 -1
- package/dist/idempotency.js +2 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/internal-response.d.ts +15 -0
- package/dist/internal-response.js +27 -0
- package/dist/jwk.d.ts +11 -7
- package/dist/jwk.js +11 -7
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +21 -15
- package/dist/middleware.d.ts +48 -7
- package/dist/middleware.js +129 -43
- package/dist/mtls.d.ts +6 -5
- package/dist/mtls.js +8 -9
- package/dist/openapi.js +1 -1
- package/dist/pagination.js +4 -1
- package/dist/response-cache.js +2 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +9 -2
- package/dist/safe-redirect.js +29 -4
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +62 -0
- package/dist/security.js +220 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/tenancy.d.ts +2 -2
- package/dist/time-claims.js +3 -1
- package/dist/types.d.ts +85 -20
- package/dist/types.js +16 -1
- package/dist/waf.js +86 -26
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
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
|
|
|
@@ -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
|
|
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
|
```
|
|
@@ -87,7 +87,7 @@ Each existing stack is excellent at one thing and forces tradeoffs everywhere el
|
|
|
87
87
|
|
|
88
88
|
DaloyJS combines the wins:
|
|
89
89
|
|
|
90
|
-
1. **Explicit contracts, minimal ceremony.** One `app.route({...})` is the source of truth for validation, types, OpenAPI, the typed client, and contract tests.
|
|
90
|
+
1. **Explicit contracts, minimal ceremony.** One `app.get(path, contract, handler)` (or the matching shorthand, or `app.route({...})` for reusable contracts) is the source of truth for validation, types, OpenAPI, the typed client, and contract tests.
|
|
91
91
|
2. **One source of truth for validation, typing, and docs** via [Standard Schema](https://github.com/standard-schema/standard-schema) — Zod 4 / Valibot / ArkType / TypeBox all work, no lock-in.
|
|
92
92
|
3. **Portable core, optional runtime optimizations** — the only thing the core knows is `Request → Response`. Adapters live at the edge.
|
|
93
93
|
4. **Security guardrails by default — bad defaults are bugs.** The core enforces body limits, prototype-pollution-safe JSON, path-traversal rejection, request timeouts, content-type checks, and RFC 9457 problem+json errors with prod-mode redaction. First-party middleware covers Helmet-grade headers, CORS, CSRF, rate limits, request ids, and signed-cookie sessions.
|
|
@@ -180,28 +180,64 @@ app.use(requestId());
|
|
|
180
180
|
app.use(secureHeaders());
|
|
181
181
|
app.use(rateLimit({ windowMs: 60_000, max: 120 }));
|
|
182
182
|
|
|
183
|
-
app.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
183
|
+
app.get(
|
|
184
|
+
"/books/:id",
|
|
185
|
+
{
|
|
186
|
+
tags: ["Books"],
|
|
187
|
+
request: { params: z.object({ id: z.string() }) },
|
|
188
|
+
responses: {
|
|
189
|
+
200: {
|
|
190
|
+
description: "Found",
|
|
191
|
+
body: z.object({ id: z.string(), title: z.string() }),
|
|
192
|
+
},
|
|
193
|
+
404: { description: "Not found" },
|
|
193
194
|
},
|
|
194
|
-
404: { description: "Not found" },
|
|
195
195
|
},
|
|
196
|
-
|
|
196
|
+
async ({ params }) => ({
|
|
197
197
|
status: 200,
|
|
198
198
|
body: { id: params.id, title: `Book ${params.id}` },
|
|
199
199
|
}),
|
|
200
|
-
|
|
200
|
+
);
|
|
201
201
|
|
|
202
202
|
serve(app, { port: 3000 });
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
+
For a concise route that keeps the full contract, use a method shorthand. The
|
|
206
|
+
method and path produce a stable operation id (`getRoot` here), while the
|
|
207
|
+
response schema preserves validation, OpenAPI, and data-exposure protection:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const app = new App().get(
|
|
211
|
+
"/",
|
|
212
|
+
{ responses: { 200: { body: z.object({ hello: z.string() }) } } },
|
|
213
|
+
() => ({ status: 200, body: { hello: "world" } })
|
|
214
|
+
);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
There is intentionally no `app.get(path, handler)` form. If a streaming or
|
|
218
|
+
proxy route genuinely needs to return a raw `Response`, declare a contract and
|
|
219
|
+
set `acknowledgeNoResponseBodySchema: true`; otherwise DaloyJS fails closed
|
|
220
|
+
rather than silently bypassing response-body validation. The same rule applies
|
|
221
|
+
when `preBody` or `beforeHandle` short-circuits with a successful raw response;
|
|
222
|
+
ordinary `4xx`/`5xx` hook denials remain secure by default without an opt-out.
|
|
223
|
+
|
|
224
|
+
Add request schemas and other contract options as the endpoint grows:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
app.post(
|
|
228
|
+
"/books",
|
|
229
|
+
{
|
|
230
|
+
request: { body: z.object({ title: z.string().min(1) }) },
|
|
231
|
+
responses: {
|
|
232
|
+
201: {
|
|
233
|
+
body: z.object({ id: z.string(), title: z.string() }),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
({ body }) => ({ status: 201, body: { id: "1", title: body.title } })
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
205
241
|
---
|
|
206
242
|
|
|
207
243
|
## OpenAPI + Hey API typed client
|
|
@@ -237,15 +273,32 @@ export default defineConfig({
|
|
|
237
273
|
For TypeScript consumers in the same monorepo you can skip codegen entirely and use the **in-process typed client**:
|
|
238
274
|
|
|
239
275
|
```ts
|
|
240
|
-
import {
|
|
241
|
-
const client =
|
|
276
|
+
import { createInProcessClient } from "@daloyjs/core/client";
|
|
277
|
+
const client = createInProcessClient(app);
|
|
242
278
|
const r = await client.getBookById({ params: { id: "1" } });
|
|
243
279
|
// ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }
|
|
244
280
|
```
|
|
245
281
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
282
|
+
For multi-file applications, export each contract with `defineRoute()` and
|
|
283
|
+
compose the literal tuple with `app.registerRoutes([...])`. This retains every
|
|
284
|
+
operation across module boundaries:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { App, defineRoute } from "@daloyjs/core";
|
|
288
|
+
import { createInProcessClient } from "@daloyjs/core/client";
|
|
289
|
+
import { listBooksRoute } from "./routes/list-books.js";
|
|
290
|
+
import { getBookRoute } from "./routes/get-book.js";
|
|
291
|
+
|
|
292
|
+
const app = new App().registerRoutes([listBooksRoute, getBookRoute] as const);
|
|
293
|
+
const client = createInProcessClient(app); // typed, no socket or port
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Chained `route()` calls remain supported. Avoid widening a composed app back to
|
|
297
|
+
a bare `App` annotation, because that deliberately discards its route tuple.
|
|
298
|
+
Callback-style `group()` and plugin `register()` provide runtime encapsulation,
|
|
299
|
+
but TypeScript cannot widen the already-created parent variable from inside a
|
|
300
|
+
callback. Export route tuples and compose them with `registerRoutes()` whenever
|
|
301
|
+
the no-codegen client must include those module routes.
|
|
249
302
|
|
|
250
303
|
---
|
|
251
304
|
|
|
@@ -329,7 +382,10 @@ import { generateOpenAPI } from "@daloyjs/core/openapi";
|
|
|
329
382
|
|
|
330
383
|
The UI is always contract-accurate — never stale. `create-daloy` templates opt in with `docs: true`.
|
|
331
384
|
|
|
332
|
-
If you omit `openapi.info
|
|
385
|
+
If you omit `openapi.info`, the portable defaults are `DaloyJS API` / `0.0.0`.
|
|
386
|
+
Set `openapi.info` (or the top-level `title`, `version`, and `description`) for
|
|
387
|
+
real services. The core never reads the host filesystem, so the same docs
|
|
388
|
+
bundle runs unchanged on Workers, Vercel, Bun, Deno, and Node.
|
|
333
389
|
|
|
334
390
|
Prefer a factory? `createApp(options)` is exported as an alias of `new App(options)`.
|
|
335
391
|
|
|
@@ -418,15 +474,17 @@ for the full picture, plus the per-provider guides under [`/docs/auth`](https://
|
|
|
418
474
|
|
|
419
475
|
```text
|
|
420
476
|
$ pnpm bench
|
|
421
|
-
static route lookup
|
|
422
|
-
dynamic 4-segment lookup
|
|
423
|
-
miss
|
|
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
|
|
424
480
|
```
|
|
425
481
|
|
|
426
|
-
-
|
|
482
|
+
- After traversal checks, exact static routes resolve with an allocation-free
|
|
483
|
+
`Map.get` fast path — **~26M ops/sec**.
|
|
427
484
|
- Dynamic routes walk a trie, **O(path-segments)** regardless of route count.
|
|
428
485
|
- Body parsing is lazy and only runs when a route declares a body schema.
|
|
429
|
-
-
|
|
486
|
+
- Path normalization and splitting use index/character scans rather than
|
|
487
|
+
regular expressions.
|
|
430
488
|
|
|
431
489
|
### Cold-start tip (serverless / edge)
|
|
432
490
|
|
|
@@ -465,13 +523,14 @@ Gate it in CI two ways: `daloy inspect --check <entry>` exits non-zero on any er
|
|
|
465
523
|
const usersPlugin = {
|
|
466
524
|
name: "users",
|
|
467
525
|
register(app) {
|
|
468
|
-
app.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
526
|
+
app.get(
|
|
527
|
+
"/me",
|
|
528
|
+
{
|
|
529
|
+
operationId: "me",
|
|
530
|
+
responses: { 200: { description: "ok" } },
|
|
531
|
+
},
|
|
532
|
+
async () => ({ status: 200, body: { user: "alice" } }),
|
|
533
|
+
);
|
|
475
534
|
},
|
|
476
535
|
};
|
|
477
536
|
app.register(usersPlugin, { prefix: "/users", tags: ["Users"] });
|
|
@@ -515,7 +574,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
|
|
|
515
574
|
|
|
516
575
|
## Status
|
|
517
576
|
|
|
518
|
-
DaloyJS is at **`1.0.0-rc.
|
|
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.
|
|
519
578
|
|
|
520
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.
|
|
521
580
|
|
|
@@ -523,7 +582,10 @@ DaloyJS is at **`1.0.0-rc.3`**, a security-hardening release candidate. Because
|
|
|
523
582
|
|
|
524
583
|
- Contract-first routing with Standard Schema validation (Zod 4, Valibot, ArkType, TypeBox) and OpenAPI 3.1 generated from a single source of truth.
|
|
525
584
|
- Live OpenAPI 3.1 spec served as both JSON (`GET /openapi.json`) and YAML (`GET /openapi.yaml`) when `docs: true`, with a choice of Scalar (default), Swagger UI, or Redoc via `docs.ui`, plus Scalar theming/custom CSS/auth defaults via `docs.scalar`, Swagger UI options via `docs.swagger` (including persisted Authorize credentials), Redoc options via `docs.redoc`, and a provider-neutral `docs.auth` launcher for local login routes or third-party OAuth2/OIDC providers.
|
|
526
|
-
-
|
|
585
|
+
- Filesystem-free OpenAPI metadata: explicit `openapi.info` / top-level metadata wins, with portable `DaloyJS API` / `0.0.0` fallbacks on every runtime.
|
|
586
|
+
- Contract-required `get()` / `post()` / `put()` / `patch()` / `delete()` / `head()` shorthands with deterministic method+path operation IDs and the same validation, OpenAPI, response-exposure protection, and client typing as `route()`; there is no silent two-argument schema bypass.
|
|
587
|
+
- Multi-file contract composition through `defineRoute()` + `app.registerRoutes([...])`, plus a typed `createInProcessClient(app)` that traverses the real pipeline without a socket.
|
|
588
|
+
- Header/JWT/basic/mTLS authentication runs in the `preBody` phase before request-body I/O; body-aware WAF, idempotency, signature, and application middleware keep the validated `beforeHandle` phase.
|
|
527
589
|
- RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
|
|
528
590
|
- AI-friendly route metadata via optional `meta: { examples, extensions, summary, description, tags }`; examples are validated against your schemas at build time, surfaced as OpenAPI `examples` + `x-daloy-*` extensions, and dumped as `routes.json` / `routes.yaml` via `daloy inspect --ai`.
|
|
529
591
|
- Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
@@ -532,7 +594,7 @@ DaloyJS is at **`1.0.0-rc.3`**, a security-hardening release candidate. Because
|
|
|
532
594
|
|
|
533
595
|
### Runtimes and deployment
|
|
534
596
|
|
|
535
|
-
- 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()`.
|
|
536
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.
|
|
537
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.
|
|
538
600
|
- Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
|
|
@@ -600,9 +662,9 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
600
662
|
- `concurrencyLimit()` per-route / per-client concurrency limits + queueing at `@daloyjs/core/concurrency-limit`: HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies. HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies.
|
|
601
663
|
- `requestDecompression()` inbound decompression-bomb guard at `@daloyjs/core/request-decompression`: core is safe by omission (it never decompresses request bodies), so this is the opt-in middleware for services that must accept compressed uploads. Inflates `gzip` / `deflate` bodies behind two caps enforced **during** inflation so a zip bomb is aborted before it is fully materialised: an absolute `maxDecompressedBytes` (required) and an expansion-ratio `maxRatio` (default `100`), both rejecting with `413`. The compressed upload itself is bounded by `maxCompressedBytes` (default 1 MiB) before a byte is inflated. Unknown, non-allowlisted, runtime-unsupported, or **layered** (`gzip, gzip`) encodings are refused `415`; malformed streams `400`; bodyless / uncompressed / `identity` / `GET` / `HEAD` traffic passes through untouched. Runs in `onRequest` and stashes the inflated bytes so schema-validated bodies and raw-body handlers both see the decompressed payload. `onBomb` observability hook, exported `decompressRequestBody()` for custom flows. Built on web-standard `DecompressionStream` (brotli excluded — not in the spec). Zero runtime dependencies.
|
|
602
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.
|
|
603
|
-
- Built-in docs UI Subresource Integrity (SRI):
|
|
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.
|
|
604
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.
|
|
605
|
-
- `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.
|
|
606
668
|
- `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
|
|
607
669
|
- `timing` / `timingSafeEqual` helpers.
|
|
608
670
|
- `fileField({ magicBytes })` upload signature checks.
|
|
@@ -626,13 +688,13 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
626
688
|
|
|
627
689
|
- Plugin encapsulation (Fastify-style), decorators, structured logging, request-id propagation.
|
|
628
690
|
- Lifecycle events: `onPluginInstalled`, `onShutdown`, `onClose`.
|
|
629
|
-
- 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.
|
|
630
692
|
- `crashOnUnhandledRejection` default-on in production.
|
|
631
693
|
- `app.healthcheck()` / `app.readinesscheck()` primitives with bearer-token auth and per-IP rate limit.
|
|
632
694
|
- `disconnectStatusCode: 499` default for client-aborted requests.
|
|
633
695
|
- `defineConfig({ schema, source })` boot-time typed configuration validation.
|
|
634
696
|
- `app({ behindProxy })` declarative model (replaces `trustProxy`); `behindProxy.hops` collapses to the `(N+1)`-from-rightmost slot.
|
|
635
|
-
- 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.
|
|
636
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).
|
|
637
699
|
- PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
|
|
638
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.
|
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) {
|
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;
|