Skip to content

Search docs

Jump between documentation pages.

Browse docs

API reference: Middleware & helpers

Built-in middleware, the every/some/except composition primitives, typed dependencies, config validation, structured logging, the startup banner, and connection-info helpers. Everything on this page is exported from the root @daloyjs/core barrel. See the API reference overview for the full module map.

A typical middleware stack
Platform hygieneapplied by secureDefaults
requestId()secureHeaders()
Traffic shaping
rateLimit()loadShedding()compression()
Authentication & access
bearerAuth()jwk()requireScopes()requireRoles()csrf()
Route handlertyped ctx, typed response
Hooks compose top to bottom. every(), some(), and except() combine layers. defineDependency() injects per-request values into ctx.state.

Built-in middleware

ts
requestId(opts?: RequestIdOptions): Hooks
secureHeaders(opts?: SecureHeadersOptions): Hooks
cors(opts: CorsOptions): Hooks
rateLimit(opts: RateLimitOptions): Hooks
loginThrottle(opts?: LoginThrottleOptions): Hooks    // default key: TCP peer (IPv6 grouped by ipv6Subnet)
timing(headerName?: string): Hooks
compression(opts?: CompressionOptions): Hooks        // skips SSE, NDJSON/JSON Lines/json-seq, Cache-Control: no-transform
bearerAuth(opts: BearerAuthOptions): Hooks
basicAuth(opts: BasicAuthOptions): Hooks
markAuthHook(hooks: Hooks): Hooks
const AUTH_HOOK_MARKER: unique symbol  // stamped by built-ins and markAuthHook()
csrf(opts?: CsrfOptions): Hooks
fetchMetadata(opts?: FetchMetadataOptions): Hooks   // Sec-Fetch-Site/Mode/Dest enforcement
requireScopes(scopes: string[], opts?: RequireScopesOptions): Hooks
                                                      // { phase?: "beforeHandle" | "preBody"; match?: "all" | "any" }
requireRoles(roles: string[], opts?: RequireRolesOptions): Hooks
                                                      // { claim?: string | (claims) => unknown; match?: "all" | "any" }; always preBody
ipRestriction(opts: IpRestrictionOptions): Hooks    // CIDR allow/deny
loadShedding(opts?: LoadSheddingOptions): Hooks
etag(opts?: ETagOptions): Hooks                      // 304 + Set-Cookie / Cache-Control skip
  // SSE, NDJSON, unknown-length, and over-maxBytes bodies are sent untagged

RateLimitOptions

A limiter placed before preBody auth also counts requests that auth rejects by throwing, so failed credential guesses consume the budget.

By default the limiter sends X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Set standardHeaders: "draft-8" to also send the IETF RateLimit and RateLimit-Policy headers. Add legacyHeaders: false to send only the IETF pair. The option names and the policy name match express-rate-limit, so clients written against an Express API keep working after a move to Daloy.

ts
app.use(rateLimit({ windowMs: 60_000, max: 120, standardHeaders: "draft-8", legacyHeaders: false }));
// RateLimit-Policy: "120-in-60sec"; q=120; w=60
// RateLimit: "120-in-60sec"; r=119; t=60

t is the number of seconds until the window resets. No partition key (pk) is sent, so the bucket key never leaves the server. Turning headers off never turns enforcement off. Browsers can only read these headers cross-origin when they are listed in cors({ exposedHeaders }).

