import type { BinaryResponse, RawResponse } from "../binary.ts" import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts" import type { Params, RouteSchema } from "./context.ts" import type { StatusResponse } from "./runtime-core.ts" /** * One route's input/output shape as the **client** will consume it. `query`/`body` * are `never` when the route declares no schema for them, so the client can detect * "this route takes no body" via `[body] extends [never]`. `output` is the * handler's raw return type (the client applies `Jsonify` when reading it). */ export interface RouteInfo { // `object` is the *bound*; each route stores its precise `Params` (e.g. // `{ id: string }`), which a generic `Path` can't be proven to fit into // `Record`. The precise type survives in the accumulated // registry, so exact param-key checking is preserved downstream. readonly params: object readonly query: unknown readonly body: unknown readonly output: unknown /** The complete inferred/declared status-keyed response map. */ readonly responses?: unknown /** The route's declared error bodies as a status-keyed record (from `schema.errors`, e.g. * `{ 404: NotFound }`); `unknown` when the route declares none. The typed client turns this into * a status-discriminated failure union. */ readonly errors?: unknown /** The SSE event payload type (from `schema.sse`, declared via `app.sse()`); `never` for ordinary * routes. The typed client keys `.subscribe()` availability and its event type off this. */ readonly sse?: unknown /** The WebSocket message contract (from `app.ws()`'s `messageSchema`/`sendSchema`): `in` is what the * client may send, `out` what the server pushes. Present only on WS entries (method key `"WS"`); * the typed client keys `.ws()` availability and its frame types off this. */ readonly ws?: unknown } /** The accumulated, type-level map of every route on a Server: path → method → RouteInfo. */ export type Registry = Record> /** The empty registry (no routes). `NonNullable` is `{}` without tripping noBannedTypes. */ export type EmptyRegistry = NonNullable /** The client-visible request inputs use the schema's INPUT side: the registry describes what a * caller must SEND over the wire (pre-validation), not what the handler receives after parsing. With * a Zod `.default()`/transform the two differ - `InferOutput` would force the client to supply * fields the schema fills in. (Same rule as the WS `in` channel below.) */ type RegistryBody = S extends { body: infer B extends StandardSchemaV1 } ? InferInput : never type RegistryQuery = S extends { query: infer Q extends StandardSchemaV1 } ? InferInput : never type StatusCodeOf = Output extends StatusResponse ? (number extends Code ? never : Code) : never type StatusBodyAt = Output extends StatusResponse ? Code extends Actual ? Body : never : never type IsSuccessCode = `${Code}` extends `2${string}` ? true : false type EmptyResponseMap = NonNullable type StatusBodies> = [Codes] extends [never] ? EmptyResponseMap : { [Code in Codes]: StatusBodyAt } /** A dynamic status code cannot be classified as success or failure; expose an opaque success arm * rather than guessing a status or body schema. Explicit route schemas still override this fallback. */ type DynamicStatusResponses = Output extends StatusResponse ? number extends Code ? { 200: unknown } : EmptyResponseMap : EmptyResponseMap type InferredResponses = (Exclude< Output, StatusResponse | Response > extends never ? EmptyResponseMap : { 200: Exclude | Response> }) & StatusBodies & DynamicStatusResponses type ExplicitResponses = (S extends { response: infer R extends StandardSchemaV1 } ? { 200: InferOutput } : EmptyResponseMap) & (S extends { errors: infer E extends Record } ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput : never } : EmptyResponseMap) type MergeResponseMaps = Omit & Explicit type ResponseMap = MergeResponseMaps< InferredResponses, ExplicitResponses > /** Public type seam for build-time consumers that need the complete route response map. */ export type ResponseMapFor = ResponseMap type ResponseErrors = { [K in keyof M as K extends number ? (IsSuccessCode extends true ? never : K) : never]: M[K] } type ResponseSuccessBodies = { [K in keyof M]: K extends number ? (IsSuccessCode extends true ? M[K] : never) : never }[keyof M] type NonEmptyOrUnknown = keyof M extends never ? unknown : M /** The client-visible output is the union of all inferred/declared 2xx bodies. */ type RegistryOutput = ResponseSuccessBodies> /** The SSE event payload type from a route's `sse` schema; `never` for ordinary routes. */ type RegistrySse = S extends { sse: infer E extends StandardSchemaV1 } ? InferOutput : never /** Build a {@link RouteInfo} from a route's path, schema, and handler output type. */ export type RouteInfoFor = { readonly params: Params readonly query: RegistryQuery readonly body: RegistryBody readonly output: RegistryOutput readonly responses?: ResponseMapFor readonly errors: NonEmptyOrUnknown>> readonly sse: RegistrySse } /** * The client-visible output of a handler: its awaited return, with an UNbranded raw `Response` removed * (a route returning a bare `Response` has no describable body). A response branded by `bytes()` * ({@link BinaryResponse}) or `raw()` ({@link RawResponse}) is KEPT, so the brand reaches the client's * `Jsonify` and types `data` as `Blob` / `Jsonify` rather than collapsing to `never`. */ export type OutputOf unknown> = KeepBrandedResponse< Awaited> > /** Preserve a `bytes()`/`raw()`-branded response; drop only a bare `Response`. Distributes over a * union return, so `Foo | BinaryResponse` keeps both arms. */ type KeepBrandedResponse = O extends BinaryResponse ? O : O extends RawResponse ? O : O extends Response ? never : O /** * The registry entry for a WebSocket route (stored under the pseudo-method key `"WS"`, so WS routes * ride the same path → method map as HTTP routes - no extra Server generic). `in` is the client → * server frame type: the `messageSchema`'s INPUT side, since that is what goes on the wire before * validation transforms it. `out` is the server → client frame type from `sendSchema` (a type-level * contract; the server's own sends are not runtime-validated). Either is `unknown` when undeclared. */ export type WsRouteInfoFor< Path extends string, In extends StandardSchemaV1 | undefined, Out extends StandardSchemaV1 | undefined, > = { readonly params: Params readonly query: never readonly body: never readonly output: never readonly errors: unknown readonly sse: never readonly ws: { readonly in: In extends StandardSchemaV1 ? InferInput : unknown readonly out: Out extends StandardSchemaV1 ? InferOutput : unknown } } /** * Merge a new route into the registry, combining methods that share a path. * * SCALING CEILING (measured - see many-routes.test-d.ts and the isolation study below): a single * fluent chain hits TS2589 at ~95-100 routes. This intersection is NOT the cause - in isolation it * accumulates 1000+ routes cleanly, and heavy per-route `Params`/schema inference alone * reaches 600+. The wall is an INTERACTION unique to the fluent builder: each `.get(path, handler)` * both (a) computes the handler's context type from the path (`c.params.id` inferred from `:id`) * AND (b) returns `Server, Ctx>`. Neither alone strains the compiler; the * PRODUCT - recomputing the handler context while re-threading the ever-larger registry at each of * N steps - exhausts TypeScript's per-expression instantiation budget around N≈95. It is therefore * O(N) and inherent to any builder that infers handler context AND accumulates a typed route * registry (Elysia/tRPC/hono's typed clients cap the same way); it is not fixable by reshaping * AddRoute. Past ~90 routes, use a path that DOESN'T form the product: split into domain groups and * `.merge()` them (each group is a short chain; a merge is one `R & R2` intersection with no * per-call context work), or contract-first `implement()` (the registry is one object type declared * upfront - no grow-R-per-call, so no ceiling at all). */ export type AddRoute< R extends Registry, Method extends string, Path extends string, Info extends RouteInfo, > = R & { [P in Path]: { [M in Method]: Info } }