UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

339 lines (313 loc) 10.1 kB
import type { ContractLike, ResolveContract } from "./contract-like.js"; import { resolveContract } from "./contract-like.js"; import type { AddedCtxFromHooks, Handler, RouteHook } from "./http.js"; import type { AnyUseCaseLike, AnyUseCaseRouteDef, UseCaseRouteDef, ValidatedRouteInputs, } from "./use-case-route.js"; /** * Route registration that connects a contract to the handler implementing it. * * Most apps keep route definitions in `features/<feature>/routes.ts` and * compose them with `defineRoutes(...)`. */ export type HandlerRouteDef< Ctx, CLike extends ContractLike = ContractLike, Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook< Ctx, object >[], > = { /** * Contract builder or plain contract config for this route. */ contract: CLike; /** * Route-scoped hooks that run after group hooks and before the handler. */ hooks?: Hooks; /** * Handler that implements the contract. */ handle: Handler<Ctx & AddedCtxFromHooks<Hooks>, ResolveContract<CLike>>; useCase?: never; input?: never; status?: never; }; /** * Route registration for one contract. * * Routes either bind the contract directly to a use case (`{ contract, * useCase }`) or implement a full handler (`{ contract, handle }`). The full * handler form is the escape hatch for response headers, streaming, native * `Response` values, and multi-status handling. */ export type RouteDef< Ctx, CLike extends ContractLike = ContractLike, Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook< Ctx, object >[], > = HandlerRouteDef<Ctx, CLike, Hooks> | AnyUseCaseRouteDef<Ctx, CLike, Hooks>; // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries type AnyRouteDef = RouteDef<any, any>; // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries type PlainRouteDef<Ctx> = RouteDef<Ctx, any, readonly []>; // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries type AnyContractRouteDef<Ctx> = RouteDef<Ctx, any>; const ROUTE_GROUP_KIND = "beignet.route-group"; /** * Named collection of related route registrations. * * Route groups colocate feature routes and can apply scoped route hooks to * every route in the group. `defineRoutes(...)` flattens them before server * registration while preserving those hooks. */ export type RouteGroup< Ctx, Routes extends readonly AnyRouteDef[] = readonly AnyRouteDef[], > = { /** * Internal marker used by `defineRoutes(...)`. */ kind: typeof ROUTE_GROUP_KIND; /** * Human-readable group name. */ name: string; /** * Hooks applied to every route in this group. */ hooks?: readonly RouteHook<Ctx, object>[]; /** * Route definitions in this group. */ routes: Routes; }; type RouteInput<Ctx> = | AnyContractRouteDef<Ctx> | AnyRouteDef | RouteGroup<Ctx, readonly AnyRouteDef[]>; type ContextualRouteInput<Ctx> = | PlainRouteDef<Ctx> | RouteGroup<Ctx, readonly AnyRouteDef[]>; /** * App-bound builder for one route registration. */ export type RouteDefinitionBuilder<Ctx> = { < CLike extends ContractLike, UC extends AnyUseCaseLike, const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [], >( route: UseCaseRouteDef<Ctx, CLike, UC, Hooks>, ): UseCaseRouteDef<Ctx, CLike, UC, Hooks>; < CLike extends ContractLike, const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [], >( route: HandlerRouteDef<Ctx, CLike, Hooks>, ): HandlerRouteDef<Ctx, CLike, Hooks>; }; /** * App-bound builder for a named route group. */ export type RouteGroupBuilder<Ctx> = { < const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [], const R extends readonly PlainRouteDef< Ctx & AddedCtxFromHooks<GroupHooks> >[] = readonly PlainRouteDef<Ctx & AddedCtxFromHooks<GroupHooks>>[], >(group: { name: string; hooks?: GroupHooks; routes: R & ValidatedRouteInputs<Ctx & AddedCtxFromHooks<GroupHooks>, R>; }): RouteGroup<Ctx, R>; < const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [], const R extends readonly AnyRouteDef[] = readonly AnyRouteDef[], >(group: { name: string; hooks?: GroupHooks; routes: R & ValidatedRouteInputs<Ctx & AddedCtxFromHooks<GroupHooks>, R>; }): RouteGroup<Ctx, R>; }; /** * Route declaration builders bound to an application context type. */ export interface Routes<Ctx> { /** Define one route registration. */ defineRoute: RouteDefinitionBuilder<Ctx>; /** Define a named group of related route registrations. */ defineRouteGroup: RouteGroupBuilder<Ctx>; } type RoutesFromInput<Input> = Input extends RouteGroup<infer _Ctx, infer Routes> ? Routes : Input extends AnyRouteDef ? readonly [Input] : readonly []; type FlattenRouteInputs<Inputs extends readonly unknown[]> = number extends Inputs["length"] ? readonly AnyRouteDef[] : Inputs extends readonly [infer First, ...infer Rest] ? readonly [...RoutesFromInput<First>, ...FlattenRouteInputs<Rest>] : readonly []; type ContractsFromRouteList< // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level Routes extends readonly RouteDef<any, any>[], > = { readonly [Index in keyof Routes]: Routes[Index] extends RouteDef< // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level any, infer CLike > ? ResolveContract<CLike> : never; }; /** * Define one route registration with hook-aware handler typing. * * Direct route objects are still supported. Use this helper when route-scoped * hooks enrich `ctx` for a single handler and you want TypeScript to infer the * added fields. */ function createRouteBuilder<Ctx>(): RouteDefinitionBuilder<Ctx> { function define< CLike extends ContractLike, UC extends AnyUseCaseLike, const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [], >( route: UseCaseRouteDef<Ctx, CLike, UC, Hooks>, ): UseCaseRouteDef<Ctx, CLike, UC, Hooks>; function define< CLike extends ContractLike, const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [], >( route: HandlerRouteDef<Ctx, CLike, Hooks>, ): HandlerRouteDef<Ctx, CLike, Hooks>; function define(route: unknown): unknown { return route; } return define; } /** * Define and flatten route registrations with strong type inference. * * Pass route definitions and route groups here before `createServer(...)`. * Group entries are flattened so downstream tooling receives one route list. * * @example * ```ts * const routes = defineRoutes<AppContext>([ * { contract: listPosts, useCase: listPostsUseCase }, * ]); * ``` */ export function defineRoutes< Ctx, const R extends readonly ContextualRouteInput<Ctx>[] = readonly ContextualRouteInput<Ctx>[], >(routes: R & ValidatedRouteInputs<Ctx, R>): FlattenRouteInputs<R>; export function defineRoutes< Ctx, const R extends readonly RouteInput<Ctx>[] = readonly RouteInput<Ctx>[], >(routes: R & ValidatedRouteInputs<Ctx, R>): FlattenRouteInputs<R>; export function defineRoutes< Ctx, const R extends readonly RouteInput<Ctx>[] = readonly RouteInput<Ctx>[], >(routes: R): FlattenRouteInputs<R> { const flattened: RouteDef<Ctx>[] = []; for (const route of routes) { if (isRouteGroup(route)) { for (const groupRoute of route.routes) { flattened.push({ ...groupRoute, hooks: [...(route.hooks ?? []), ...(groupRoute.hooks ?? [])], }); } } else { flattened.push(route); } } return flattened as unknown as FlattenRouteInputs<R>; } /** * Extract contract configs from a route list. * * Use this to drive clients, OpenAPI, and docs from the same route list passed * to `createServer(...)`. */ export function contractsFromRoutes< // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level const R extends readonly RouteDef<any, any>[], >(routes: R): ContractsFromRouteList<R> { return routes.map((route) => resolveContract(route.contract), ) as unknown as ContractsFromRouteList<R>; } /** * Define a named group of related route registrations. * * Route groups are flattened by defineRoutes, so createServer still receives * a regular route list while app code can keep feature route wiring and scoped * hooks colocated. * * @example * ```ts * const { defineRouteGroup } = createRoutes<AppContext>(); * const todoRoutes = defineRouteGroup({ * name: "todos", * hooks: [auth.optional()], * routes: [ * { contract: listTodos, useCase: listTodosUseCase }, * ] * }); * ``` */ function createRouteGroupBuilder<Ctx>(): RouteGroupBuilder<Ctx> { const createGroup = < const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [], const GroupRoutes extends readonly AnyRouteDef[] = readonly AnyRouteDef[], >(input: { name: string; hooks?: GroupHooks; routes: GroupRoutes; }): RouteGroup<Ctx, GroupRoutes> => ({ kind: ROUTE_GROUP_KIND, name: input.name, hooks: input.hooks, routes: input.routes, }); return createGroup as RouteGroupBuilder<Ctx>; } /** * Create route declaration builders bound to an application context type. * * Call this once in `lib/routes.ts`, then import the app-bound builders from * feature route files. * * @example * ```ts * export const { defineRoute, defineRouteGroup } = * createRoutes<AppContext>(); * ``` */ export function createRoutes<Ctx>(): Routes<Ctx> { return { defineRoute: createRouteBuilder<Ctx>(), defineRouteGroup: createRouteGroupBuilder<Ctx>(), }; } function isRouteGroup<Ctx>(route: RouteInput<Ctx>): route is RouteGroup<Ctx> { return ( typeof route === "object" && route !== null && "kind" in route && route.kind === ROUTE_GROUP_KIND ); }