Skip to content

Search docs

Jump between documentation pages.

Browse docs

API reference: App & routing

The App class, route registration, hook and context types, the per-request dispatch order, error classes, and the Standard Schema validation helpers. Everything on this page is exported from the root @daloyjs/core barrel. For the module map and a runnable starter snippet, see the API reference overview.

Per-request dispatch order
  1. 01hookonRequest(req)
  2. 02routerRoute match
  3. 03hookpreBody(ctx) raw, no body
  4. 04validateParse & validateparams / query / headers / body
  5. 05hookbeforeHandle(ctx)
  6. 06routehandler(ctx)
  7. 07hookafterHandle(ctx, result)
  8. 08hookonSend / onResponse(res)
Header-only auth runs in preBody before the body is ever read. Body-aware guards (WAF, idempotency) run in beforeHandle with the parsed body.

class App

ts
new App(options?: AppOptions)
createApp(options?: AppOptions): App  // identical to `new App(...)`, point-free factory

interface AppOptions {
  // OpenAPI document metadata
  title?: string;
  version?: string;
  description?: string;

  // Secure-by-default master switches
  secureDefaults?: boolean;            // default: true
  acknowledgeInsecureDefaults?: boolean; // required when disabling defaults in production
  preset?: "internal-service";         // service-to-service preset (browser guards off)

  // Request limits (1.5.4: finite non-negative integers, else construction throws)
  bodyLimitBytes?: number;             // default: 1 MiB; every route, raw ctx.request reads included (1.5.4)
  allowedContentTypes?: string[];      // default: ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"]
  requestTimeoutMs?: number;           // default: 30_000; 0 disables; covers hooks + body read + handler (1.5.4)
  requestTimeoutStatus?: 408 | 503 | 504; // default: 408; status the requestTimeoutMs deadline answers with (1.10.0)
  maxHeaderCount?: number;             // default: 100; 0 disables. Node also 431s at the parser cap (HTTP + WS upgrade); usable budget is 99 at default
  multipart?: { maxFileBytes?: number; maxFields?: number; maxFiles?: number };

  // Environment & logging
  production?: boolean;                // defaults from NODE_ENV
  env?: "development" | "production" | "test";  // "production" also forces mcpRoutes() error redaction
  logger?: Logger | { level?: LogLevel } | false;
  stripServerHeaders?: boolean;        // default: true

  // Header / cross-origin guards (secure-by-default)
  secureHeaders?: SecureHeadersOptions | false;
  corsCrossOriginGuard?: boolean;      // default: true
  csrf?: "off";                        // opt-out for the session+CSRF boot guard
  trustProxy?: boolean;                // legacy tri-state guard (undefined refuses X-Forwarded-*)
  behindProxy?: BehindProxyConfig;     // "none" | "loopback" | { hops: N } | { cidrs: [...] } (a /0 cidr throws, 1.5.4)

  // Operational
  disconnectStatusCode?: number;       // default: 499 (client-disconnect log code)
  crashOnUnhandledRejection?: boolean; // default: true in production
  loadShedding?: boolean | LoadSheddingOptions;

  // Validation, hooks, mock mode
  validateResponses?: boolean;         // default: true
  mockMode?: boolean;
  hooks?: Hooks;                       // all-request hooks, unmatched 404/405 included (see below)

  // OpenAPI / docs auto-mount
  openapi?: AppOpenAPIOptions;
  docs?: boolean | "auto" | DocsRouteOptions;  // default: false (create-daloy templates set true)
}

// Routing
app.route<P, Req, Res>(def: RouteDefinition<P, Req, Res>): App
defineRoute(def: RouteDefinition): RouteDefinition  // literal-preserving identity helper
app.registerRoutes(defs: readonly RouteDefinition[]): App
app.get(path, contract, handler): App                // also post/put/patch/delete/head
app.ws<P, TData>(path: P, handler: WebSocketHandler<P, AppState, TData>): App
app.group(prefix, { tags?, hooks?, auth? }, register: (child: App) => void): App
app.use(hooks: Hooks): App
app.decorate<K, V>(key: K, value: V, { override? }?): App

// Plugins / lifecycle
app.register(plugin: { name?, seed?, stateful?, dependencies?, extensions?, register? }
                    | ((app: App) => void | Promise<void>),
             { prefix?, tags?, hooks?, auth? }?): App
app.onPluginInstalled(listener: (info: PluginInstalledEvent) => void | Promise<void>): App
app.onShutdown        (listener: (info: ShutdownEvent)        => void | Promise<void>): App
app.onClose           (cleanup:  () => void | Promise<void>): App

// Built-in routes
app.healthcheck    (opts?: HealthRouteOptions): App     // GET /healthz by default (opts.path to override)
app.readinesscheck (opts?: ReadinessCheckOptions): App  // GET /readyz   by default; opts.checks adds dependency checks (1.13.0)
app.cspReportRoute (opts?: CspReportRouteOptions): App

