Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// Router: pure core mapping method + pathname → a discovered plugin route. I/O-free;
|
||||
// app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at
|
||||
// `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params.
|
||||
// Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent.
|
||||
|
||||
import { fullPath, type Plugin, type Route } from "./plugin.ts";
|
||||
|
||||
export interface RouteMatch {
|
||||
params: Record<string, string>;
|
||||
plugin: Plugin;
|
||||
route: Route;
|
||||
}
|
||||
|
||||
function segments(path: string): string[] {
|
||||
return path.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function paramCount(path: string): number {
|
||||
return segments(path).filter((s) => s.startsWith(":")).length;
|
||||
}
|
||||
|
||||
// Match a concrete pathname's segments against a route pattern's; return the params or null.
|
||||
function matchSegments(pattern: string[], path: string[]): Record<string, string> | null {
|
||||
if (pattern.length !== path.length) return null;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const pat = pattern[i] as string;
|
||||
const seg = path[i] as string;
|
||||
if (pat.startsWith(":")) {
|
||||
try {
|
||||
params[pat.slice(1)] = decodeURIComponent(seg);
|
||||
} catch {
|
||||
return null; // malformed %-encoding → no match
|
||||
}
|
||||
} else if (pat !== seg) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// Every plugin route whose path pattern matches `pathname`, regardless of method, with params.
|
||||
function matchPath(plugins: Plugin[], pathname: string): RouteMatch[] {
|
||||
const path = segments(pathname);
|
||||
const out: RouteMatch[] = [];
|
||||
for (const plugin of plugins) {
|
||||
for (const route of plugin.routes ?? []) {
|
||||
const params = matchSegments(segments(fullPath(plugin.id, route.path)), path);
|
||||
if (params) out.push({ params, plugin, route });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The single route for `method` + `pathname`, or null. A GET route also answers HEAD. Among
|
||||
// matches the most specific (fewest `:param` segments) wins; ties keep discovery order (plugins
|
||||
// sorted by id, routes as declared) — sort is stable.
|
||||
export function matchRoute(plugins: Plugin[], method: string, pathname: string): RouteMatch | null {
|
||||
const wanted = method.toUpperCase();
|
||||
const candidates = matchPath(plugins, pathname).filter(
|
||||
(m) => m.route.method === wanted || (wanted === "HEAD" && m.route.method === "GET"),
|
||||
);
|
||||
candidates.sort((a, b) => paramCount(a.route.path) - paramCount(b.route.path));
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
// Methods allowed at `pathname` (for a 405 `Allow` header); empty when no route matches the path.
|
||||
export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
||||
const methods = new Set<string>();
|
||||
for (const m of matchPath(plugins, pathname)) {
|
||||
methods.add(m.route.method);
|
||||
if (m.route.method === "GET") methods.add("HEAD");
|
||||
}
|
||||
return [...methods].sort();
|
||||
}
|
||||
|
||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
||||
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses
|
||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||
export function isAuthorized(route: Route, roles: string[]): boolean {
|
||||
return route.public === true || route.permission == null || roles.includes(route.permission);
|
||||
}
|
||||
Reference in New Issue
Block a user