import { getControllerOptions, getControllerPrefix } from "../decorators/Controller.ts";
import { getRoot, resolveViewRoot } from "../decorators/Root.ts";
import { getParamMetadata, type ParamMetadata } from "../decorators/Params.ts";
import { getRoutes, type RouteOptions } from "../decorators/Route.ts";
import { getViews, type ViewMetadata } from "../decorators/View.ts";
import type {
Headers,
HttpMethod,
HttpResponse,
StatusCode,
} from "../contracts/typegen/http/index.ts";
import type { RenderedView } from "../contracts/typegen/http/response.ts";
import type { RequestContext } from "../contracts/typegen/http/request.ts";
import { serveStaticFile } from "./http.ts";
import { existsSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts";
import { RoutePipeline } from "./pipeline.ts";
import { getAuth } from "../decorators/Auth.ts";
/** Template rendered by a controller, the props are checked by the typegen. */
export interface ViewDescriptor
{
/** Template path, relative to the views directory or absolute. */
template: P;
/** Props handed to the template. */
props?: P extends ViewPath ? ViewPropsOf
: Record;
/** Status code of the response. */
status?: StatusCode;
/** Headers of the response. */
headers?: Headers;
}
export interface RouterOptions {
/** Renders a view descriptor; injected by the application. */
renderView?: (
descriptor: ViewDescriptor,
context: RequestContext,
) => Promise | string | RenderedView;
pipeline?: RoutePipeline;
}
export interface RouteDefinition {
method: HttpMethod;
/** Full path, controller prefix included. */
path: string;
controller: object;
methodName: string;
options: RouteOptions;
/** Template rendered by the route, declared with `@View()`. */
view?: ResolvedView;
/** Invokes the controller method with the resolved arguments. */
invoke: (context: RequestContext) => Promise;
}
export type RouteMatch =
| { kind: "match"; route: RouteDefinition; params: Record }
| { kind: "method-not-allowed"; allowed: HttpMethod[] }
| { kind: "not-found" };
/** Joins a controller prefix and a route path into a canonical path. */
export function joinPath(prefix: string, path: string): string {
const raw = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
const trimmed = raw.replace(/\/+$/, "");
return trimmed.length === 0 ? "/" : trimmed;
}
/** Splits a path into its segments. */
export function pathSegments(path: string): string[] {
return path.split("/").filter((segment) => segment.length > 0);
}
/**
* Matches the segments of a route against the segments of a request path.
*/
export function matchSegments(
routeSegments: string[],
requestSegments: string[],
): Record | null {
const params: Record = {};
for (let index = 0; index < routeSegments.length; index++) {
const segment = routeSegments[index] as string;
const actual = requestSegments[index];
if (segment === "*") {
params["*"] = requestSegments.slice(index).join("/");
return params;
}
if (segment.startsWith(":")) {
const optional = segment.endsWith("?");
const name = segment.slice(1, optional ? -1 : undefined);
if (actual === undefined) {
if (optional) continue;
return null;
}
params[name] = actual;
continue;
}
if (actual === undefined || segment !== actual) return null;
}
if (requestSegments.length > routeSegments.length) return null;
return params;
}
export interface RawRoute {
method: string;
path: string;
handler: (
request: Request,
context: RequestContext,
) => Promise | Response;
}
export interface StaticRoot {
/** Prefix of the controller the files are served below. */
prefix: string;
/** Absolute path of the directory. */
path: string;
}
/** A view of a controller, resolved against the view folder. */
export interface ResolvedView {
/** Absolute path of the template. */
path: string;
/** Route of the template, derived from its path. */
route: string;
/** Name of the method the view belongs to. */
propertyKey: string;
method: HttpMethod;
status?: StatusCode;
name?: string;
description?: string;
params?: Record;
}
/**
* Resolves the template of a view.
*/
export function resolveView(view: ViewMetadata, viewRoot: string): ResolvedView {
const absolute = isAbsolute(view.path)
? view.path
: resolve(viewRoot, view.path);
const path = existsSync(absolute)
? absolute
: existsSync(`${absolute}.spug`)
? `${absolute}.spug`
: absolute;
let relative = path.startsWith(`${viewRoot}/`)
? path.slice(viewRoot.length + 1)
: path.split("/").pop() ?? path;
relative = relative.replace(/\.[^./]+$/, "");
const segments = relative.split("/").filter((segment) => segment.length > 0);
if (segments.length > 0 && segments[segments.length - 1] === "index") {
segments.pop();
}
return {
path,
route: segments.length === 0 ? "/" : `/${segments.join("/")}`,
propertyKey: view.propertyKey,
method: view.method,
status: view.status,
name: view.name,
description: view.description,
params: view.params,
};
}
export class Router {
private readonly routes: RouteDefinition[] = [];
private readonly rawRoutes: RawRoute[] = [];
private readonly staticRoots: StaticRoot[] = [];
constructor(private readonly options: RouterOptions = {}) {}
/**
* Adds a route that is not bound to a controller.
*/
raw(
method: string,
path: string,
handler: RawRoute["handler"],
): void {
this.rawRoutes.push({ method: method.toUpperCase(), path, handler });
}
/** Registers every route declared on a controller instance. */
register(controllerType: object, instance: unknown): void {
const prefix = getControllerPrefix(controllerType);
const routes = getRoutes(controllerType);
const views = getViews(controllerType);
const viewRoot = resolveViewRoot(
controllerType,
getControllerOptions(controllerType).dir ?? process.cwd(),
);
const root = getRoot(controllerType);
if (root) {
this.staticRoots.push({ prefix, path: root.path });
}
for (const route of routes) {
const view = views.find(
(entry) => entry.propertyKey === route.propertyKey,
);
this.routes.push({
method: route.method,
path: joinPath(prefix, route.path),
controller: controllerType,
methodName: route.propertyKey,
options: route.options,
view: view ? resolveView(view, viewRoot) : undefined,
invoke: (context) =>
this.invokeHandler(
instance,
route.propertyKey,
getParamMetadata(controllerType, route.propertyKey),
context,
),
});
}
for (const view of views) {
if (routes.some((route) => route.propertyKey === view.propertyKey)) {
continue;
}
const resolved = resolveView(view, viewRoot);
this.routes.push({
method: view.method,
path: joinPath(prefix, resolved.route ?? "/"),
controller: controllerType,
methodName: view.propertyKey,
options: {},
view: resolved,
invoke: (context) =>
this.invokeHandler(
instance,
view.propertyKey,
getParamMetadata(controllerType, view.propertyKey),
context,
),
});
}
}
/** Every registered route definition. */
list(): RouteDefinition[] {
return [...this.routes];
}
/** Every route that is not bound to a controller. */
listRaw(): RawRoute[] {
return [...this.rawRoutes];
}
/** Public route metadata for client navigation and route-aware components. */
clientRoutes(): { name: string; description?: string; path: string; method: HttpMethod; params: Record }[] {
return this.routes.map((route) => {
const view = route.view;
const params = Object.fromEntries(pathSegments(route.path).filter((part) => part.startsWith(":")).map((part) => {
const key = part.slice(1).replace(/[?*]$/, "");
return [key, route.options.params?.[key] ?? view?.params?.[key] ?? ""];
}));
return { name: route.options.name ?? view?.name ?? route.methodName, description: route.options.description ?? view?.description, path: route.path, method: route.method, params };
});
}
/** Finds the route matching a method and path. */
match(method: string, path: string): RouteMatch {
const segments = pathSegments(path);
const allowed = new Set();
for (const route of this.routes) {
const params = matchSegments(pathSegments(route.path), segments);
if (!params) continue;
if (route.method === method) {
return { kind: "match", route, params };
}
allowed.add(route.method);
}
if (allowed.size > 0) {
return { kind: "method-not-allowed", allowed: [...allowed] };
}
return { kind: "not-found" };
}
/** Executes a request and normalises the handler result into a response. */
async handle(context: RequestContext): Promise {
for (const route of this.rawRoutes) {
if (route.method !== context.method) continue;
if (route.path.endsWith("*") ? !context.path.startsWith(route.path.slice(0, -1)) : route.path !== context.path) continue;
const response = await route.handler(
context.raw as Request,
context,
);
return {
status: response.status as StatusCode,
headers: Object.fromEntries(response.headers.entries()),
body: new Uint8Array(await response.arrayBuffer()),
};
}
const match = this.match(context.method, context.path);
if (match.kind === "not-found") {
const asset = await this.serveStatic(context.path);
if (asset) return asset;
return {
status: 404,
headers: { "content-type": "text/plain; charset=utf-8" },
body: "Not Found",
};
}
if (match.kind === "method-not-allowed") {
return {
status: 405,
headers: {
"content-type": "text/plain; charset=utf-8",
allow: match.allowed.join(", "),
},
body: "Method Not Allowed",
};
}
context.params = match.params;
context.locals.routes = this.clientRoutes();
const blocked = await this.options.pipeline?.run(context, match.route);
if (blocked) return blocked;
const auth = getAuth(match.route.controller, match.route.methodName);
if (auth) {
const identity = context.locals.auth as { roles?: string[] } | undefined;
if (!identity || (auth.roles?.some((role) => !identity.roles?.includes(role)) ?? false)) {
return { status: auth.redirect ? 302 : identity ? 403 : 401, headers: auth.redirect ? { location: auth.redirect } : { "content-type": "text/plain; charset=utf-8" }, body: auth.redirect ? undefined : "Unauthorized" };
}
}
const result = await match.route.invoke(context);
return this.toResponse(result, context, match.route);
}
/**
* Serves a file of the static root of a controller.
*/
private async serveStatic(path: string): Promise {
const candidates = this.staticRoots
.filter((root) => path.startsWith(root.prefix))
.sort((left, right) => right.prefix.length - left.prefix.length);
for (const candidate of candidates) {
const relative = path.slice(candidate.prefix.length) || "/";
const file = await serveStaticFile(candidate.path, relative);
if (file) return file;
}
return undefined;
}
private async invokeHandler(
instance: unknown,
methodName: string,
paramMetadata: Map,
context: RequestContext,
): Promise {
const handler = (instance as Record)[methodName];
if (typeof handler !== "function") {
throw new Error(`Controller method ${methodName} is not callable`);
}
if (paramMetadata.size === 0) {
return (handler as (ctx: RequestContext) => unknown).call(
instance,
context,
);
}
const args: unknown[] = [];
for (const [index, meta] of paramMetadata) {
args[index] = resolveParam(meta, context);
}
return (handler as (...args: unknown[]) => unknown).apply(instance, args);
}
private async toResponse(
result: unknown,
context: RequestContext,
route: RouteDefinition,
): Promise {
const status = route.options.status ?? 200;
if (route.view) {
const descriptor: ViewDescriptor = isViewDescriptor(result)
? result
: {
template: route.view.path,
props: (result ?? {}) as Record,
};
const rendered = await this.renderView(descriptor, context);
return {
status: descriptor.status ?? status,
headers: {
"content-type": "text/html; charset=utf-8",
...(descriptor.headers ?? {}),
},
body: rendered,
};
}
if (result === undefined || result === null) {
return { status, headers: {}, body: result ?? undefined };
}
if (isHttpResponse(result)) {
return {
status: result.status,
headers: result.headers ?? {},
body: result.body,
};
}
if (isViewDescriptor(result)) {
const rendered = await this.renderView(result, context);
return {
status: result.status ?? status,
headers: {
"content-type": "text/html; charset=utf-8",
...(result.headers ?? {}),
},
body: rendered,
};
}
if (typeof result === "string") {
return {
status,
headers: { "content-type": "text/html; charset=utf-8" },
body: result,
};
}
return {
status,
headers: { "content-type": "application/json; charset=utf-8" },
body: result,
};
}
private async renderView(
descriptor: ViewDescriptor,
context: RequestContext,
): Promise> {
const renderer = this.options.renderView;
if (!renderer) {
throw new Error(
`Template "${descriptor.template}" cannot be rendered: no view renderer configured`,
);
}
const rendered = await renderer(descriptor, context);
if (typeof rendered === "string") return rendered;
if (rendered.deferred && rendered.deferred.length > 0) {
return toStream(rendered);
}
return rendered.html;
}
}
/** Builds a stream that sends the shell first and the deferred chunks after. */
export function toStream(rendered: RenderedView): ReadableStream {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode(rendered.html));
for (const chunk of rendered.deferred ?? []) {
const content = await chunk.content;
controller.enqueue(
encoder.encode(
`${content}
`,
),
);
}
controller.close();
},
});
}
function resolveParam(meta: ParamMetadata, context: RequestContext): unknown {
switch (meta.kind) {
case "param":
return meta.name
? (context.params as Record)[meta.name]
: context.params;
case "body":
return context.body;
case "form":
return context.form;
case "query":
return meta.name ? context.query[meta.name] : context.query;
case "request":
return context;
case "response":
return context.respond;
case "locals":
return context.locals;
}
}
function isHttpResponse(value: unknown): value is HttpResponse {
return (
typeof value === "object" &&
value !== null &&
typeof (value as HttpResponse).status === "number" &&
"headers" in value
);
}
function isViewDescriptor(value: unknown): value is ViewDescriptor {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ViewDescriptor).template === "string"
);
}