windowMsrequired
number
Default:-
Fixed-window width in milliseconds (e.g. 60_000 for one minute).
maxrequired
number
Default:-
Maximum allowed requests per windowMs per key.
keyGenerator
(ctx: RateLimitContext) => string
Default:-
Derive the bucket key from ctx. Default: the trusted forwarded client IP when proxy trust is configured, otherwise the unspoofable TCP peer (peer:<ip>), and one shared "global" bucket only when the runtime exposes no peer. Never key on a raw client-supplied header. When the limiter precedes preBody authentication, it also receives failed attempts before body I/O; rely only on the raw request and state populated by earlier preBody hooks.When none of keyGenerator, trustProxyHeaders, trustedHops, or trustedProxies is set, the default key follows the App's behindProxy posture (via resolveClientIp), so behind a proxy each client gets its own bucket instead of sharing the proxy's. behindProxy must describe your real topology: prefer { cidrs } when you know the proxy addresses, because { hops } trusts the right-most forwarded entries from anyone who can reach the app directly.
ipv6Subnet
number
Default:-
IPv6 prefix length the default IP-based key groups on (1-128). Default 64: one subscriber normally holds a whole /64, so keying on the full address would give an attacker a fresh bucket per address. IPv4 and IPv4-mapped addresses are always keyed on the canonical dotted address. Ignored when keyGenerator is set.Since 1.3.7
store
RateLimitStore
Default:-
Custom backend (default: shared in-memory store).
trustProxyHeaders
boolean
Default:-
Trust x-forwarded-for / x-real-ip when deriving the default key. Off by default because those headers are client-spoofable unless your reverse proxy strips and rewrites them.When enabled, the key is the **rightmost** X-Forwarded-For entry — the one your immediate proxy appended — never the attacker-influenceable leftmost one, so rotating spoofed left entries cannot evade the limit. Behind more than one proxy hop, set trustedHops instead; to also verify the peer is one of YOUR proxies (required when the origin itself can be reached), set trustedProxies.
trustedHops
number
Default:-
Declare exactly how many proxy hops sit between Daloy and the public internet. Implies proxy-header trust and derives the default key that many entries from the right of X-Forwarded-For via resolveForwardedClientIp. Must be an integer in [1, 64]; validated at construction. Ignored when a custom keyGenerator is supplied.
trustedProxies
readonly string[]
Default:-
Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate peer's address. Forwarded headers feed the bucket key only when the TCP socket actually talking to the adapter matches the list — the one property a remote client cannot spoof — so a direct-to-origin attacker can neither evade the limit with rotating spoofed XFF nor burn another identity's bucket. Implies proxy-header trust at one hop unless trustedHops says otherwise; validated and compiled at construction. On peer-less edge platforms verification fails closed. Ignored when a custom keyGenerator is supplied.
retryAfter
boolean
Default:-
When true, set Retry-After header on 429. Default: true.
standardHeaders
"draft-8"
Default:-
Also emit the IETF RateLimit / RateLimit-Policy headers (draft-ietf-httpapi-ratelimit-headers-08), e.g. RateLimit-Policy: "100-in-60sec"; q=100; w=60 and RateLimit: "100-in-60sec"; r=42; t=17, where t is seconds until the window resets. The policy name matches express-rate-limit's, so clients written against it keep working. Off by default. No partition key (pk) is sent, so the bucket key never leaves the server.Since 1.9.0
legacyHeaders
boolean
Default:-
Emit the X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset headers. Default: true. Set false together with standardHeaders to send only the IETF headers.Since 1.9.0
groupId
string
Default:-
Share a single bucket across every rateLimit() call that declares the same groupId. Use this to enforce one combined limit across related routes (e.g. /login, /login/otp, and /password-reset all spend from the same per-IP "auth" bucket). Without groupId, each rateLimit() call gets its own independent in-memory store.Cooperation is only meaningful for the default in-memory store; when an explicit store is supplied the developer is responsible for keying it. The framework prepends the groupId to the derived key so two groups never collide in a shared store either.Since 0.19.0
Generated from RateLimitOptions in src/middleware.ts.

ETagOptions

weak
boolean
Default:-
Emit a weak ETag (W/"..."). Default false — strong ETags allow range-request resumption and prevent byte-level cache poisoning by intermediaries.
generator
(body: Uint8Array) => string | Promise<string>
Default:-
Override the digest function. Default: SHA-1 via WebCrypto. SHA-1 is appropriate here because the ETag is a cache identifier, not a security boundary; collisions only cause a stale 304, never a confidentiality break.
maxBytes
number
Default:-
Largest body, in bytes, that is buffered to compute a tag. Bodies whose Content-Length exceeds this are sent untagged instead of buffered. Default 1_048_576 (1 MiB). Must be a non-negative finite number.Since 1.3.7
Generated from ETagOptions in src/etag.ts.

