/** * @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="" */ 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/.wrn`); a page picks one via `layout`. */ layouts: ComponentRef[]; /** Typed global/page stores discovered under `app/stores/`. */ stores: ComponentRef[]; /** Validation schemas (`app/schemas/.ts`) shared by API + forms. */ schemas: ComponentRef[]; /** Authorization declarations (`app/authz/.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 { 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(); 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/.wrn. A page selects one with its // `layout` export; the layout wraps the page body via . 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/.{ts,js}. Imported by API routes and // referenced by forms via `data-schema=""`. 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/.{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";