@daloyjs/core
v1.16.0
Published
DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify
Readme
DaloyJS
A contract-first TypeScript REST API framework built for AI-assisted teams. DaloyJS ships secure defaults and refuses to start in production on the misconfigurations it knows about. It has zero runtime dependencies and a hardened, provenance-signed publish pipeline. No framework can make an app fully secure or stop every supply-chain attack; DaloyJS documents what is on by default, what you must turn on, and the risks that remain.
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.
DaloyJS is maintained in the GitHub organization at https://github.com/daloyjs; the canonical framework repository is https://github.com/daloyjs/daloy.
Acknowledgements
We are grateful to the following companies for supporting DaloyJS with free access to their tools and services.
Built for the vibe-coding era
Most backend code is now written with AI. Non-developers describe an app and ship whatever the model produces; engineers let agents install dependencies, run tests, and open PRs. The code works on the happy path and gets deployed within the hour — usually with no body limits, skippable input validation, admin routes left mounted on the public app, and an outbound fetch that will happily call cloud-metadata endpoints. At the same time, the dependency tree itself has become the attack surface: self-replicating npm worms, malicious postinstall scripts, CI cache-poisoning, and slopsquatting — where an attacker pre-registers a package name an AI assistant is likely to hallucinate.
DaloyJS is built for exactly this moment, from two directions at once:
- Secure defaults in the runtime. Body limits, prototype-pollution-safe JSON, path-traversal rejection, request timeouts, header-injection guards, real
405s, and RFC 9457 problem+json with prod-mode redaction are on in the constructor, so the common gaps are closed when nobody remembered to close them. When the environment resolves to production (env: "production",production: true, orNODE_ENV=production), the app also refuses to boot on the unsafe configuration it knows about: weak session secrets, a state-changing session route with no CSRF protection,auth:declared with no auth hook, and more (see Refuse-to-boot guardrails). Some checks apply in every environment, such ascors()refusing a wildcard origin with credentials. A request carryingX-Forwarded-*or a vendor client-IP header gets a500in production until you declarebehindProxy. With no environment signal, or (from 1.5.4) an unrecognizedNODE_ENVsuch asstaging, each would-be refusal is logged as a warning instead. - A hardened supply chain.
@daloyjs/coreships zero runtime dependencies, is published with npm provenance and CycloneDX + SPDX SBOMs, and the pnpm scaffold settings (ignoreScripts, a 24-hour release-age cooldown, source-verified lockfiles) plus the repo's CIverify:*gates shrink the blast radius of the campaigns making headlines. They reduce that risk; they do not remove it (see SECURITY.md for what remains).
The point is that none of this costs you developer experience or portability: you keep contract-first DX in the league of ts-rest, Elysia, and FastAPI, and Hono-grade portability across Node, Bun, Deno, Workers, Vercel, Fastly, and Lambda. The aim is to make the secure path the path of least resistance. See Vibe Coding Security: what DaloyJS already blocks and the security docs.
DaloyJS exists to be the framework you'd build if you took the best ideas from each modern stack:
| You want | Today's best-of | What DaloyJS gives you |
| ---------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Best OpenAPI ergonomics | FastAPI | OpenAPI 3.1 from a single route definition; docs: true mounts /docs and /openapi.json. |
| Best Vercel / serverless / edge fit | Hono | Web-standard Request → Response core with adapters for Node, Bun, Deno, Cloudflare, Vercel, Fastly, Lambda, and Azure. |
| Mature Swagger / docs / ops in Node | Fastify | Encapsulated plugins, structured logger, graceful shutdown, request ids, and lifecycle hooks — all first-party. |
| Modern TS-first DX, Bun acceptable | Elysia | End-to-end typed handlers, typed context, and a typed in-process client — no codegen step required. |
| Best-in-class typed client codegen for any consumer | Hey API | One pnpm gen command emits a fully-typed fetch SDK from your live OpenAPI spec. |
| Contract-first typed client, no codegen | ts-rest | Your route definition is the contract: an in-process typed client with zero codegen, plus OpenAPI 3.1 + a Hey API SDK for consumers that can't import your types. |
| Opinionated DI / module architecture for large teams | NestJS | Plugin encapsulation, register() prefixes, and defineDependency() typed-DI with per-request dedup — no decorators. |
| Minimalist async middleware cascade | Koa | Koa-style Context on a web-standard core, with validation, OpenAPI, errors, and security headers in-box. |
| Services + real-time API framework | FeathersJS | First-party app.ws() with CSWSH refuse-to-boot guards, plus SSE / NDJSON streaming and raw Response passthrough (return a Vercel AI SDK stream straight from a handler) over explicit OpenAPI routes. |
| Battle-tested Node middleware compatibility | Express v5 | Regex-free trie router, schema-validated routes, RFC 9457 problem+json, and production refuse-to-boot guards on every runtime. |
| Portable supply-chain hardening for the apps you build | pnpm defaults + a zero-runtime-dep core | Hardened pnpm install settings, source-verified lockfiles, zero runtime deps, CycloneDX + SPDX SBOM, and npm provenance attestations. |
framework test suite passing · ≥90% line + function coverage / ≥92% branch coverage · typechecks on TypeScript 7 with `strict: true`
runs on Node, Bun, Deno, Cloudflare (and self-hosted workerd), Vercel, Fastly, Lambda, Azure Functions
~25M static-route ops/sec · ~2.2M dynamic-route ops/sec on M-class CPUWhy a new framework?
Each existing stack is excellent at one thing and forces tradeoffs everywhere else:
- Hono is small and portable but OpenAPI is a plugin afterthought.
- Elysia has gorgeous typing but pulls you toward Bun.
- Fastify has the best Node ops story but is Node-only and validation/types/docs are not unified.
- FastAPI has the best docs ergonomics — but it's Python.
- Hey API gives you the best typed client — but you still need a server that produces a clean spec.
- ts-rest gives lovely end-to-end types from a shared contract — but it rides on top of another server (Express/Fastify/Nest/Next), its safety is TypeScript-only, and OpenAPI and security are bring-your-own.
- npm leaves supply-chain protection up to you.
DaloyJS combines the wins:
- Explicit contracts, minimal ceremony. One
app.get(path, contract, handler)(or the matching shorthand, orapp.route({...})for reusable contracts) is the source of truth for validation, types, OpenAPI, the typed client, and contract tests. - One source of truth for validation, typing, and docs via Standard Schema — Zod 4 / Valibot / ArkType / TypeBox all work, no lock-in.
- Portable core, optional runtime optimizations — the only thing the core knows is
Request → Response. Adapters live at the edge. - 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.
- Tooling and inspectability over magic.
app.introspect()is a public API; contract-test runner is built in. - Optimize for large-team maintenance, not only solo-dev speed. Encapsulated plugins, decorators, request ids, structured logger.
Get started
For a new DaloyJS project, the recommended path is the official scaffolder:
pnpm create daloy@latest my-api
# or
npm create daloy@latest my-api
# add GitHub Actions + governance files for a company repo
pnpm create daloy@latest my-api --with-ci --code-owner @acme/securitycreate-daloy gives you a working project structure, runtime template selection, docs routes, OpenAPI wiring, production-oriented defaults, and an optional hardened GitHub security bundle without copying code out of the README.
See Scaffold a project for templates and flags.
Install core manually
DaloyJS is distributed via pnpm for supply-chain hygiene and backed by a hardened release pipeline — strict isolation, content-addressable store, deterministic lockfile, no phantom dependencies, SHA-pinned CI actions, npm staged publishing, and provenance attestations.
pnpm add @daloyjs/core zod@^4Zod 4 is the recommended validator for new DaloyJS apps because it is modern, smaller, and Standard-Schema-compatible. DaloyJS still accepts any Standard Schema validator, so teams can use Valibot, ArkType, TypeBox, or another compatible schema library when that better fits their stack.
On pnpm 11+, install hardening lives in pnpm-workspace.yaml with camelCase keys; pnpm 11 ignores these settings in .npmrc. The create-daloy pnpm scaffold writes:
# pnpm-workspace.yaml
minimumReleaseAge: 1440 # skip versions published in the last 24 hours
blockExoticSubdeps: true # transitive deps must come from the registry
ignoreScripts: true # no dependency lifecycle scripts
strictPeerDependencies: true
verifyStoreIntegrity: true
preferFrozenLockfile: trueThe same values are mirrored in the scaffold's .npmrc for npm and older tooling. This repo's own pnpm-workspace.yaml uses strictDepBuilds: true with an allowBuilds allowlist (currently esbuild only) instead of ignoreScripts, and CI runs pnpm verify:lockfile to reject git dependency sources and non-registry tarball URLs in pnpm-lock.yaml.
What these settings cover, and what they do not:
- Blocking lifecycle scripts stops install-script payloads (the Shai-Hulud kind of worm). It does not stop a payload that runs when your code imports a package, which is how the
chalk/debugandnode-ipccompromises executed; only the release-age cooldown and a committed lockfile help there. - The cooldown is consumer-side: it protects the installs that set it, and only against compromises caught within 24 hours.
- Slopsquatting, where an AI coding assistant hallucinates a package name (
request-promise-native2,@types/fastify-helmet, etc.) and an attacker registers it, is narrowed but not closed. The cooldown catches a squat that is detected and unpublished within a day. In the DaloyJS repo,pnpm verify:known-dep-names(scripts/verify-known-dep-names.ts) also refuses any top-level dependency name that is not on an explicit allowlist, so a hallucinated name needs a reviewed one-line diff; that gate runs in this repo's CI and is not scaffolded into your app.@daloyjs/core's zero-runtime-dep posture means a hallucinated dependency cannot land transitively through the published tarball. See SECURITY.md § Slopsquatting for the full mapping.
Run pnpm audit --prod regularly (or pnpm run audit in this repo) — and pnpm install --frozen-lockfile --ignore-scripts in CI.
SBOM + release automation
Daloy ships a CycloneDX 1.5 + SPDX 2.3 SBOM for both @daloyjs/core and create-daloy.
If you want to run the SBOM flow locally, the two commands are:
pnpm gen:sbom
pnpm verify:sbompnpm gen:sbom regenerates the publishable SBOM files for both packages. pnpm verify:sbom checks that the generated SBOMs match the current package manifests and that @daloyjs/core still declares zero runtime dependencies.
You do not need to remember to run those commands manually for CI or publish:
.github/workflows/ci.ymlrunspnpm gen:sbomandpnpm verify:sbomon every push tomainand every PR..github/workflows/release.ymlrerunspnpm gen:sbomandpnpm verify:sbombefore either npm staged-publish job is allowed to proceed.
That means a release will fail before publish if the SBOMs are missing, stale, or inconsistent with package.json.
The workflow stages releases on npm instead of making them installable immediately, so a maintainer still has to review the stage ID and approve it with npm MFA.
For maintainers, the safe rule is: use one publish path per version. Either publish through the protected GitHub release workflow, or publish locally for an exceptional case, but do not do both for the same version.
Hello world
import { z } from "zod";
import { App, secureHeaders, rateLimit, requestId } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
const app = new App({ bodyLimitBytes: 1024 * 1024, requestTimeoutMs: 5_000 });
// First-party security middleware — usually three plugins in other frameworks.
// `new App()` already auto-applies requestId() and secureHeaders() with these
// defaults; calling them explicitly only customizes (and replaces) the auto ones.
app.use(requestId());
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 120 }));
app.get(
"/books/:id",
{
tags: ["Books"],
request: { params: z.object({ id: z.string() }) },
responses: {
200: {
description: "Found",
body: z.object({ id: z.string(), title: z.string() }),
},
404: { description: "Not found" },
},
},
async ({ params }) => ({
status: 200,
body: { id: params.id, title: `Book ${params.id}` },
})
);
serve(app, { port: 3000 });For a concise route that keeps the full contract, use a method shorthand. The
method and path produce a stable operation id (getRoot here), while the
response schema preserves validation, OpenAPI, and data-exposure protection:
const app = new App().get(
"/",
{ responses: { 200: { body: z.object({ hello: z.string() }) } } },
() => ({ status: 200, body: { hello: "world" } })
);There is intentionally no app.get(path, handler) form. If a streaming or
proxy route genuinely needs to return a raw Response, declare a contract and
set acknowledgeNoResponseBodySchema: true; otherwise DaloyJS fails closed
rather than silently bypassing response-body validation. The same rule applies
when preBody or beforeHandle short-circuits with a successful raw response;
ordinary 4xx/5xx hook denials remain secure by default without an opt-out.
Add request schemas and other contract options as the endpoint grows:
app.post(
"/books",
{
request: { body: z.object({ title: z.string().min(1) }) },
responses: {
201: {
body: z.object({ id: z.string(), title: z.string() }),
},
},
},
({ body }) => ({ status: 201, body: { id: "1", title: body.title } })
);OpenAPI + Hey API typed client
DaloyJS produces a clean OpenAPI 3.1 document with zero plugins, then @hey-api/openapi-ts turns that into a fully typed TypeScript SDK that any consumer (your web app, mobile RN bundle, internal CLI) can drop in.
pnpm gen # writes generated/openapi.json + generated/client/That single command runs the two scripts:
// package.json
"scripts": {
"gen:openapi": "node scripts/dump-openapi.ts",
"gen:client": "openapi-ts",
"gen": "pnpm gen:openapi && pnpm gen:client"
}openapi-ts.config.ts:
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "./generated/openapi.json",
output: { path: "./generated/client", postProcess: ["prettier"] },
plugins: ["@hey-api/client-fetch", "@hey-api/typescript", "@hey-api/sdk"],
});For TypeScript consumers in the same monorepo you can skip codegen entirely and use the in-process typed client:
import { createInProcessClient } from "@daloyjs/core/client";
const client = createInProcessClient(app);
const r = await client.getBooksById({ params: { id: "1" } }); // operationId derived from GET /books/:id
// ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }For multi-file applications, export each contract with defineRoute() and
compose the literal tuple with app.registerRoutes([...]). This retains every
operation across module boundaries:
import { App, defineRoute } from "@daloyjs/core";
import { createInProcessClient } from "@daloyjs/core/client";
import { listBooksRoute } from "./routes/list-books.js";
import { getBookRoute } from "./routes/get-book.js";
const app = new App().registerRoutes([listBooksRoute, getBookRoute] as const);
const client = createInProcessClient(app); // typed, no socket or portChained route() calls remain supported. Avoid widening a composed app back to
a bare App annotation, because that deliberately discards its route tuple.
Callback-style group() and plugin register() provide runtime encapsulation,
but TypeScript cannot widen the already-created parent variable from inside a
callback. Export route tuples and compose them with registerRoutes() whenever
the no-codegen client must include those module routes.
Built-in docs UI (Scalar / Swagger UI / Redoc)
FastAPI-style. One line on the App constructor mounts GET /docs, GET /openapi.json, and GET /openapi.yaml for you,
with a strict CSP and CDN-hosted assets:
import { App } from "@daloyjs/core";
const app = new App({
openapi: { info: { title: "My API", version: "1.0.0" } },
docs: true, // mounts GET /docs (Scalar), GET /openapi.json, GET /openapi.yaml
});Use docs: "auto" to mount only when production: false, or the object form for full control:
new App({
openapi: { info: { title: "My API", version: "1.0.0" } },
docs: {
ui: "scalar", // "scalar" (default) | "swagger" | "redoc"
path: "/reference",
openapiPath: "/spec.json",
openapiYamlPath: "/spec.yaml", // or `false` to disable the YAML route
enabled: "auto", // default: off in production; `true` to publish there too
scalar: {
theme: "kepler",
customCss: ":root { --scalar-color-accent: #2563eb; }",
hideTestRequestButton: true,
},
swagger: {
docExpansion: "none",
displayRequestDuration: true,
},
auth: {
loginUrl: "/login", // or Auth0 / Entra ID / Okta / Clerk / Better Auth
label: "Sign in",
target: "popup",
},
tags: ["Docs"],
},
});Switch UIs with one word. Scalar and Swagger UI include developer request
consoles for authenticated endpoints: Scalar selects the first configured
OpenAPI security scheme by default, and Swagger UI keeps values from the
Authorize dialog across reloads. ui: "redoc" renders Redoc instead, and its
options are forwarded to Redoc.init via docs.redoc; Redoc displays security
requirements but is a read-only reference UI, not a Try It console:
new App({
openapi: { info: { title: "My API", version: "1.0.0" } },
docs: {
ui: "redoc",
redoc: { hideDownloadButtons: true, sortPropsAlphabetically: true },
},
});The scalar option is forwarded to Scalar's HTML API as JSON configuration,
with Daloy keeping the live openapiPath as the source. Use it for themes,
custom CSS, layout, auth defaults, and client visibility without copying the
HTML helper. The swagger option is forwarded to SwaggerUIBundle with Daloy
owning url / dom_id. Redoc spins up a blob: Web Worker for search, so the
auto-mounted /docs page widens its CSP with worker-src 'self' blob: for
ui: "redoc" only — Scalar and Swagger UI keep the tighter default.
Add docs.auth when you want all three docs UIs to expose the same visible
authorization launcher; point it at a local login route or any OAuth2/OIDC
provider entry point.
Prefer to mount manually? Import the helpers directly:
import { swaggerUiHtml, scalarHtml, redocHtml, htmlResponse } from "@daloyjs/core/docs";
import { generateOpenAPI } from "@daloyjs/core/openapi";The UI is always contract-accurate — never stale. create-daloy templates opt in with docs: "auto", so docs mount everywhere except production.
If you omit openapi.info, the portable defaults are DaloyJS API / 0.0.0.
Set openapi.info (or the top-level title, version, and description) for
real services. The core never reads the host filesystem, so the same docs
bundle runs unchanged on Workers, Vercel, Bun, Deno, and Node.
Prefer a factory? createApp(options) is exported as an alias of new App(options).
import { createApp } from "@daloyjs/core";
const app = createApp({ docs: true });daloy dev — one-command watch mode
daloy dev [entry] delegates to the host runtime's native watch tool, with no extra config:
| Runtime | Spawned command |
| ------- | --------------------------------------------------------------- |
| Node | node --watch <entry> |
| Bun | bun --hot <entry> |
| Deno | deno run --watch --allow-net --allow-env --allow-read <entry> |
Entry defaults to src/index.ts, src/main.ts, src/server.ts, or src/app.ts. Node.js (>= 22.18) runs TypeScript entries natively via built-in type stripping — no loader needed. Projects that rely on non-erasable syntax (enums, runtime namespaces, parameter properties) or extensionless relative imports can keep using a loader directly, e.g. node --import tsx --watch <entry> (and daloy inspect falls back to tsx automatically when the native load fails and tsx is installed).
Pass --runtime <node|bun|deno> to override runtime detection. This is required when running daloy dev from a package.json script on Bun or Deno, because the CLI binary's #!/usr/bin/env node shebang otherwise forces Node detection. The bun-basic template ships "dev": "daloy dev --runtime bun" for this reason.
Security guardrails
Some protections are enforced by the App core whenever the relevant request
path is used. Others are first-party middleware so applications can choose the
right CORS policy, rate-limit key, CSP, session secret, or CSRF rollout for their
deployment.
| Threat | Built-in behavior |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Body-size DoS | Core-enforced streamed read with a hard cap (default 1 MiB); Content-Length checked first. From 1.5.4 the cap covers every route, including handlers that read ctx.request directly (413 when exceeded); before that it applied only to routes with a body schema. |
| Prototype pollution | Core JSON parser strips __proto__ / constructor / prototype via reviver. |
| Malformed UTF-8 in bodies | From 1.16.0 the JSON, urlencoded and text body parsers decode strictly: an invalid or overlong UTF-8 sequence answers 400 instead of being validated with U+FFFD substituted (RFC 8259 §8.1). |
| Upload path traversal | From 1.16.0 fileField() refuses a file name with a path separator, control character, ./.., or over 255 characters (422) unless allowUnsafeFilenames: true, so file.name joined onto an upload directory cannot escape it. |
| Header / response splitting | Core header sanitizers reject CRLF + NUL. |
| Path traversal | Core router rejects .. segments and // before walking, and never binds encoded traversal (%2e%2e, ..%2F, ..%5C) or control characters into params. Encoded / in a param is a 404 unless the route sets allowEncodedSlash. |
| Slow-loris / hung handlers | Core requestTimeoutMs (default 30 s) answers 408 (or 504 / 503 via requestTimeoutStatus, 1.10.0) and aborts ctx.request.signal; from 1.5.4 it covers hooks, the body read and the handler (before: the handler only). The Node adapter also aborts the signal on client disconnect. Node adapter sets requestTimeout + headersTimeout + maxHeaderSize. |
| HTTP/2 Bomb / header-count flood | Core maxHeaderCount rejects requests with more than 100 header fields (431) before routing; the Node adapter sets server.maxHeadersCount and answers 431 once a request reaches the cap (a truncated flood looks identical at the limit), so 99 fields is the usable budget on Node. See SECURITY.md for the upstream HTTP/2 mitigations. |
| MIME sniffing | First-party secureHeaders() sets X-Content-Type-Options: nosniff; auto-applied by new App(). |
| Clickjacking | First-party secureHeaders() sets X-Frame-Options: DENY + CSP frame-ancestors 'none'; auto-applied by new App(). |
| XSS via injected scripts | First-party secureHeaders() provides a strict CSP default-src 'self' baseline; the directives-object form supports per-request nonces and Trusted Types (require-trusted-types-for 'script'). |
| Cross-origin leakage | First-party secureHeaders() sets cross-origin-opener-policy + cross-origin-resource-policy to same-origin; auto-applied by new App(). |
| CSRF | First-party csrf() ships two strategies: double-submit cookie (default) and Fetch-Metadata (Sec-Fetch-Site-based, tokenless); both with timing-safe verification. |
| Information disclosure (5xx) | 5xx problem+json detail is redacted unless the environment is explicitly development or test; an unset NODE_ENV redacts too. |
| Credential timing attacks | First-party timingSafeEqual() helper for tokens & signatures. |
| Brute-force / scraping | First-party rateLimit() with a fixed-window counter + Retry-After, keyed per client IP by default; Node/Bun/Deno scaffolded apps enable it. |
| Method confusion | Real 405 with Allow header, not a misleading 404. |
| CORS misconfig | First-party cors() requires an explicit allowlist and throws for * with credentials; from 1.5.4 it also refuses credentials: true with an origin list or predicate that allows every origin or "null". |
| Request correlation | First-party requestId() uses cryptographic ids; new App() generates one per request by default, and an installed requestId() replaces it. |
| Supply chain (portable) | pnpm scaffolds set ignoreScripts, minimumReleaseAge: 1440, verified store and a reproducible lockfile in pnpm-workspace.yaml; the --with-ci bundle (on by default, --no-ci skips it) adds pnpm verify:lockfile source verification. Every app also installs a zero-runtime-dependency @daloyjs/core published with CycloneDX + SPDX SBOM and npm provenance you can verify on install — regardless of where you host your repo. |
Portable vs. GitHub-only. The runtime protections and the published @daloyjs/core SBOM/provenance travel with every app you scaffold, no matter which CI host you use — GitLab, Bitbucket, Azure DevOps, Jenkins, on-prem, or laptop. The strongest install-time bundle is available when you choose pnpm, because minimum-release-age, blockExoticSubdeps, and the workspace gates are pnpm features. The @daloyjs/core release pipeline itself is separately hardened on GitHub Actions — no pull_request_target, no Actions cache, top-level permissions: {}, step-security/harden-runner, a protected release.yml, npm trusted publishing with --provenance, CodeQL + Opengrep dual SAST, OpenSSF Scorecard, zizmor, Dependabot, and CODEOWNERS — and create-daloy --with-ci ships the app-safe parts as an optional GitHub Actions bundle for teams on GitHub. See SECURITY.md and the supply-chain security docs.
Authentication, OAuth2 & OpenID Connect
DaloyJS is a resource server (and a toolkit for building a relying party), not an identity provider. Like Hono, Express, Fastify, or ASP.NET Core, it verifies and enforces tokens on each request — it does not ship a login UI, a user database, or an OAuth2 authorization server. It is not an "IdentityServer": it cannot, on its own, do what Duende IdentityServer, Keycloak, or Auth0 do (run login pages, manage clients/consent, mint tokens).
To add login you bring an OpenID Connect provider. It does not have to be Auth0/Okta/Clerk specifically — any standards-compliant IdP works, including managed (Auth0, Okta, Clerk, Microsoft Entra ID, AWS Cognito) and self-hosted open source (Keycloak, Zitadel, Ory, Authentik, Logto, SuperTokens, Dex). Don't build your own authorization server — verify tokens from a vetted one.
- API as a resource server (default): verify JWTs with
jwk()against the provider's JWKS (asymmetric-only algorithm allowlist,issuer/audienceenforced), then authorize per route withrequireScopes()orrequireRoles(). - Browser app: use the back-end-for-frontend (BFF) pattern — run the
authorization-code + PKCE flow server-side, keep tokens in a
session()cookie (never in JavaScript), and protect mutations withcsrf().
Read Auth architecture: where DaloyJS fits in OAuth2 & OpenID Connect
for the full picture, plus the per-provider guides under /docs/auth.
Performance
$ pnpm bench
static route lookup 25,024,295 ops/sec
dynamic 4-segment lookup 2,198,274 ops/sec
miss 8,203,816 ops/sec- After traversal checks, exact static routes resolve with an allocation-free
Map.getfast path — ~25M ops/sec. - Dynamic routes walk a segment trie in path-length time without backtracking; overlapping routes can require visiting additional branches.
- Body parsing is lazy and only runs when a route declares a body schema.
- Path normalization and splitting use index/character scans rather than regular expressions.
Cold-start tip (serverless / edge)
For deployments where every millisecond of startup matters (Lambda, Vercel, Cloudflare Workers, Fastly Compute), import App from the deep entry point instead of the barrel:
import { App } from "@daloyjs/core/app"; // ~10 ms faster to import than "@daloyjs/core"
import { serve } from "@daloyjs/core/node";The saving is measured by pnpm bench:serverless (median fresh-process import: about 19.5 ms for dist/app.js versus 29.3 ms for the barrel on an Apple M3 Max with Node v26.4.0). It is a one-off cost per cold start, not per request.
@daloyjs/core/app resolves to the same App class with the same constructor defaults — auto secureHeaders, request ids, body limits, request timeouts, prototype-pollution guards, problem+json redaction, and the boot guards behave exactly as with the barrel import. (fetchGuard() is never wired automatically: it is an opt-in wrapper you import and call for outbound requests, and it does not patch the global fetch.) The deep import only skips loading unrelated peripheral modules (jwk, jwt, multipart, websocket, streaming, compression, subdomains, etc.) that the barrel re-exports for convenience. If you use any of those, import them directly from their own subpaths (@daloyjs/core/jwk, @daloyjs/core/multipart, …) so each one is paid for only when used.
Long-lived Node servers will not notice the difference. This is purely a cold-start optimization for serverless.
Test client + contract tests
const res = await app.request("/books/1");
import { runContractTests } from "@daloyjs/core/contract";
const report = await runContractTests(app);
if (!report.ok) process.exit(1);The contract runner verifies that declared examples actually match their schemas, flags duplicate/missing operationIds, dead routes, and accidental body schemas on safe methods.
Gate it in CI two ways: daloy inspect --check <entry> exits non-zero on any error-level issue, or assert report.ok inside your test suite. Every create-daloy template ships a contract-gate test (tests/contract.test.ts, tests/contract_test.ts on Deno) wired into its test task, so scaffolded projects fail CI on a broken contract out of the box. For a localhost-only gate that runs before code leaves your machine, each template also ships an opt-in pre-push hook (.githooks/pre-push, enabled with hooks:install which points core.hooksPath at it); it runs daloy inspect --check on every git push and is bypassable with git push --no-verify.
Plugin encapsulation (Fastify-style)
const usersPlugin = {
name: "users",
register(app) {
app.get(
"/me",
{
operationId: "me",
responses: { 200: { description: "ok" } },
},
async () => ({ status: 200, body: { user: "alice" } })
);
},
};
app.register(usersPlugin, { prefix: "/users", tags: ["Users"] });
await app.ready();Multi-runtime
import { serve } from "@daloyjs/core/node"; // Node (Heroku, Railway, Render, Fly.io, any PaaS)
import { serve } from "@daloyjs/core/bun"; // Bun
import { serve } from "@daloyjs/core/deno"; // Deno
import { toFetchHandler } from "@daloyjs/core/cloudflare"; // Cloudflare Workers
import {
toFetchHandler as toVercelFetchHandler,
toRouteHandlers,
toWebHandler,
} from "@daloyjs/core/vercel"; // Vercel Node / Edge / Next.js
import { toNetlifyHandler } from "@daloyjs/core/netlify"; // Netlify Functions v2 / Edge Functions
import { installFastlyListener } from "@daloyjs/core/fastly"; // Fastly Compute
import { toLambdaHandler } from "@daloyjs/core/lambda"; // AWS Lambda / Netlify Functions v1 (legacy) / Lambda Function URLs
import { toAzureHandler } from "@daloyjs/core/azure"; // Azure Functions (Node.js v4 programming model)The core only ever sees Request → Response. Adapters live at the edge.
References
- Hey API — typed OpenAPI client codegen: https://heyapi.dev/openapi-ts/get-started
- Hono — portable web-standard router: https://hono.dev/docs/
- Elysia — TS-first DX & typed context: https://elysiajs.com/at-glance.html
- Fastify — production Node web framework: https://fastify.dev/docs/latest/Reference/
- ts-rest — contract-first, RPC-like client/server over REST: https://ts-rest.com/
- pnpm — strict, secure, content-addressable package manager: https://pnpm.io/motivation
- Standard Schema — universal validator interface: https://github.com/standard-schema/standard-schema
- RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457
Status
DaloyJS is at 1.16.0 — the public API is frozen and follows SemVer from here: no 1.x minor changes the API, and any deprecation gets at least one minor cycle before removal. @daloyjs/core (npm), create-daloy (npm), and @daloyjs/daloy (JSR) ship together at matching versions.
The release-candidate train that led here was largely adversarial: rc.1 through rc.9 carried remediations from live over-the-wire engagements against realistic multi-tenant apps, and the findings clustered in one place worth naming — composition, not individual modules. A responseCache() mounted ahead of the network-identity gates silently disabled them; the same order left rateLimit() never counting the requests a cache hit or an idempotent replay served; a forwarded-header resolver read the one X-Forwarded-For slot an attacker controls; idempotency() replayed a stored Set-Cookie. Each is fixed, each has a regression test, and several are now production refuse-to-boot guards so the unsafe wiring does not ship quietly. See the CHANGELOG for the full train and boot guards for the orders the framework now refuses.
Security fixes after 1.0.0 follow the published patch SLA (48h critical / 7d high / 30d medium) with a GitHub Security Advisory per confirmed issue, and the 1.x line carries a minimum 5-year security-update support period (see Support lifetime).
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 for the policy.
Routing, validation, and docs
- Contract-first routing with Standard Schema validation (Zod 4, Valibot, ArkType, TypeBox) and OpenAPI 3.1 generated from a single source of truth.
- Live OpenAPI 3.1 spec served as both JSON (
GET /openapi.json) and YAML (GET /openapi.yaml) whendocs: true, with a choice of Scalar (default), Swagger UI, or Redoc viadocs.ui, plus Scalar theming/custom CSS/auth defaults viadocs.scalar, Swagger UI options viadocs.swagger(including persisted Authorize credentials), Redoc options viadocs.redoc, and a provider-neutraldocs.authlauncher for local login routes or third-party OAuth2/OIDC providers. - Filesystem-free OpenAPI metadata: explicit
openapi.info/ top-level metadata wins, with portableDaloyJS API/0.0.0fallbacks on every runtime. - 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 asroute(); there is no silent two-argument schema bypass. - Multi-file contract composition through
defineRoute()+app.registerRoutes([...]), plus a typedcreateInProcessClient(app)that traverses the real pipeline without a socket. - Header/JWT/basic/mTLS authentication runs in the
preBodyphase before request-body I/O; body-aware WAF, idempotency, signature, and application middleware keep the validatedbeforeHandlephase. - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside
app.route()(WebDAV,TRACE,CONNECTrejected at the framework boundary). - AI-friendly route metadata via optional
meta: { examples, extensions, summary, description, tags }; examples are validated against your schemas at build time, surfaced as OpenAPIexamples+x-daloy-*extensions, and dumped asroutes.json/routes.yamlviadaloy inspect --ai. - Project docs site serves a curated
/llms.txtindex (Markdown map +.mdsiblings of every docs page, blog underOptional) so coding agents can load documentation without scraping HTML; see llms.txt docs. - Dependency-free MCP Streamable HTTP server helpers at
@daloyjs/core/mcp, speaking the stateless MCP2026-07-28revision and every earlier one on the same endpoint:createMcpHandler()exposes tools (withoutputSchema,annotations, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validatesOriginagainst DNS rebinding (with anallowedOriginsallowlist), whilemcpRoutes("/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. Modern requests getserver/discover, per-request_metavalidation,resultType+_meta.serverInfoon every result,ttlMs/cacheScopecaching hints (defaulting to no caching andprivatescope so an authorization-scoped tool list is never shared by a proxy), multi round-trip requests (input_required+ client retry, replacing server-initiated elicitation/sampling/roots), andx-mcp-headerparameter mirroring — with a missing or disagreeingMCP-Protocol-Version/Mcp-Method/Mcp-Name/Mcp-Param-*header rejected as-32020so a gateway can never route on one value while the tool executes another. Legacy clients keep theinitializehandshake unchanged. Everytools/callargument is validated server-side against the tool'sinputSchema(a dependency-free JSON-Schema subset —type/required/properties/additionalProperties/enum/const/bounds; exposed asvalidateMcpInput()) before the handler runs, rejecting a mismatch with JSON-RPC-32602; the JSON-RPC body is parsed with prototype-pollution-safesafeJsonParse; an unauthenticatedmcpRoutes()endpoint refuses to boot in production unless opted out withmcpRoutes(path, handler, { public: true });mcpRoutes(path, handler, { hooks })scopes auth to the POST transport; and tools, resources, and prompts read the principal your auth middleware verified asctx.state(forwarded bymcpRoutes()), so a tool never has to trust an identity header.mcpRoutes(path, handler, { methodAuth: { discovery, call } })gives discovery (server/discover,tools/list, and the other list methods) its own credential, so a backend that calls tools with delegated user tokens can list them at startup with a service token; it routes on theMcp-Methodheader, which the handler holds to the body, and falls back to thecallcredential when the header is missing. - Dependency-free A2A (Agent2Agent) 1.0 agent endpoint at
@daloyjs/core/a2a:createA2aHandler()turns an existing service into a peer other agents can discover and call, anda2aRoutes("/a2a", handler, { hooks: auth })mounts the public Agent Card at/.well-known/agent-card.jsonplus the JSON-RPC binding (SendMessage,GetTask,CancelTask,ListTasks). DaloyJS owns the protocol (card, envelope,A2A-Version/A2A-Extensionsnegotiation, validation withgoogle.rpcerror details, spec error codes); oneonMessagehandler owns the meaning and returns a direct message or a task result, so no skill router or LLM runs in core. Capabilities are derived and honest (streaming/pushNotifications/extendedAgentCardarefalseand answer-32004/-32003). An optionaltaskStore(boundedmemoryTaskStore()included) enables multi-turninput-requiredtasks, with every store call scoped by a requiredtaskOwnerthat fails closed (401) so one caller can never read another's tasks. SameOriginDNS-rebinding check as MCP, production HTTPS-only card URLs, redacted internal errors, and interop tested against the official@a2a-js/sdkclient.createA2aClient()is the other side, for delegating to remote agents: SSRF-guarded transport by default, no credentials on card discovery, the card's JSON-RPC URL pinned to the card's origin (orallowedOrigins) so a hostile card cannot redirect your token,https:only, no redirects, capped and validated responses, timeouts, and opt-in W3C trace propagation. Interop tested against the official SDK's reference server. - API lifecycle and breaking-change detection: mark routes
deprecatedor give them asunsetdate to emit RFC 8594Deprecation/Sunsetheaders and anx-sunsetOpenAPI extension, then gate CI withdiffOpenAPI()/ thedaloy diffcommand, which fail on a breaking change versus the last published spec. - In-process test client (
app.request()), contract-test runner (gated in CI viadaloy inspect --checkand shipped as a default test in everycreate-daloytemplate), in-process typed client, and Hey API codegen viapnpm gen.
Runtimes and deployment
- Adapters for Node (Heroku, Railway, Render, Fly.io, Google Cloud Run), Bun, Deno, Cloudflare Workers (also self-hosted workerd), Vercel Node / Edge / Next.js, Netlify Functions v2 / Edge Functions (
toNetlifyHandlerreads the client IP andwaitUntilfrom Netlify's context), Fastly Compute, AWS Lambda / Lambda Function URLs (including behind CloudFront), and Azure Functions, including backpressure-safe Lambda response streaming viatoLambdaStreamHandler(). - Azure Functions adapter at
@daloyjs/core/azure:toAzureHandler(app)serves the whole app from one catch-all v4 HTTP trigger, streams the request body in sobodyLimitBytesstill applies, moves everySet-Cookieonto Azure'scookieschannel (the host keeps one value per header name), answers an unparseable URL with400, awaits the OTLP flush, and supports streamed responses with{ stream: true }plusenableHttpStream. Typed structurally, so the core takes no dependency on@azure/functions. - Real-runtime CI smoke suite (
pnpm smoke:deno,smoke:bun,smoke:workerd): the publisheddist/build is served through the real Deno, Bun, and workerd adapters, on checksum-pinned runtime binaries, and probed for routing, secure headers, schema and body-limit rejection, problem+json, multipleSet-Cookie, and conn-info that never comes from a spoofable header. - Fastly Compute adapter flushes OTLP telemetry through the fetch event's
waitUntil(installFastlyListener), andtoFastlyHandleraccepts an optionalwaitUntil. - Deno: the adapter and
deno-basictemplate stay supported while the Deno runtime is in upstream maintenance mode; the docs cover moving off Deno Deploy (shutting down around April 2027) to Cloudflare Workers, a self-hosted container, or Node/Bun. daloy devwatch loop delegates to the host runtime's native watcher (node --watch,bun --hot, ordeno run --watch) with a--runtimeoverride for cross-runtimepackage.jsonscripts.pnpm create daloyscaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel templates, plus optional--with-ciGitHub 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 fromPATH, and skips a doomed dependency install when the chosen package manager is absent.- Container-first templates:
HEALTHCHECKto/readyz,STOPSIGNAL SIGTERM, non-root user,tinias PID 1; from 1.5.4 base images are pinned by digest. - 1.5.4 scaffold install hardening beyond pnpm: npm scaffolds get
ignore-scripts=trueandmin-release-age=1(days) in.npmrc; Yarn scaffolds get a.yarnrc.ymlwithenableScripts: falseandnpmMinimalAgeGate: "1d", plus a.yarnrcwithignore-scripts truefor Yarn 1 (which has no release-age setting). - 1.5.4: several
daloy doctorchecks read option names that do not exist and so never fired; they now read the real options. - Generated
deploy.ymlfor container templates signs every pushed GHCR image with Sigstore Cosign (keyless OIDC) and attaches an SPDX SBOM attestation so consumers cancosign verifyandcosign verify-attestation --type spdxjsoninstead of trusting the registry alone. - Pretty
printStartupBanner()/formatStartupBanner()helpers at@daloyjs/core/banner, used by every starter template (TTY +NO_COLOR/FORCE_COLORaware, ASCII fallback for dumb terminals).
Core security primitives
- Body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards.
- 1.5.4:
bodyLimitBytes(default 1 MiB) applies to every route, including handlers that readctx.requestdirectly (text(),json(),arrayBuffer(),formData(),body,clone()); an over-limit body answers413. Previously only routes with a request-body schema were capped. - 1.5.4:
requestTimeoutMs(default 30 s) covers thepreBody,beforeHandleandafterHandlehooks, the body read, and the handler, measured from the first asynchronous step (previously the handler only). On timeoutctx.request.signalaborts and the client gets408. - 1.11.0: stored-response replays (
responseCache(),idempotency()) can no longer answer before a laterbeforeHandleauth hook (markAuthHook()/some()), withapp.use()or insideevery(); Envoy XFCC parsing honors\"escapes and fails closed on malformed headers;jwk()backs off between refreshes during an IdP outage and caps JWKS bodies at 1 MiB;MemorySessionStoreis bounded (maxEntries, default 10,000). - 1.10.0:
requestTimeoutStatus: 504(or503) makes that deadline answerGateway Timeoutwhen it mostly trips on slow upstream work, so monitoring counts it as a server failure and clients do not retry it as their own fault. Any other value throws at construction; the Node adapter's slow-client408is unchanged. - 1.5.4: numeric options (
bodyLimitBytes,requestTimeoutMs,maxHeaderCount,jsonMaxKeys,jsonMaxDepth) must be finite non-negative integers, checked at construction in every environment; an explicitundefineduses the default.Number(process.env.UNSET)isNaN, which used to disable a limit silently and now throws.rateLimit()likewise refuses awindowMsthat is not a positive integer or amaxthat is not a non-negative integer (max: 0still refuses every request). - Router captures never bind encoded traversal (
..%2F,..%5C) or control characters, an encoded/in a capture is a404unless the route setsallowEncodedSlash: true, andexcept()matches the exact route the router dispatches to (trailing slashes included). - Request-smuggling defense: duplicate
Host,Content-Length, andTransfer-Encodingheaders are rejected. allowedHostsrefuses unknownHostvalues with400before routing (DNS rebinding);serve()on Node, Bun and Deno defaults tolocalhost,*.localhostand IP literals in development.- Opt-in
requireAuth: truerefuses, at registration, any route with no authentication hook unless it is markedpublic: true. ServerandX-Powered-Byheaders stripped by default.- Structured-log redaction defaults for authorization, cookie, password, token, and JWT-shaped values.
secureHeaders()auto-applied; user-installed instances automatically replace the auto one.- Cross-origin state-changing requests rejected with
403unless a route'scors()policy allows the origin. - 5xx problem+json
detailis redacted unless the environment is explicitly development or test; an unsetNODE_ENVredacts too. - Real 405 with
Allowheader instead of a misleading 404. Cache-Control: no-storebaked intoUnauthorizedError/ForbiddenError/TooManyRequestsErrorso every first-party auth 401 / 403 / 429 response is uncacheable.ServiceUnavailableError(503) for maintenance and outages: stabledaloyjs.devproblemtype, a validatedRetry-After, andCache-Control: no-storeso a CDN never keeps serving the outage.
Refuse-to-boot guardrails
The framework refuses to start (or to construct) on the unsafe configurations listed below. Most guards apply only when the environment resolves to production: env: "production", production: true, or NODE_ENV=production exactly. Items marked every environment throw regardless. The adapters run the route-table guards at startup through app.assertSecureConfig(), and daloy doctor (or app.assertSecureConfig({ production: true }) in a test) runs them in CI. With no environment signal at all, or (from 1.5.4) an unrecognized NODE_ENV such as staging or prod, each would-be refusal is logged as a warning instead of refused, and 1.5.4 also logs a one-time warning naming the unrecognized value. These guards catch the misconfigurations they know about; they are not a proof that an app is secure:
- Weak session secrets (under 32 bytes, a known placeholder, or a single repeated character),
cors({ origin: "*" }), andsession()+ state-changing route withoutcsrf(), in production. - Every environment:
cors({ origin: "*", credentials: true }); from 1.5.4 alsocredentials: truewith an origin predicate or list that allows every origin (detected by probing a canary origin) or allows"null". - Every environment, 1.5.4: invalid numeric limits (
NaN,Infinity, negatives, non-integers, strings) forbodyLimitBytes,requestTimeoutMs,maxHeaderCount,jsonMaxKeys,jsonMaxDepth, andrateLimit()windowMs/max; and a/0range (0.0.0.0/0,::/0) inbehindProxy: { cidrs }or a guard'strustedProxies, which would trust every peer. - Production, 1.5.4:
jwk()andcreateJwtVerifier()without anaudience, unlessallowAnyAudience: trueis passed (for a single-tenant private identity provider). - Production, 1.5.4:
createJwtSigner()/createJwtVerifier()with an HS* key that meets the 32-byte floor but is guessable: a single repeated byte, a short repeated pattern, a known placeholder, or fewer than 8 distinct byte values. - Every environment, 1.5.4:
session()withcookieOptions.httpOnly: false; in production alsocookieOptions.secure: false, unlessallowInsecureCookie: trueis set for a genuinely plain-HTTP deployment. - Not a boot guard, but related: in production, a request carrying a forwarded or client-IP header (
X-Forwarded-*,X-Real-IP, and vendor headersCF-Connecting-IP/Fly-Client-IP/True-Client-IP) gets a500untilbehindProxyis set. The check is per request, so the app still starts. - Shadow auth: a route that declares an
auth:requirement (advertised as protected in the OpenAPIsecuritylist) but installs no authentication hook to enforce it. Built-in auth middlewares (bearerAuth/basicAuth/jwk/httpSignatureAuth/clientCertAuth) satisfy the guard automatically; mark a custom auth hook (or upstream-gateway-enforced auth) withmarkAuthHook(). The boot check sees that an auth-marked hook is present; from 1.5.4 a request-time check also confirms it ran, soexcept(() => true, bearerAuth(...))orsome(auth, permissiveHook)no longer lets a protected route through: production answers500instead of running the handler. - Unauthenticated MCP: an
mcpRoutes()endpoint with no auth hook — MCP tools are model-controlled and side-effecting. Opt out for a genuinely public server withmcpRoutes(path, handler, { public: true }), or relax only discovery withmethodAuth(its combined hook satisfies the guard). Same presence-not-execution limit as shadow auth. - Unauthenticated A2A: an
a2aRoutes()JSON-RPC endpoint with no auth hook, since peer agents drive whateveronMessagedoes. Protect it witha2aRoutes(path, handler, { hooks: auth })(the Agent Card stays public) or opt out with{ public: true }. Same presence-not-execution limit as shadow auth. A card advertising a non-HTTPS, non-loopback URL is also refused in production. secureDefaults: falsein production unlessacknowledgeInsecureDefaults: trueis set, plus a once-per-processerrorlog naming every disabled default.preset: "internal-service"topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (autosecureHeaders,corsCrossOriginGuard,csrfboot guard, unconfiguredX-Forwarded-*guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot underevent: "security.preset.applied", and the live
