Brings the uncommitted body of work under version control so it cannot be
lost. Gates are green: 152 tests pass across rpc/router/dev-server,
typecheck, lint, format and check:public-api all clean.
NOT YET REVIEWED. None of Tasks 5-11 has had an independent task review, and
Task 4's second fix round was never re-reviewed either.
Known gaps against the plan, recorded here rather than discovered later:
- packages/rpc/test/{transport,server,client}.test.ts are ABSENT. The plan
required a test file for each. server.ts holds the fail-closed identity and
permission checks and currently has no direct coverage at all.
- rpc-endpoint.test.ts has 3 tests where the plan specified 9. Missing:
unknown service, non-POST, malformed body, non-rpc passthrough, and the
isInternalCaller sweep. This is the task where a reachable
/__wrnexus/rpc/* makes every permission check in the workspace bypassable.
- http.test.ts has 3 of 7; integration.test.ts 2 of 3;
services-discovery.test.ts 1 of 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
339 lines
12 KiB
TypeScript
339 lines
12 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[];
|
|
/** Service implementations (`app/services/<name>.ts`) mounted for inter-app calls. */
|
|
services: 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 });
|
|
}
|
|
|
|
const services: ComponentRef[] = [];
|
|
for (const f of scanDir(join(appDir, "services"), [".js"])) {
|
|
if (!/\.(ts|js)$/.test(f.file) || /[.]gen[.](ts|js)$/.test(f.file)) continue;
|
|
const name = basename(f.file).replace(/\.(ts|js)$/, "");
|
|
if (!isSafeIslandName(name)) {
|
|
console.warn(`[wrnexus] skipping service with unsafe name: ${name}`);
|
|
continue;
|
|
}
|
|
services.push({ name, file: f.file });
|
|
}
|
|
|
|
return {
|
|
pages,
|
|
api,
|
|
realtime,
|
|
middlewareFiles,
|
|
components,
|
|
layouts,
|
|
stores,
|
|
schemas,
|
|
authz,
|
|
services,
|
|
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";
|