// Dispatch + introspection
app.ready(): Promise<void>
app.fetch(req: Request): Promise<Response>
app.request(input: string | URL | Request, init?: RequestInit): Promise<Response>
app.introspect(): IntrospectedRoute[]
app.assertSecureConfig(opts?: { production?: boolean }): readonly SecureConfigIssue[]  // run the boot guards now; throws in production
app.shutdown(timeoutMs?: number, reason?: string): Promise<void>

Route, hooks & context types

ts
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
type PathString = `/${string}`;
type ParamsOf<P>   // infers ":id" -> "id" | ...
type PathParams<P> // { [K in ParamsOf<P>]: string }

interface RequestSchemas {
  params?:  StandardSchemaV1;
  query?:   StandardSchemaV1;
  headers?: StandardSchemaV1;
  body?:    StandardSchemaV1;
}

interface ResponseSpec {
  description?: string; // default: HTTP <status> response
  body?:    StandardSchemaV1;
  headers?: Record<string, { description?: string; schema?: StandardSchemaV1 }>;
  examples?: Record<string, unknown>;
}
type ResponsesMap = { [status: number]?: ResponseSpec };

interface AuthSpec {
  scheme: string;        // refs components.securitySchemes
  scopes?: string[];
  payload?: boolean;     // default true; refuse to opt out when scheme requires payload auth
}

// Plugin-extensible - augment via "declare module"
interface AppState {}

type AuthScheme = "bearer" | "basic" | "jwt" | "jwk" | "webhook" | "session" | "apiKey";
interface AuthContext<TCredentials = unknown> {
  readonly scheme: AuthScheme;
  readonly credentials: TCredentials;
}

interface BaseContext<P extends string, R extends RequestSchemas | undefined> {
  request: Request;
  params:  InferRequest<R, P>["params"];
  query:   InferRequest<R, P>["query"];
  headers: InferRequest<R, P>["headers"];
  body:    InferRequest<R, P>["body"];
  state:   AppState & Record<string, unknown>;
  set:     { status?: number; headers: Headers };
}

interface PreBodyContext<P extends string = string> {
  request: Request;
  params: PathParams<P>; // raw router values
  query: Record<string, string | string[] | undefined>;
  headers: Record<string, string | undefined>;
  body: undefined;
  state: AppState & Record<string, unknown>;
  set: { status?: number; headers: Headers };
}

// HandlerReturn<R> is a discriminated union by status code - TS enforces
// that every returned response is declared in the route's responses map.
type HandlerReturn<R extends ResponsesMap> = ...;

interface Hooks {
  onRequest?:    (req: Request) => void | Promise<void>;
  preBody?:       (ctx: PreBodyContext) => void | Response | Promise<void | Response>;
  beforeHandle?: (ctx) => void | Response | Promise<void | Response>;
  afterHandle?:  (ctx, result) => void | unknown | Promise<void | unknown>;
  onError?:      (err, ctx?) => void | Response | Promise<void | Response>;
  onSend?:       (res: Response, ctx?) => void | Response | Promise<void | Response>;
  onResponse?:   (res: Response, ctx?) => void;
}

// A successful raw Response from preBody/beforeHandle bypasses response-body
// validation and therefore requires acknowledgeNoResponseBodySchema: true on
// the route. Error/denial Responses (4xx/5xx) do not require an opt-out.

interface RouteDefinition<P, Req, Res, S> {
  method: HttpMethod;
  path: P;
  operationId?: string;
  summary?: string;
  description?: string;
  tags?: string[];
  deprecated?: boolean;
  request?: Req;
  responses: Res;
  auth?: AuthSpec;
  hooks?: Hooks;
  meta?: RouteMeta;        // AI-friendly metadata (surfaces as x-daloy-* in OpenAPI)
  examples?: RouteExample[];
  callbacks?: CallbackMap;
  handler: (ctx) => HandlerReturn<Res> | Promise<HandlerReturn<Res>>;
}

interface IntrospectedRoute {
  method: HttpMethod;
  path: string;
  operationId?: string;
  tags?: string[];
  summary?: string;
  description?: string;
  deprecated?: boolean;
  hasBody: boolean;
  hasQuery: boolean;
  hasParams: boolean;
  hasHeaders: boolean;
  responses: number[];
  auth?: { scheme: string; scopes?: string[] };
  meta?: RouteMeta;
}

Hook dispatch order & when the body is read

Per matched request, hooks fire in this order: onRequest(req) → route match → preBody(ctx) → validate params/query/headers and read & parse the request body when the route declares a request.body schema → beforeHandle(ctx) → handler(ctx) → afterHandle(ctx, result) → onSend(res) → onResponse(res).

Header-only authentication runs in preBody, where route params, query values, and headers are raw and ctx.body is always undefined. Built-in bearer, basic, JWK, and mTLS helpers can reject an unauthenticated upload without consuming it.

