/** * 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 client-safe half of the colocated API. * * The types and small values BOTH sides of the wire read: the route declaration shape * ({@link Decl}), the projected {@link Manifest} a typed client is built from, path-parameter * inference, the typed status reply, the guard TYPE contracts, and multipart's wire shapes. * Nothing here touches a server API: this module (and client.ts, which consumes it) is what * '@azerothjs/http/api/shared' exposes, so importing that entry can never drag Node code into * a browser bundle. feature.ts and register.ts import THIS module - never the reverse; the * kernel-purity test text-walks every import specifier, type-only included, so the direction * is load-bearing. * * Why a manifest and not a runtime contract value: types erase, but a type plus two runtime * fields per route does not. The client's whole runtime need is method + path; everything else * - params, input, query, output - is inference from `typeof` the server's own declaration. * The manifest is a projection of that declaration, computed by the framework, so it is not a * second source of truth; a ten-route feature's manifest measures in the hundreds of bytes. */ import type { StandardSchemaV1 } from '@azerothjs/schema'; /** * A route boundary schema: any Standard Schema v1 validator (https://standardschema.dev - the * `~standard` property Zod, Valibot, ArkType and the house schema all expose). * * There is ONE schema concept at a boundary. The native `@azerothjs/schema` value is one of these * and additionally self-describes, so consumers use its extras when present (`safeParse`: one pass * with issue codes; `meta`: the OpenAPI description) and never require them. A foreign schema still * validates; its OpenAPI entry degrades to the permissive shape because there is no `meta` to walk. */ export type RouteSchema = StandardSchemaV1; /** Infers the param object type from a route pattern string (mirrors @azerothjs/http). */ export type PathParams = Path extends `${infer Head}/${infer Rest}` ? PathParams & PathParams : Path extends `:${infer Name}` ? Name extends '' ? object : { [K in Name]: string; } : Path extends `*${infer Name}` ? Name extends '' ? object : { [K in Name]: string; } : object; /** * The JSON verbs a declaration may carry. QUERY (RFC 10008) is a safe, idempotent method that * carries a request body - a read whose parameters are too large or structured for a URL; its * `input` is that body, validated exactly as a POST body is, and the handler MUST NOT mutate * state. `raw` routes may use any method string beyond this union. */ export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY'; /** * How a route speaks on the wire - the discriminant every consumer dispatches on: * - 'json': the default; validated input/query in, validated JSON out. * - 'form': multipart/form-data in ({@link MultipartInput}); validated fields, buffered files. * - 'raw': the handler owns the whole exchange and returns a `Response` (uploads beyond the * buffered form, webhooks verifying raw bytes, files, 304s via `conditional`). * - 'stream': a Server-Sent-Events route; the handler drives the connection. * * - 'action': a server action - POST-only and param-free, so the typed client surfaces it * as a directly-callable function; the wire behavior is exactly 'json'. * * The kinds beyond 'json' exist so those routes stay INSIDE the system: they inherit the * feature's guard and appear in the manifest and the OpenAPI document, instead of degrading to * hand-mounted `app.get` calls that re-implement authorization and vanish from the spec. */ export type RouteKind = 'json' | 'form' | 'raw' | 'stream' | 'action'; /** * Display-only documentation for one route. NOTHING here affects registration, validation, * or the client - by contract, not just by convention: the OpenAPI exporter is the only * consumer. What a machine can derive (operation ids from feature keys, parameters from the * pattern, schemas from the declarations) is never repeated here; `docs` carries only * what a machine cannot know. */ export interface RouteDocs { /** One-line summary shown beside the operation. */ summary?: string; /** Longer prose (CommonMark). */ description?: string; /** Grouping tags; defaults to the route's feature key in the registered record. */ tags?: readonly string[]; /** Marks the operation deprecated in the spec. */ deprecated?: boolean; /** * Error responses this handler can produce beyond the framework-derived set * (422/415/500). Status plus prose - the body is always the error envelope. */ errors?: ReadonlyArray<{ status: number; code?: string; description?: string; }>; /** Names of securitySchemes (from the export options) this route requires; [] = public. */ security?: ReadonlyArray; } /** * One uploaded file as a form-route handler receives it. Structurally identical to * @azerothjs/http's UploadedFile (register passes those through verbatim); declared here so * shared code - which browsers import - stays a pure schema+api affair. */ export interface ContractFile { /** The form field name this file was posted under. */ name: string; /** The client-supplied filename, verbatim. UNTRUSTED: sanitize before touching a filesystem. */ filename: string; /** The part's declared Content-Type (application/octet-stream when the client omits it). */ contentType: string; /** The raw file bytes. */ data: Uint8Array; } /** What a form route's handler receives as `input`: validated text fields plus the files. */ export interface MultipartInput> { /** The text fields - validated by the `fields` schema, or the raw first-value map without one. */ fields: Fields; /** File parts in posted order. */ files: ContractFile[]; } /** @internal The brand distinguishing a typed status reply from an arbitrary object body. */ export declare const REPLY: unique symbol; /** * A typed non-default reply: status + body + headers, built with {@link reply}. Unlike a * raw `Response`, the body is STILL validated against the route's schema for that status * (`responses[status]`, or `output` for 200) - a status code and headers do not cost * the route its output guarantee. */ export interface StatusReply { readonly [REPLY]: true; status: S; body: B; headers?: Record; } /** * Builds a typed reply. `reply(201, user, { location })` sends 201 with a validated * body; `reply(204)` sends an empty response. The status must be declared in the * route's `responses` map (or be 200 with `output`, or carry no body) for the body * type to check. */ export declare function reply(status: S): StatusReply; /** * Body form: the status plus a typed body, and optional headers. * * The status must appear in the route's `responses` map - or be 200 with `output` - for the * body type to check against anything. * * @typeParam S - The status code, held as a literal. * @typeParam B - The body type, checked against the declared response for `S`. * @param status - The HTTP status. * @param body - Serialized according to the route's declaration. * @param headers - Merged into the response. */ export declare function reply(status: S, body: B, headers?: Record): StatusReply; /** @internal Whether a handler return is a typed status reply. */ export declare function isStatusReply(value: unknown): value is StatusReply; /** * @internal A `responses` key as its numeric status. Reverse-mapped inference records the * literal keys of `{ 404: problem }` as the STRING `"404"` - a plain `& number` on the * keyof would erase every status, so this converts instead. */ type StatusOf = K extends number ? K : K extends `${infer N extends number}` ? N : never; /** * The reply union a route's `responses` map admits: one {@link StatusReply} per declared * status with that status's body type, plus `StatusReply<200, Out>` when `output` is * declared, plus the always-legal bodyless reply (a 204/205/redirect carries nothing to * validate). */ export type ReplyOf = (Responses extends Record ? { [S in keyof Responses]: StatusReply, Responses[S]>; }[keyof Responses] : never) | StatusReply<200, Out> | StatusReply; /** * THE context a route handler receives - the single argument, exactly like a plain * http handler, with the validated `input`/`query` added where the route declared a * schema. Whatever the guard chain attaches lands FLAT on this same object, TYPED - the * feature threads its guards' additions in, so a guarded handler reads them with no cast. * The documented parameter name is `context`. */ export interface HandlerContext { /** The raw web-standard Request: headers, cookies, body, signal. */ request: Request; /** The parsed request URL. */ url: URL; /** * The path the router matched, percent-decoded per segment. Present so a handler can hand * its context to a helper typed against the kernel's RequestContext - at runtime it IS the * same object, and a type that hid this field forced a cast for no reason. */ path: string; /** Decoded path parameters, typed from the pattern. */ params: PathParams & Record; /** The validated input body (undefined when the route declares no input schema). */ input: In; /** The validated query object (undefined when the route declares no query schema). */ query: Query; } /** * The minimal context a guard reads (the http RequestContext, structurally). The equivalence is * load-bearing: it is what lets a plain `(context: RequestContext) => void` middleware be used as * a guard with no wrapper, so anything added to `RequestContext` belongs here too. */ export interface GuardContext { request: Request; url: URL; params: Record; /** * The path the router matched, percent-decoded per segment. A guard is exactly the place this * matters: deciding on `url.pathname` lets `/%61dmin` read as a different path than the one * whose handler is about to run. (`//admin` cannot: the router refuses an empty segment * outright, so no such request reaches a guard.) */ path: string; } /** * A guard: reads the context and returns an object to ADD to it (typed - the additions flow into * every handler behind it), a Response to short-circuit, or nothing. * * Any plain `(context) => void | Response` is a guard: {@link guard} (feature.ts) is only needed * when the guard ADDS to the context and that addition has to be inferred. */ export interface Guard> { (context: GuardContext): Add | Response | undefined | void | Promise; } /** * A guard whose every path either attaches `Add` or short-circuits: no branch returns nothing, so * a handler behind it reads the fields as definitely present. The difference from {@link Guard} is * stated where it is checkable - this return type carries no `undefined` and no `void`. * * {@link guard} returns this shape automatically when the callback proves it. */ export interface ExactGuard { (context: GuardContext): Add | Response | Promise; } /** * @internal The variance-erased guard a stored chain carries: any {@link Guard} or * {@link ExactGuard} is assignable BECAUSE the return is `unknown` - a `Guard` would * reject every guard that actually adds something. */ export type AnyGuard = (context: GuardContext) => unknown; /** * The wire-relevant declaration of one route: what the SPEC half of a builder call recorded. * Erased at the value level (register and the OpenAPI exporter read these fields); the TYPE * half rides on {@link Decl}'s generics. */ export interface SpecShape { input?: RouteSchema; query?: RouteSchema; output?: RouteSchema; responses?: Record>; docs?: RouteDocs; /** 'form' kind: validates the TEXT fields (first value wins for repeated names). */ fields?: RouteSchema; /** 'form' kind: total body cap in bytes (default 8 MiB). */ limit?: number; /** 'form' kind: maximum number of parts (default 256). */ maxParts?: number; /** 'form' kind: per-file cap in bytes (default: the total limit). */ maxFileSize?: number; /** 'raw' kind: the request media type the OpenAPI document describes (default octet-stream). */ accepts?: string; /** 'stream' kind: the declared SSE event names, for the document only. */ events?: readonly string[]; } /** * One declared route: kind, method, feature-relative path, spec, guard chain, handler - the * WHOLE route, written once. Three consumers read the same declaration: `register` (server), * the manifest projection + `typeof` (client), and the OpenAPI exporter (describer). * * The `any`-heavy {@link AnyDecl} view exists for the same variance reason the old contract * documented: schemas are invariant, so a bare `Decl` with defaulted generics would reject * every declared route. */ export interface Decl, Kind extends RouteKind = RouteKind> { kind: Kind; /** Uppercase wire method. 'raw' routes may carry any method string. */ method: string; /** Feature-relative pattern; {@link pathOf} is the ONE place a full path is composed. */ path: Path; spec: SpecShape; /** * The COMPLETE guard chain this route runs: the feature's chain plus anything * `routes.with(...)` added, or exactly what `routes.only(...)` named. The builder * resolves it as the route is declared - the one moment both halves are known - so a * route's real guards are readable AT the route and nothing downstream re-derives * them. This is the single source of truth for what protects a route. */ guards: ReadonlyArray; /** The handler. Typed through the builder; erased here. */ handler: (context: never) => unknown; /** @internal Phantom slots carrying the inferred wire types for ClientOf/register. */ readonly types?: { in: In; out: Out; query: Query; add: Add; }; } /** * Any route regardless of its wire types - the shape membership checks compare against. * The `any`s are deliberate (variance-erasing existential; schemas are invariant). */ export type AnyDecl = Decl; /** A feature's routes: name -> declaration. The name is written exactly once. */ export type Routes = Record; /** * A built feature: the prefix, the declarations, the feature-level guard chain, and the * manifest projection. `typeof` a feature is the client's whole type source. */ export interface Feature { prefix: Prefix; routes: R; /** Projects the runtime half a client needs: method + path (+ non-json kind marker). */ manifest(): Record; } /** * One manifest row: everything a typed client needs at RUNTIME. Two fields for a JSON route; * `kind` appears on every non-'json' route: form/raw/stream so the JSON client's refusal * is loud at the call instead of a silently mis-encoded request, and 'action' so the client * surfaces the route as a directly-callable function. */ export interface ManifestEntry { method: string; /** The full feature-prefixed path (registration prefix excluded - that is the baseUrl). */ path: string; kind?: 'form' | 'raw' | 'stream' | 'action'; } /** The manifest for a whole registered record: feature key -> route key -> entry. */ export type Manifest = Record>; /** * THE full-path composition - router registration, the manifest, and the OpenAPI exporter all * call this one function, so the trailing-slash rule (`feature('/orgs/:slug')` + `r.get('/')`) * cannot drift between them. */ export declare function pathOf(prefix: string, path: string, mount?: string): string; /** * @internal THE rule for which schema validates a given status: the `responses` map, with `output` * as the declared shorthand for its 200 entry. Two consumers must agree on it - register validates * a handler's return against it, and the OpenAPI exporter describes the same status from it. */ export declare function responseSchemaFor(decl: AnyDecl, status: number): unknown; export {};