requireScopes() options

requireScopes() reads ctx.state.user.scopes, which jwk(), bearerAuth() and other auth helpers fill in. jwk() builds that list from the token's scope claim (space-delimited string), or, when scope is absent, from scp (a space-delimited string as Microsoft Entra ID issues it since 1.10.0, or an array) or scopes (array). A caller without credentials gets 401 with a WWW-Authenticate: Bearer scope="..." challenge; a caller with too few scopes gets 403.

By default the check runs in beforeHandle, after the request body is read and validated. That means a valid token with too few scopes and a malformed body gets 400, and the server parses a body from a caller it is about to refuse. Since 1.10.0 you can authorize in preBody instead, right after header-only authentication:

ts
import { App, every, jwk, requireScopes } from "@daloyjs/core";

app.use(every(
  jwk({ jwks, algorithms: ["RS256"], issuer, audience, maxLifetimeSeconds: 86_400 }),
  // 403 before any body I/O; owning either scope is enough.
  requireScopes(["agent.chat", "agent.admin"], { phase: "preBody", match: "any" }),
));
  • phase: "preBody" runs the same check before the body is read: missing credentials are 401, insufficient scope is 403, whatever the body contains. The authentication hook must run first (earlier in every(), or registered earlier with app.use()), or every request is a 401.
  • match: "any" accepts a caller that owns at least one listed scope. It requires phase: "preBody", and construction throws otherwise. Stored-response replays (idempotency(), responseCache()) run in beforeHandle and only understand all-of requirements, so an any-of check must reject before they can serve a stored response. All-of guards keep feeding those replay checks; any-of guards do not.
  • The default (phase: "beforeHandle", match: "all") is unchanged, so existing apps that authenticate in beforeHandle keep working.
phase
"beforeHandle" | "preBody"
Default:"beforeHandle"
Lifecycle phase the scope check runs in. "beforeHandle" runs after the request body is read and validated, so a caller with too few scopes and a malformed body gets 400. "preBody" runs before any body I/O, right after preBody authentication such as jwk() / bearerAuth(), so an under-scoped caller always gets 403 and its body is never parsed. The authentication hook must run first (registered earlier, or earlier inside every()); otherwise every request is a 401.
match
"all" | "any"
Default:"all"
How the listed scopes combine. "all" requires every listed scope; "any" accepts a caller that owns at least one. "any" requires phase: "preBody": stored-response replays (idempotency(), responseCache()) only understand all-of requirements, so an any-of check must reject before any beforeHandle replay can run. Any-of guards are not added to the replay scope requirements.
Generated from RequireScopesOptions in src/middleware.ts.

requireRoles() options

requireRoles() authorizes by role, group, or permission instead of OAuth scope. It reads a list-valued claim from ctx.state.user.claims (what jwk() stamps), or from ctx.state.user itself when an auth helper stamped a plain object (basicAuth(), or bearerAuth() whose validate returned one). No caller is a 401 with WWW-Authenticate: Bearer; a caller without the roles is a 403 that does not name them.

ts
import { jwk, requireRoles } from "@daloyjs/core";

app.use(jwk({ jwks, algorithms: ["RS256"], issuer, audience }));

requireRoles(["Books.Admin"]);                                   // Entra app roles (`roles`)
requireRoles(["admins"], { claim: "cognito:groups" });           // a named top-level claim
requireRoles(["delete:books"], { claim: "permissions" });        // Auth0 RBAC
requireRoles(["editor", "admin"], {                              // Keycloak, any one role
  claim: (c) => (c.realm_access as { roles?: unknown } | undefined)?.roles,
  match: "any",
});
  • A string claim is one top-level claim name, read verbatim, so names containing dots or colons work. __proto__, constructor, and prototype are refused at construction, and only the claims object's own properties are read.
  • The claim value may be an array of strings or a space-delimited string. Anything else, a missing claim, or a reader that throws counts as no roles (fail closed).
  • The check always runs in preBody, before the body is read and before idempotency() or responseCache() could replay a stored response, so there is no phase option. Register the authentication hook first.
