/** * Copyright (c) 2026 AzerothJS. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /** * The kernel dispatcher. * * The smallest complete server: register routes, then `handle(request)` maps any web-standard * Request to exactly one Response. Everything the architecture promises about the hot path is * enforced here: * * - `handle` NEVER throws and never returns a rejected promise. Every failure - a handler * throw, an async rejection, a body-reader error - flows through the one error path * (errors.ts) and comes back as a Response. There is no crashed process and no hung * request, the two classic Express failure modes. * - A miss is a 404; a path that exists under other methods is a 405 WITH the Allow header * (the router distinguishes them by construction). * - HEAD is served from the GET handler with the body stripped and the entity headers kept, * per RFC 9110 - and a streaming body is cancelled, not leaked. * - The handler context is TYPED from the route pattern: `app.get('/users/:id', ...)` * receives `context.params: { id: string }` with no annotation, no codegen, no cast. * * `app.handle(new Request('http://local/x'))` is the entire integration-testing story - no * sockets, no listen(), no inject shim. Middleware compose ABOVE this dispatcher via typed * context accumulation (see `use`); every dispatch runs inside a request root (store * isolation + cleanup registry; see request-root.ts); adapters (node:http etc.) sit below. */ import type { PathParams } from './router.ts'; import { RadixRouter } from './router.ts'; import { type ErrorObserver, type ErrorSerializer } from './errors.ts'; import { type EdgeMiddleware, type HandlerWrapper } from './edge.ts'; /** * THE context - the single argument every handler receives, carrying this one * request's whole world: the raw web-standard Request, the typed path params, the * parsed URL, and (flat on the object) whatever middleware added. The documented * parameter name is `context`; the framework only defines the shape. */ export interface RequestContext = Record> { /** The raw web-standard Request: headers, cookies, body, signal. */ request: Request; /** Decoded path parameters, typed from the route pattern. */ params: Params; /** The parsed request URL (parsed lazily, once, shared by everyone). */ url: URL; /** * The path the ROUTER matched: percent-decoded per segment, one trailing slash dropped. * Prefer THIS over `url.pathname` for a policy decision (an auth prefix check, a CSRF * exemption list, a rate-limit bucket, an audit line), because `url.pathname` preserves the * client's spelling: `/%61dmin` reads as something other than `/admin` there while still * reaching the `/admin` handler. * * A path carrying an empty segment (`//admin`) never reaches any handler - it is a * distinct URI and the router refuses it - so that spelling is not a way around a check * written on either accessor. */ path: string; } /** A route handler: one context in, exactly one Response out. Ctx is what middleware added. */ export type Handler = Record, Ctx extends object = object> = (context: RequestContext & Ctx) => Response | Promise; /** * The observability seam: called once per request with the outcome and wall time. This is * where request logging, metrics, and tracing attach (an OpenTelemetry span is one observer * away) - interfaces live here, dependencies do not. Observer throws are swallowed: watching * the system must never be able to break it. */ export interface RequestObserver { onComplete(request: Request, response: Response, durationMs: number): void; } /** * One app's policy: what an error reveals, who watches a request, and whether each dispatch * gets a request root. Decided once at construction - a {@link App.with} fork shares it. */ export interface AppOptions { /** * Development mode: error responses expose non-HttpError messages and 5xx stacks. * Never enable in production - secrecy of internals is the default for a reason. */ dev?: boolean | undefined; /** Observes every error the app maps (logging seam). Its own throws are swallowed. */ onError?: ErrorObserver; /** * Reshapes the error wire body so the app can speak its own envelope instead of the default * `{ error: { code, message } }` - return a plain value to replace the body, a `Response` for * full control, or `undefined` to keep the default for that error. Applies uniformly to every * error, route-miss 404s included; a broken serializer falls back to the default shape. */ serializeError?: ErrorSerializer; /** * Wrap every dispatch in a request root (store isolation across awaits + the * onWorkUnitCleanup registry). Default true; set false only where the runtime lacks * AsyncLocalStorage or for micro-benchmarking the bare kernel. */ requestRoot?: boolean; /** Observes every completed request (logging/metrics/tracing seam). */ observe?: RequestObserver | undefined; /** * How long a handler may take to PRODUCE its response before the client is answered 503. * Opt-in with no default, because only the application knows how long its own code should * take - the socket timeouts in the node adapter bound the PEER, which the framework can * size, and this bounds YOUR CODE, which it cannot. * * What it does: frees the socket and answers the client. What it does NOT do, and cannot: * * - It does not cancel the handler. A JavaScript promise cannot be cancelled from outside, * so the handler runs on and keeps its memory, its pool slot, and its upstream connection * until it settles by itself. This bounds the CLIENT's wait, not the server's resources. * - It does not bound a streaming body. The clock stops when the handler returns a * `Response`, so SSE, static files, and multipart may stream for as long as they like - * a bound on total duration would break all three by design. * - It DOES include reading the request body, because that happens inside the handler. * Keep it above {@link SocketTimeouts.requestMs} (default 300000) or a slow upload the * adapter explicitly permits will be refused here. * * Note for a graceful drain: the 503 closes the response, so the drain stops counting this * request as in-flight and may exit while its handler is still running. */ responseTimeoutMs?: number | undefined; /** * Hears a STREAMING response whose producer failed after the headers were sent. * * Deliberately NOT {@link onError}: that seam hands over a mapped {@link HttpError} with a * status, and a committed stream has no status left to map - reporting one would mean * fabricating a response that never existed. This failure is real and currently invisible: * the client gets a truncated body while the server records the 2xx it already sent, and * over h2c that truncation is byte-identical to a normal end. * * Covers a streaming `Response` the kernel monitors. It does NOT cover an SSE stream, * which closes cleanly on a producer rejection and reports through `sse()`'s own * `onError`, nor a body the kernel could not monitor because the handler took its own * reader. */ onStreamError?: ((error: unknown, request: Request) => void) | undefined; } /** * @internal The pathname of an absolute-form URL by string scan - no URL allocation on the * hot path. The path starts at the first '/' after the authority and ends at '?' or '#'. */ export declare function pathnameOf(url: string): string; /** * A middleware: reads the context accumulated SO FAR (the request rides on it), and * returns either the context it ADDS (an object, merged flat onto the context for * everything downstream), a Response (short circuit: guards deny, caches answer * early), or nothing. There is no `next()` - control flow is the return value, and * the chain is composed once per route at registration. */ export type Middleware> = (context: RequestContext & Ctx) => Added | Response | null | undefined | void | Promise; /** * A named, versioned server plugin: the packaged form of the functional `app.register(fn)` seam. * `install` receives the app - with whatever context type it has accumulated so far - and * returns the (possibly more capable) app; the return TYPE flows the plugin's context additions * to everything registered after it, exactly as `use` and `plugin` do. `name` identifies the * plugin so a double registration is caught at boot rather than silently applied twice. * * A plugin does only what an ordinary caller could do by hand: register routes, add middleware * via `use`, mount an edge pipeline, read config. There is no privileged hook into the kernel * and none into the compiler - a plugin extends the running server, never the language. * * @example * ```ts * const timing: AzerothPlugin = * { * name: 'timing', * version: '1.0.0', * install: (app) => app.use(() => ({ startedAt: Date.now() })) * }; * app.register(timing); // ctx now carries startedAt for later routes * ``` */ export interface AzerothPlugin { /** Unique plugin name; a second registration under the same name throws at boot. */ name: string; /** Optional semver of the plugin, surfaced by {@link App.plugins} for diagnostics. */ version?: string | undefined; /** Applies the plugin to the app, returning the app with any context additions. */ install(app: App): App; } /** * @internal What a {@link App.with} fork inherits from its parent. The router is SHARED (every * route lands in one table), the options are SHARED (one error/observer policy), the plugin * registry is SHARED (a duplicate is caught across forks); only the middleware list is a COPY, so * a fork's added middleware never leaks back to the parent. Not part of the public surface. */ interface AppInternals { router: RadixRouter; middlewares: Array>>; installed: Array<{ name: string; version?: string | undefined; }>; wrappers: HandlerWrapper[]; } /** * The application: one route table, the middleware stacked above it, and {@link handle} below. * * `new App().get('/x', handler)` is already a complete server - no plugin, no adapter, and no * socket is required to exercise it. Registration is LEXICAL: {@link use} applies to every route * registered after it, {@link with} only to the routes registered through the view it returns, * and each route snapshots its chain at registration, so nothing added later reaches back into a * route already on the table. * * `Ctx` is what middleware and plugins have added to the handler context, accumulated by the type * system as they are registered: it starts empty, every {@link use} / {@link with} / * {@link register} widens it, and a handler therefore reads `context.accountId` with no cast - * while one registered ABOVE the middleware that supplies it fails to compile. * * There are no sub-applications: {@link with} and {@link register} return views onto the SAME * table, so a request is matched once, no mount prefix has to be composed by hand, and * {@link routes} prints the entire surface. * * @example * ```ts * // POSITIVE check: an unset NODE_ENV must not expose internals, so `staging` and a * // forgotten variable both count as not-development. * const app = new App({ dev: process.env.NODE_ENV === 'development' }); * * app.get('/healthz', () => json({ ok: true })); * app.with(requireAuth).get('/account/me', (context) => json({ id: context.accountId })); * * const response = await app.handle(new Request('http://local/healthz')); // the whole test story * ``` */ export declare class App { #private; /** * @param options Error, observability, and request-root policy for every route on this app. * @param internals @internal What a {@link with} fork inherits; never passed by application code. */ constructor(options?: AppOptions, internals?: AppInternals); /** * Appends a middleware. Its returned object joins the context TYPE for every route * registered AFTER it - ordering is lexical: what you read top-to-bottom is what runs, * and a route above a `use` is untouched by it. The runtime merge happens in the * composed chain; middleware never mutate the context directly, they return additions. * * Also takes an EDGE middleware ({@link rateLimit}, {@link cors}, {@link securityHeaders}, * {@link requestId}, or your own via `edge()`), which wraps the whole dispatch instead of * joining the per-route chain - it has to, because a limiter must refuse before a route is * matched and a preflight must be answered for paths with no route. The two kinds are told * apart by a brand, not by shape, because both are single-argument functions. * * SOUNDNESS CAVEATS (the deliberate trade, same one Hono makes): `use` mutates the * shared middleware list and returns `this` re-typed, so (1) an app reference ALIASED * before the `use` registers routes whose handlers run the middleware but are typed * WITHOUT its additions, and (2) a middleware that can SHORT-CIRCUIT (return a * Response instead of additions) leaves the additions typed-as-present on paths it * never decorated. When either matters, prefer {@link with}: its forked view makes * both the scope and the typing exact. * * The reverse trade is real too, so this is a choice and not a ranking: `with` RETURNS the * app that carries the middleware and does not mutate this one, so dropping its return value * is a silent no-op - `app.with(requireAuth)` on its own compiles, runs, and leaves the routes * below it unguarded. `use` cannot fail that way. Reach for `with` when scope or exact typing * matters; reach for `use` when the middleware genuinely applies to everything after it. */ use(edgeMiddleware: EdgeMiddleware): this; use = Record>(middleware: Middleware): App; /** * Opens a SCOPED registration view. `app.with(mw)` returns an app that shares this one's route * table but runs `mw` only for the routes registered THROUGH the returned view - and no others. * It is the scoped counterpart to {@link use}: `use` makes a middleware global from its lexical * position onward, `with` confines it to a handful of routes without a manual guard call inside * each handler. Chain it - `app.with(throttle).with(requireAuth).get(...)` - and each middleware's * context additions flow into the next and into the handler, typed end to end. The fork never * mutates this app: routes registered directly on `app` are untouched, and a later `app.use` does * not reach back into an already-opened fork. * * @example * ```ts * const authed = app.with(requireAuth); // Middleware * authed.get('/account/me', (context) => json({ id: context.accountId })); // typed * authed.patch('/account', updateHandler); * app.get('/health', () => text('ok')); // no auth - the fork did not touch app * ``` */ with = Record>(middleware: Middleware): App; /** Registers a handler; the pattern's params type the context. Conflicts throw here, at boot. */ route

(method: string, pattern: P, handler: Handler & Record, Ctx>): this; /** * Registers a GET route. HEAD on the same pattern is answered from this handler with the body * stripped and the entity headers kept, so a HEAD route is never written by hand. * * @example * ```ts * app.get('/users/:id', (context) => json({ id: context.params.id })); // params typed from the pattern * ``` */ get

(pattern: P, handler: Handler & Record, Ctx>): this; /** Registers a POST route: the write that is NOT idempotent - a creation, or an action. */ post

(pattern: P, handler: Handler & Record, Ctx>): this; /** Registers a PUT route: a whole-resource write, sent twice for the same end state. */ put

(pattern: P, handler: Handler & Record, Ctx>): this; /** Registers a PATCH route: a partial update, the body carrying only what changes. */ patch

(pattern: P, handler: Handler & Record, Ctx>): this; /** Registers a DELETE route. The name is `delete`, not `del`: it is a method, not a bare word. */ delete

(pattern: P, handler: Handler & Record, Ctx>): this; /** * Registers a QUERY handler (RFC 10008). QUERY is a SAFE and IDEMPOTENT method that carries * a request body: it is for reads whose parameters are too large or too structured for a URL * (a complex filter, a search document), where a GET query string does not fit and a POST * would wrongly signal a state change. The handler MUST NOT mutate state - that contract is * what lets responses be cached and requests retried. Read the body as you would a POST's * (readJson/readForm enforce the required Content-Type); reply with `json` plus any * QUERY headers (Content-Location) the response warrants. * @experimental The QUERY method (RFC 10008) is not yet deployed internet reality - * proxies, caches, and tooling may not recognize it. The surface is stable within the * 1.x train but carries an experimental flag until the RFC is. */ query

(pattern: P, handler: Handler & Record, Ctx>): this; /** * Registers a plugin - the ONE plugin verb, in either form: * * - a NAMED plugin ({@link AzerothPlugin}): runs `install`, records name+version, and * REJECTS a second registration under the same name (a duplicate is almost always a * wiring mistake - two copies, or two versions - and applying it twice would double * its middleware/routes). The shipped-module form. * - a plain FUNCTION over the app returning the (possibly more capable) app: applied * directly, no registry entry - the one-off anonymous transform. * `app.register(auth).register(metrics)` IS the composition, typed end to end. * * Either way the plugin's context additions flow into the returned app's type, so * routes registered afterwards see them. */ register(plugin: AzerothPlugin): App; register(fn: (app: App) => App): App; /** The installed plugins (name + version), in registration order - print it at boot. */ plugins(): ReadonlyArray<{ name: string; version?: string | undefined; }>; /** The registered route table, one line per route - print it at boot. */ routes(): string[]; /** * Maps one Request to one Response. This function cannot throw and cannot reject; every * failure becomes an error Response through the one error path. Unless opted out, the * whole dispatch runs inside a request root: stores are request-isolated across awaits * and onWorkUnitCleanup teardown ALWAYS runs when the request settles. */ handle(request: Request): Promise; /** * @internal The app's stream-fault observer, for kernel-adjacent callers that construct a * streaming response ON THE APP'S BEHALF - today the api layer's `sse()`. Without it a * declared `routes.stream` producer failure falls back to `sse()`'s own stderr notice * instead of reaching the observer the app already configured. */ get streamErrorObserver(): ((error: unknown, request: Request) => void) | undefined; } export {};