The body is then read and validated before beforeHandle. Body-aware guards still need the parsed ctx.body; waf() inspects it for NoSQL-operator injection and other inbound attack signatures, and idempotency() derives its dedup key from it. Deferring the read would silently turn those into no-ops.

Custom cheap guards can use preBody. Keep rate limits that depend on validated identity, WAF, idempotency, dependencies, and other parsed-input logic in beforeHandle.

Authorization can run in preBody too: requireScopes(scopes, { phase: "preBody" }) answers 403 before the body is read, so a caller with too few scopes and a malformed body gets 403, not 400. See requireScopes() options.

All-request hooks vs route-composed hooks

Where you register a hook decides which requests it sees:

Registered withMatched routesUnmatched 404 / wrong-method 405Use it for
new App({ hooks })Every phaseonRequest, onError, onSend, onResponseRequest logging, metrics, tracing, error shaping that must see every response
app.use(hooks)Every phasePerimeter guards only (preBody / beforeHandle: rate limits, IP rules, CORS preflight). onSend / onResponse run only when one of those guards answers; a plain 404 / 405 skips themRoute-level middleware: auth, scopes, CORS, idempotency, metrics that should only count real routes
Route or group hooksThat route / group onlyNeverPer-route policy

So an observer registered with app.use({ onResponse }) runs for matched routes but not for a plain unmatched 404. That is deliberate: unmatched paths are attacker-controlled, and route-composed metrics such as httpMetrics() must not mint a series per random path. When a hook must see every request (access logs, request counters, tracing), pass it to the constructor:

ts
const app = new App({
  hooks: {
    // Runs exactly once per request, including unmatched 404s and 405s.
    onResponse(res, ctx) {
      log.info({ requestId: ctx?.state.requestId, status: res.status }, "HTTP request");
    },
  },
});

app.use(cors({ origin: "https://app.example.com" })); // route-composed

ctx is optional in constructor onResponse / onError hooks because the cold 404 path may not build a full context. Keep high-cardinality labels (the raw path) out of metrics recorded there.

Request timeout status

requestTimeoutMs bounds the hooks, the body read and the handler. When it trips, the request's signal is aborted and the client gets a problem response with type: "https://daloyjs.dev/errors/request-timeout". The status defaults to 408 Request Timeout, which per RFC 9110 means the client was too slow. When the deadline mostly trips on slow upstream work (an LLM call, another API), answer 504 Gateway Timeout instead, so monitoring counts it as a server-side failure and clients and proxies do not treat it as their fault:

ts
const app = new App({
  requestTimeoutMs: 150_000,
  requestTimeoutStatus: 504, // or 503; anything other than 408 | 503 | 504 throws
});

Only the handler deadline changes. The Node adapter still answers 408 when a client is too slow to send its headers or body, because that one really is the client's fault.

Errors

ts
// All errors extend HttpError and serialize to RFC 9457 application/problem+json.
class HttpError extends Error {
  status: number; title: string;
  type?: string; detail?: string; instance?: string;
  headers?: Record<string, string>;
}
interface ProblemDetails { type?: string; title: string; status: number; detail?: string; instance?: string; [ext: string]: unknown }

class BadRequestError            extends HttpError {} // 400
class UnauthorizedError          extends HttpError {} // 401 - sets WWW-Authenticate
class ForbiddenError             extends HttpError {} // 403
class NotFoundError              extends HttpError {} // 404
class MethodNotAllowedError      extends HttpError {} // 405 - sets Allow
class RequestTimeoutError        extends HttpError {} // 408
class ConflictError              extends HttpError {} // 409 - sets cache-control: no-store
class PayloadTooLargeError       extends HttpError {} // 413
class UnsupportedMediaTypeError  extends HttpError {} // 415
class ValidationError            extends HttpError {} // 422 - carries StandardSchema issues
class TooManyRequestsError       extends HttpError {} // 429 - sets Retry-After
class RequestHeaderFieldsTooLargeError extends HttpError {} // 431 - maxHeaderCount guard
class InternalError              extends HttpError {} // 500 - detail redacted in production

// Defensive guard: throws MessageLeakError when a custom error response
// would set a header outside the safe allowlist.
const SAFE_CUSTOM_ERROR_RESPONSE_HEADERS: ReadonlySet<string>;
class MessageLeakError extends Error {}
function checkCustomErrorResponseHeaders(headers: Headers | Record<string, string>): void;

function httpError(opts: HttpErrorOptions): HttpError;  // typed factory

Schema validation

ts
interface StandardSchemaV1<Input = unknown, Output = Input> { ... }  // Standard Schema spec
function isStandardSchema(value: unknown): value is StandardSchemaV1;
function validate<S extends StandardSchemaV1>(schema: S, input: unknown):
  | { ok: true;  value: StandardSchemaV1.InferOutput<S> }
  | { ok: false; issues: ReadonlyArray<StandardSchemaV1.Issue> };

Next up: middleware, composition & app helpers.