554 lines
15 KiB
TypeScript
554 lines
15 KiB
TypeScript
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<P extends string = string> {
|
|
/** Template path, relative to the views directory or absolute. */
|
|
template: P;
|
|
/** Props handed to the template. */
|
|
props?: P extends ViewPath ? ViewPropsOf<P> : Record<string, unknown>;
|
|
/** 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> | 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<unknown>;
|
|
}
|
|
|
|
export type RouteMatch =
|
|
| { kind: "match"; route: RouteDefinition; params: Record<string, string> }
|
|
| { 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<string, string> | null {
|
|
const params: Record<string, string> = {};
|
|
|
|
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> | 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<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, string>;
|
|
}[] {
|
|
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<HttpMethod>();
|
|
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<HttpResponse> {
|
|
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<HttpResponse | undefined> {
|
|
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<number, ParamMetadata>,
|
|
context: RequestContext,
|
|
): Promise<unknown> {
|
|
const handler = (instance as Record<string, unknown>)[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<HttpResponse> {
|
|
const status = route.options.status ?? 200;
|
|
|
|
if (route.view) {
|
|
const descriptor: ViewDescriptor = isViewDescriptor(result)
|
|
? result
|
|
: {
|
|
template: route.view.path,
|
|
props: (result ?? {}) as Record<string, unknown>,
|
|
};
|
|
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<string | ReadableStream<Uint8Array>> {
|
|
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<Uint8Array> {
|
|
const encoder = new TextEncoder();
|
|
return new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
controller.enqueue(encoder.encode(rendered.html));
|
|
for (const chunk of rendered.deferred ?? []) {
|
|
const content = await chunk.content;
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`<div data-muddle-deferred="${chunk.id}">${content}</div>`,
|
|
),
|
|
);
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
function resolveParam(meta: ParamMetadata, context: RequestContext): unknown {
|
|
switch (meta.kind) {
|
|
case "param":
|
|
return meta.name
|
|
? (context.params as Record<string, string>)[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"
|
|
);
|
|
}
|