/** * 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 one error path. * * Every throw in the stack - sync or async, handler or middleware, kernel or app code - ends * up in exactly one place: `errorResponse`, which turns ANY thrown value into a Response with * a stable wire shape. One path means one set of guarantees: no second kind of "error * middleware" selected by convention, and no async rejection that bypasses handling because * nothing awaited it - the dispatcher awaits everything and routes every failure here. * * The wire shape is stable and machine-readable: * * { "error": { "code": "not-found", "message": "...", "details"?: ... } } * * `code` is a kebab-case identifier clients can switch on (statuses are too coarse; messages * are for humans). `details` carries structured payloads - most importantly the field-error * map `{ field: message }` that `azerothjs`'s setError consumes, so a server-side * validation failure lands in the SAME shape the browser form already understands. * * EXPOSURE: a 4xx describes the client's mistake, so its message crosses the wire. A 5xx * describes the server's - its message (stack traces, internal paths, query fragments) stays * in the log; the client sees a generic body. `HttpError.expose` encodes that default per * error, and constructing a bare `new HttpError(500, ...)` follows it. */ import type { Issue } from '@azerothjs/schema'; /** * An HTTP-mappable error. Throw it anywhere; the kernel maps it to a Response. Not exported * subclasses' `code` values are stable API - clients switch on them. */ export declare class HttpError extends Error { /** The HTTP status this error maps to. */ readonly status: number; /** Stable machine-readable identifier (kebab-case), e.g. 'payload-too-large'. */ readonly code: string; /** Whether `message` crosses the wire (default: true for 4xx, false for 5xx). */ readonly expose: boolean; /** Structured wire payload, e.g. a field-error map for validation failures. */ readonly details: unknown; /** Extra response headers this error mandates (e.g. Allow on a 405, Retry-After on a 429). */ readonly headers: Record; constructor(status: number, message: string, options?: { code?: string; expose?: boolean; details?: unknown; headers?: Record; cause?: unknown; }); } /** 400: the request is malformed (bad JSON, invalid escape, missing required part). */ export declare class BadRequestError extends HttpError { constructor(message?: string, options?: { code?: string; details?: unknown; cause?: unknown; }); } /** 401: no valid credentials. `WWW-Authenticate` is the caller's to set via headers. */ export declare class UnauthorizedError extends HttpError { constructor(message?: string, options?: { code?: string; details?: unknown; headers?: Record; }); } /** 403: authenticated, but not allowed. */ export declare class ForbiddenError extends HttpError { constructor(message?: string, options?: { code?: string; details?: unknown; }); } /** 404: nothing lives at this path. */ export declare class NotFoundError extends HttpError { constructor(message?: string, options?: { code?: string; details?: unknown; }); } /** 405: the path exists under other methods; carries the Allow header by construction. */ export declare class MethodNotAllowedError extends HttpError { constructor(allowed: string[], message?: string); } /** 409: the request conflicts with current state (duplicate create, stale update). */ export declare class ConflictError extends HttpError { constructor(message?: string, options?: { code?: string; details?: unknown; }); } /** 413: the body exceeds the configured limit. Kernel body readers throw this. */ export declare class PayloadTooLargeError extends HttpError { constructor(message?: string, options?: { details?: unknown; }); } /** 415: the Content-Type is not one this endpoint reads. */ export declare class UnsupportedMediaTypeError extends HttpError { constructor(message?: string, options?: { details?: unknown; }); } /** * 422: syntactically fine, semantically invalid - THE validation error. `details.fields` is * the field-error map (`{ field: message }`) that the frontend form's setError consumes * directly; `details.issues` (when the validator supplies them) is the ordered list of * `{ path, code, message }` failures for clients that switch on stable codes. */ export declare class ValidationError extends HttpError { constructor(fieldErrors: Record, message?: string, issues?: ReadonlyArray); } /** * 429: rate limited; `retryAfterSeconds` becomes the Retry-After header. `options.headers` * carries the limiter's own RateLimit-* set, so a refusal can be THROWN - and answered by the * chain owner's error policy - without losing the headers a returned Response would have kept. */ export declare class TooManyRequestsError extends HttpError { constructor(retryAfterSeconds?: number, message?: string, options?: { headers?: Record; }); } /** The stable 404 Response for a routing miss, built without constructing an Error. */ export declare function notFoundResponse(): Response; /** @internal Marks an error the client caused; returns it for use in a throw or a reject. */ export declare function markClientFault(error: E): E; /** * @internal Invokes a REPORTING sink so its own failure cannot take the server down. * * Every one of these sinks is consumer code the framework calls to TELL it something, never to * ask it anything - so its failure has nowhere meaningful to go, and must not propagate. Two * shapes have to be contained, and guarding only the first is the trap this exists to close: * * - a SYNCHRONOUS throw, which inside a floating promise or an event handler becomes an * uncaughtException, whose default disposition exits the process; * - an ASYNC rejection from a sink declared `void` but written `async`, which a plain * try/catch around the CALL never sees, because the call itself returned fine. * * Measured before this existed: a throwing observer on a declared stream route exited the * process (taking every other live connection with it), and an async-rejecting one did the same * through a sink whose synchronous throw was already guarded. */ export declare function reportIsolated(sink: ((...args: A) => unknown) | undefined, ...args: A): void; /** @internal Whether {@link markClientFault} branded this value. */ export declare function isClientFault(error: unknown): boolean; /** What the kernel knows about an error's context beyond the error itself. */ export interface ErrorContext { /** * The CLIENT had already disconnected when this error was mapped. * * A handler that honours `request.signal` rejects when the client hangs up, and that * rejection maps to a 500 like any other - so without this flag an ordinary abandoned * navigation is indistinguishable from a server fault, and a dashboard fed by this seam * counts it as one. * * It is deliberately a FACT rather than a filter: the kernel does not suppress the report, * because "aborted" does not prove the abort CAUSED the error. A handler's own timeout * controller, a mutating call handed `request.signal`, and a graceful-shutdown drain all * raise `AbortError` on an aborted request while being real faults worth seeing. Classify * on this flag; do not assume it means nothing happened. */ clientGone: boolean; } /** How `errorResponse` reports the errors it maps (the observability seam's first consumer). */ export type ErrorObserver = (error: unknown, mapped: HttpError, context?: ErrorContext) => void; /** The context a custom error serializer receives - everything needed to shape the wire body. */ export interface ErrorSerializerContext { /** The thrown value mapped to its HTTP form: status, code, message, expose, details, headers. */ error: HttpError; /** The request that failed - available for path, request id, timestamp context, etc. */ request: Request; /** * Whether the error's message may cross the wire (true for a 4xx, or in dev). When false, do * NOT put `error.message` in the body - a 5xx message can hold server internals. */ expose: boolean; /** Development mode. */ dev: boolean; } /** * Reshapes the error wire body. Return a plain value to REPLACE the default `{ error: { code, * message } }` (the kernel still applies the error's status and any mandated headers - a 405 * `Allow`, a 429 `Retry-After`); return a `Response` to take control of the status, body and * media type (mandated headers it leaves unset are still applied); return `undefined` to fall * back to the default shape for this error. Wired via `new App({ serializeError })`. * * This is the seam that lets an app speak its own envelope (`{ success, code, field, message }`, * a JSON:API document, ...) without reimplementing the one error path - the same mapping, the * same guarantees, one place to change the shape. */ export type ErrorSerializer = (context: ErrorSerializerContext) => unknown; /** * Turns ANY thrown value into the stable wire Response. A non-HttpError becomes a 500 whose * message never crosses the wire (unless `dev`, where debugging beats secrecy). This function * itself cannot throw - the last resort must be unconditionally safe. * * @param error - Whatever was thrown. * @param options.dev - Expose non-HttpError messages and stacks (development only). * @param options.observe - Called with every mapped error, for logging; its own throws are swallowed. * @param options.serialize - Reshapes the body (or returns a full Response); falls back to default on undefined. Runs only when `request` is also given - the serializer contract includes the request. * @param options.request - The failing request, handed to a serializer (path/id context). */ export declare function errorResponse(error: unknown, options?: { dev?: boolean | undefined; observe?: ErrorObserver | undefined; serialize?: ErrorSerializer | undefined; request?: Request | undefined; }): Response;