claim
string | RoleClaimReader
Default:"roles"
Where the granted roles live. A string names one top-level claim, read verbatim (so namespaced names such as "https://example.com/roles" or "cognito:groups" work); its value may be an array of strings or a space-delimited string. Pass a RoleClaimReader for nested or object-shaped claims, e.g. Keycloak's realm_access.roles.
match
"all" | "any"
Default:"all"
"all" requires every listed role; "any" accepts the caller when it holds at least one of them.
Generated from RequireRolesOptions in src/middleware.ts.

BearerAuthOptions

Since 1.12.0, validate may return a user-shaped object instead of true. It is stamped on ctx.state.user, so requireScopes() (reading scopes) and requireRoles() work with any verifier: a provider SDK, an introspection call, or a test stub. Return { sub, scopes, claims } to match jwk(). See pattern B on the auth overview.

bearerAuth() and jwk() pass any token value through verbatim, with no length, charset or prefix check, so new token formats keep working. The one exception is a token containing whitespace or a comma. Duplicate Authorization headers are comma-joined into one value (Bearer a, Bearer b), so such a token is a 401 and never reaches validate() or the JWT verifier.

validaterequired
(token: string) => boolean | object | Promise<boolean | object>
Default:-
Token check (signature, format, or a provider SDK's verify call). Return a falsy value to reject the request with 403, or true to accept it.Since 1.12.0 it may instead resolve to a user-shaped object, which is stamped on ctx.state.user, so requireScopes() (reading scopes) and requireRoles() (reading claims, or the object itself) work with any verifier, not only jwk(). Return { sub, scopes, claims } to match the shape jwk() produces.
realm
string
Default:-
WWW-Authenticate realm. Default: "api".
verify
BearerAuthVerifyHook<string>
Default:-
Optional per-request revalidation hook. Called after validate accepts the token. Returning false rejects the request with 403; returning true or undefined accepts. Use for revocation lists, token-version counters, etc.Since 0.22.0
Generated from BearerAuthOptions in src/middleware.ts.

Composition primitives

ts
every(...layers: Hooks[]): Hooks      // run every lifecycle phase in order
some (...layers: Hooks[]): Hooks      // pass the first successful preBody/beforeHandle auth gate
except(when: ExceptPredicate, hooks: Hooks): Hooks  // exempt paths from preBody + beforeHandle gates

type ExceptPredicate =
  | string                            // path glob: "*" = one segment, "**" = any suffix
  | string[]                          // any-of globs
  | ((ctx) => boolean | Promise<boolean>);

// Globs match the canonical path the router dispatches on. The Node adapter
// resolves raw %2e%2e dot segments and folds \ to / before routing, and the
// Node and Lambda adapters refuse (400) a Host containing \ / ? # @ % or
// whitespace, so a crafted target cannot slip past an except() glob. Route
// params and wildcards never bind "." or "..".

Dependencies (typed DI chain)

ts
defineDependency<TName, TValue, TStateKey>(opts: {
  name: TName;
  dependsOn?: readonly string[];      // refuses cycles at registration
  stateKey?: TStateKey;
  resolve: (ctx) => TValue | Promise<TValue>;
}): DependencyHooks   // per-request cached; runs once per dependency per request

Configuration

ts
defineConfig<S extends StandardSchemaV1>(opts: {
  schema: S;
  source?: ConfigSource;               // default: "env" (process.env)
  stderr?: { write(chunk: string): void } | false;
}): Promise<StandardSchemaV1.InferOutput<S>>;
  // Async. Validates once at startup; throws ConfigValidationError on missing/invalid values.

type ConfigSource =
  | "env"
  | { kind: "env";    env: Record<string, string | undefined> }
  | { kind: "file";   path: string; parse?: (text: string) => unknown }
  | { kind: "object"; data: Record<string, unknown> }
  | { kind: "custom"; resolve: () => Promise<Record<string, unknown>> };

class ConfigValidationError extends Error {
  readonly issues: ReadonlyArray<{ key: string; message: string }>;
}

Logging

ts
type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";

createLogger(opts?: ConsoleLoggerOptions): Logger;
const noopLogger: Logger;
const DEFAULT_REDACT_KEYS: ReadonlyArray<string>;  // password, token, secret, authorization, ...
sanitizeUrlForLog(url: string): string;             // redacts sensitive query values
sanitizeUrlQueryForLog(search: string): string;     // same rules for a bare query string (since 1.3.7)
// Redaction copies on write: it never mutates the objects you pass to a log call.

interface ConsoleLoggerOptions {
  level?: LogLevel;
  bindings?: Record<string, unknown>;
  write?: (line: string) => void;
  redact?: LoggerRedactionOptions;     // { keys?, replacer? }
}

interface Logger {
  trace(obj?, msg?): void;
  debug(obj?, msg?): void;
  info (obj?, msg?): void;
  warn (obj?, msg?): void;
  error(obj?, msg?): void;
  fatal(obj?, msg?): void;
  child(bindings: Record<string, unknown>): Logger;
}

Startup banner

ts
interface StartupBannerLink { label: string; url: string }
interface StartupBannerOptions {
  name?: string;        // default: "DaloyJS"
  version?: string;
  url: string;
  runtime?: string;     // e.g. "Node.js", "Bun"
  links?: StartupBannerLink[];
  color?: boolean;
  ascii?: boolean;
}

formatStartupBanner(opts: StartupBannerOptions): string;
printStartupBanner(opts: StartupBannerOptions): void;

Connection info & proxy posture

ts
type BehindProxyConfig = "none" | "loopback" | { hops: number } | { cidrs: readonly string[] };
interface ConnInfo { remoteAddress?: string; remotePort?: number; tls?: boolean }

getConnInfo(req: Request): ConnInfo | undefined;
setConnInfo(req: Request, info: ConnInfo): void;   // adapter helper
assertBehindProxy(cfg: BehindProxyConfig | undefined): void;
resolveClientIp(ctx, cfg?: BehindProxyConfig): string | undefined;
readRemoteAddress(ctx): string | undefined;
readRemotePort(ctx): number | undefined;
pickForwardedForByHops(header: string, hops: number): string | undefined;

// Spoof-resistant client IP: reads `hops` entries from the RIGHT of
// X-Forwarded-For (the slots your own proxy chain wrote). Falls back to
// X-Real-IP only when hops === 1; past that a chain shorter than the
// declaration yields undefined rather than a caller-settable header.
resolveForwardedClientIp(req: Request, hops?: number): string | undefined;

Subdomains (Public-Suffix-aware)

ts
subdomains(hostname: string, opts?: SubdomainsOptions): SubdomainsResult;

interface SubdomainsOptions {
  baseDomain?: string;                 // pin the registrable base; skips the PSL entirely
  extraSuffixes?: readonly string[];   // extra shared-hosting suffixes
  production?: boolean;                // default: false; throws on a stale PSL snapshot
}

interface SubdomainsResult {
  baseDomain: string;                  // e.g. "example.co.uk"
  subdomain: string;                   // e.g. "api.tenant"; "" when none
  labels: readonly string[];           // e.g. ["api", "tenant"]
}

const PSL_SNAPSHOT_DATE: string;       // ISO date of the bundled PSL snapshot
const MAX_SNAPSHOT_AGE_DAYS: number;   // 90; refuses to use a stale snapshot
const PSL_PUBLIC_SUFFIXES: readonly string[];

// The staleness check applies only to the PSL path. A call with baseDomain
// never reads the snapshot, so baseDomain + production: true always works.

Next up: security & auth helpers.