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" ); }