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
  bodyLimitBytes?: number;             // default: 1 MiB
  allowedContentTypes?: string[];      // default: ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"]
  requestTimeoutMs?: number;           // default: 30_000; 0 disables
  maxHeaderCount?: number;             // default: 100; 0 disables (header-count flood / HTTP/2-Bomb guard)
  multipart?: { maxFileBytes?: number; maxFields?: number; maxFiles?: number };

  // Environment & logging
  production?: boolean;                // defaults from NODE_ENV
  env?: "development" | "production" | "test";
  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: [...] }

  // 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;

  // 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?: HealthRouteOptions): App     // GET /readyz   by default (opts.path to override)
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.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.

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.