@beignet/core
Version:
Core framework primitives for Beignet
339 lines (313 loc) • 10.1 kB
text/typescript
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
);
}