Task 10 fix round 1: the coordinator's plan doc (41fb82b9) recorded that
generated authz type files should be skipped before the isSafeIslandName
check, but the code change never landed. isSafeIslandName rejects the dot
in the stripped basename "permissions.gen", so every app running Task 12's
codegen would warn on every boot.
Add a quiet skip for *.gen.ts / *.gen.js immediately after the extension
guard, before the name check. Add tests: a .gen.ts file is skipped without
a console.warn (spied), and a .gen.js file is skipped the same way while a
legitimately named .js declaration is still discovered.
Also corrects the scanDir extraExtensions doc comment, which incorrectly
implied app/schemas passes it too (only app/authz does).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* @wrnexus/router — file-based router.
|
|
*
|
|
* Maps the `app/` directory onto route tables:
|
|
* app/pages/index.tsx -> GET /
|
|
* app/pages/about.tsx -> GET /about
|
|
* app/pages/users/[id].tsx-> GET /users/:id
|
|
* app/api/hello.ts -> /api/hello
|
|
* app/realtime/chat.ts -> /realtime/chat
|
|
* app/pages/*.wrn api -> embedded /api/* routes
|
|
* app/pages/*.wrn realtime -> embedded /realtime/* routes
|
|
* app/middleware/*.ts -> global middleware (alphabetical)
|
|
* app/components/*.wrn -> server-rendered components (by declaration),
|
|
* mounted in a page via data-component="<name>"
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { join, basename } from "node:path";
|
|
import { parse } from "@wrnexus/compiler";
|
|
import { isSafeIslandName, type Middleware } from "@wrnexus/core";
|
|
import { scanDir, type ScannedFile } from "./scan.ts";
|
|
import {
|
|
compileRoutePattern,
|
|
matchRoute,
|
|
sortRoutes,
|
|
findRouteConflicts,
|
|
type Route,
|
|
type RouteMatch,
|
|
} from "./match.ts";
|
|
|
|
export type { Route, RouteMatch } from "./match.ts";
|
|
export {
|
|
compileRoutePattern,
|
|
getRouteParams,
|
|
matchRoute,
|
|
sortRoutes,
|
|
findRouteConflicts,
|
|
} from "./match.ts";
|
|
export { generateRoutesFile } from "./routes-gen.ts";
|
|
|
|
export interface ComponentRef {
|
|
/** Validated component name (matches a `data-component` attribute). */
|
|
name: string;
|
|
/** Absolute path to the component's `.wrn` module. */
|
|
file: string;
|
|
}
|
|
|
|
export interface Router {
|
|
pages: Route[];
|
|
api: Route[];
|
|
realtime: Route[];
|
|
/** Absolute paths of middleware modules, in execution order. */
|
|
middlewareFiles: string[];
|
|
/** Server-rendered `.wrn` components, mounted via `data-component`. */
|
|
components: ComponentRef[];
|
|
/** Named page layouts (`app/layouts/<name>.wrn`); a page picks one via `layout`. */
|
|
layouts: ComponentRef[];
|
|
/** Typed global/page stores discovered under `app/stores/`. */
|
|
stores: ComponentRef[];
|
|
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
|
|
schemas: ComponentRef[];
|
|
/** Authorization declarations (`app/authz/<name>.ts`) merged into the catalog. */
|
|
authz: ComponentRef[];
|
|
matchPage(pathname: string): RouteMatch | null;
|
|
matchApi(pathname: string): RouteMatch | null;
|
|
matchRealtime(pathname: string): RouteMatch | null;
|
|
}
|
|
|
|
export interface ExternalRouteDefinition {
|
|
kind: "page" | "api" | "realtime";
|
|
path: string;
|
|
entry: string;
|
|
name?: string;
|
|
}
|
|
|
|
export interface RouterOptions {
|
|
/**
|
|
* Extra directories to scan for `.wrn` components (e.g. `@wrnexus/ui`).
|
|
* Scanned before `app/components`, so an app component of the same name wins.
|
|
*/
|
|
componentDirs?: string[];
|
|
/** Package-owned routes registered by the plugin contribution system. */
|
|
externalRoutes?: ExternalRouteDefinition[];
|
|
/** Package-owned middleware executed before app/middleware. */
|
|
middlewareFiles?: string[];
|
|
}
|
|
|
|
/**
|
|
* Convert a scanned file's relative path into a URL route pattern.
|
|
* - strips the extension
|
|
* - drops a trailing `index` segment
|
|
* - prefixes with `prefix` (e.g. "/api")
|
|
*/
|
|
export function fileToRoute(rel: string, prefix = ""): string {
|
|
const withoutExt = rel.replace(/\.(tsx|ts|wrn)$/, "");
|
|
const segments = withoutExt
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")));
|
|
if (["index", "page"].includes(segments[segments.length - 1] ?? "")) segments.pop();
|
|
const tail = segments.join("/");
|
|
const route = prefix + (tail ? "/" + tail : "");
|
|
return route === "" ? "/" : route;
|
|
}
|
|
|
|
function buildRoutes(files: ScannedFile[], prefix: string): Route[] {
|
|
const routes = files.map((f): Route => {
|
|
const raw = fileToRoute(f.rel, prefix);
|
|
const { regex, paramNames, paramMeta } = compileRoutePattern(raw);
|
|
return { raw, file: f.file, regex, paramNames, paramMeta };
|
|
});
|
|
return sortRoutes(routes);
|
|
}
|
|
|
|
function normalizeEmbeddedApiPath(path: string): string {
|
|
if (path === "/api" || path.startsWith("/api/")) return path;
|
|
return `/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
}
|
|
|
|
function routeFromRaw(raw: string, file: string): Route {
|
|
const { regex, paramNames, paramMeta } = compileRoutePattern(raw);
|
|
return { raw, file, regex, paramNames, paramMeta };
|
|
}
|
|
|
|
function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick<Router, "api" | "realtime"> {
|
|
const api: Route[] = [];
|
|
const realtime: Route[] = [];
|
|
|
|
for (const file of pageFiles) {
|
|
if (!file.file.endsWith(".wrn")) continue;
|
|
const ast = parse(readFileSync(file.file, "utf8"));
|
|
|
|
for (const block of ast.apis) {
|
|
api.push(routeFromRaw(normalizeEmbeddedApiPath(block.path), file.file));
|
|
}
|
|
|
|
for (const block of ast.realtimes) {
|
|
if (!isSafeIslandName(block.name)) {
|
|
console.warn(`[wrnexus] skipping realtime route with unsafe name: ${block.name}`);
|
|
continue;
|
|
}
|
|
realtime.push(routeFromRaw(`/realtime/${block.name}`, file.file));
|
|
}
|
|
}
|
|
|
|
return { api, realtime };
|
|
}
|
|
|
|
function warnRouteConflicts(kind: string, routes: Route[]): void {
|
|
for (const conflict of findRouteConflicts(routes)) {
|
|
console.warn(
|
|
`[wrnexus] WRN-ROUTE-CONFLICT: duplicate ${kind} route '${conflict.raw}' in ${conflict.files.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge package routes with application routes. Exact application paths always
|
|
* win so a package can safely provide defaults that an app may override.
|
|
*/
|
|
function mergeExternalRoutes(
|
|
kind: string,
|
|
applicationRoutes: Route[],
|
|
externalRoutes: Route[],
|
|
): Route[] {
|
|
const applicationPaths = new Set(applicationRoutes.map((route) => route.raw));
|
|
const retainedExternal = externalRoutes.filter((route) => {
|
|
if (!applicationPaths.has(route.raw)) return true;
|
|
console.warn(
|
|
`[wrnexus] WRN-ROUTE-OVERRIDE: application ${kind} route '${route.raw}' overrides package route from ${route.file}`,
|
|
);
|
|
return false;
|
|
});
|
|
return sortRoutes([...applicationRoutes, ...retainedExternal]);
|
|
}
|
|
|
|
/** Scan an app directory and build all route tables. */
|
|
export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
|
|
const pageFiles = scanDir(join(appDir, "pages"));
|
|
const embedded = embeddedWireRoutes(pageFiles);
|
|
const external = opts.externalRoutes ?? [];
|
|
const externalPages = external
|
|
.filter((route) => route.kind === "page")
|
|
.map((route) => routeFromRaw(route.path, route.entry));
|
|
const externalApi = external
|
|
.filter((route) => route.kind === "api")
|
|
.map((route) => routeFromRaw(route.path, route.entry));
|
|
const externalRealtime = external
|
|
.filter((route) => route.kind === "realtime")
|
|
.map((route) => routeFromRaw(route.path, route.entry));
|
|
const applicationPages = buildRoutes(pageFiles, "");
|
|
const applicationApi = sortRoutes([
|
|
...buildRoutes(scanDir(join(appDir, "api")), "/api"),
|
|
...embedded.api,
|
|
]);
|
|
const applicationRealtime = sortRoutes([
|
|
...buildRoutes(scanDir(join(appDir, "realtime")), "/realtime"),
|
|
...embedded.realtime,
|
|
]);
|
|
const pages = mergeExternalRoutes("page", applicationPages, externalPages);
|
|
const api = mergeExternalRoutes("API", applicationApi, externalApi);
|
|
const realtime = mergeExternalRoutes("realtime", applicationRealtime, externalRealtime);
|
|
|
|
warnRouteConflicts("page", pages);
|
|
warnRouteConflicts("API", api);
|
|
warnRouteConflicts("realtime", realtime);
|
|
|
|
// Middleware runs in deterministic (alphabetical) order.
|
|
const middlewareFiles = [
|
|
...new Set([
|
|
...(opts.middlewareFiles ?? []),
|
|
...scanDir(join(appDir, "middleware"))
|
|
.map((f) => f.file)
|
|
.sort(),
|
|
]),
|
|
];
|
|
|
|
// Components are keyed case-insensitively by their declared name so later
|
|
// scans override earlier ones without creating duplicate aliases. Library
|
|
// dirs (e.g. @wrnexus/ui) are scanned first; app/components last.
|
|
const componentMap = new Map<string, ComponentRef>();
|
|
const scanComponents = (dir: string): void => {
|
|
for (const f of scanDir(dir)) {
|
|
if (!f.file.endsWith(".wrn")) continue;
|
|
|
|
const fileName = basename(f.file).replace(/\.wrn$/, "");
|
|
let name = fileName;
|
|
|
|
try {
|
|
const source = readFileSync(f.file, "utf8");
|
|
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(source);
|
|
if (declaration) name = declaration[1]!;
|
|
} catch (error) {
|
|
console.warn(`[wrnexus] unable to inspect component declaration in ${f.file}`, error);
|
|
}
|
|
|
|
if (!isSafeIslandName(name)) {
|
|
console.warn(`[wrnexus] skipping component with unsafe name: ${name}`);
|
|
continue;
|
|
}
|
|
|
|
componentMap.set(name.toLowerCase(), { name, file: f.file });
|
|
}
|
|
};
|
|
for (const dir of opts.componentDirs ?? []) scanComponents(dir);
|
|
scanComponents(join(appDir, "components"));
|
|
const components = [...componentMap.values()];
|
|
|
|
// Named page layouts: app/layouts/<name>.wrn. A page selects one with its
|
|
// `layout` export; the layout wraps the page body via <slot>.
|
|
const layouts: ComponentRef[] = [];
|
|
for (const f of scanDir(join(appDir, "layouts"))) {
|
|
if (!f.file.endsWith(".wrn")) continue;
|
|
const name = basename(f.file).replace(/\.wrn$/, "");
|
|
if (!isSafeIslandName(name)) {
|
|
console.warn(`[wrnexus] skipping layout with unsafe name: ${name}`);
|
|
continue;
|
|
}
|
|
layouts.push({ name, file: f.file });
|
|
}
|
|
|
|
const stores: ComponentRef[] = [];
|
|
for (const f of scanDir(join(appDir, "stores"))) {
|
|
if (!f.file.endsWith(".wrn")) continue;
|
|
try {
|
|
const source = readFileSync(f.file, "utf8");
|
|
const declaration = /\b(?:global|page)\s+store\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source);
|
|
if (!declaration) continue;
|
|
stores.push({ name: declaration[1]!, file: f.file });
|
|
} catch (error) {
|
|
console.warn(`[wrnexus] unable to inspect store declaration in ${f.file}`, error);
|
|
}
|
|
}
|
|
|
|
// Validation schemas: app/schemas/<name>.{ts,js}. Imported by API routes and
|
|
// referenced by forms via `data-schema="<name>"`.
|
|
const schemas: ComponentRef[] = [];
|
|
for (const f of scanDir(join(appDir, "schemas"))) {
|
|
if (!/\.(ts|js)$/.test(f.file)) continue;
|
|
const name = basename(f.file).replace(/\.(ts|js)$/, "");
|
|
if (!isSafeIslandName(name)) {
|
|
console.warn(`[wrnexus] skipping schema with unsafe name: ${name}`);
|
|
continue;
|
|
}
|
|
schemas.push({ name, file: f.file });
|
|
}
|
|
|
|
// Authorization declarations: app/authz/<name>.{ts,js}, each default-exporting
|
|
// a defineAuthz() module. Merged into the catalog at boot.
|
|
const authz: ComponentRef[] = [];
|
|
for (const f of scanDir(join(appDir, "authz"), [".js"])) {
|
|
if (!/\.(ts|js)$/.test(f.file)) continue;
|
|
// Generated type files (permissions.gen.ts) live here too. Skip them quietly:
|
|
// they export types only, and isSafeIslandName would otherwise reject the dot
|
|
// and warn on every boot.
|
|
if (/[.]gen[.](ts|js)$/.test(f.file)) continue;
|
|
const name = basename(f.file).replace(/\.(ts|js)$/, "");
|
|
if (!isSafeIslandName(name)) {
|
|
console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`);
|
|
continue;
|
|
}
|
|
authz.push({ name, file: f.file });
|
|
}
|
|
|
|
return {
|
|
pages,
|
|
api,
|
|
realtime,
|
|
middlewareFiles,
|
|
components,
|
|
layouts,
|
|
stores,
|
|
schemas,
|
|
authz,
|
|
matchPage: (p) => matchRoute(pages, p),
|
|
matchApi: (p) => matchRoute(api, p),
|
|
matchRealtime: (p) => matchRoute(realtime, p),
|
|
};
|
|
}
|
|
|
|
/** Re-export for callers that load middleware modules themselves. */
|
|
export type { Middleware };
|
|
export { routeName, nameRoutes, createRouteManifest, routeUrl, findNamedRoute } from "./named.ts";
|
|
export type { NamedRoute, RouteManifestEntry } from "./named.ts";
|