first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* @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 basename),
* 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,
type Route,
type RouteMatch,
} from "./match.ts";
export type { Route, RouteMatch } from "./match.ts";
export { compileRoutePattern, matchRoute, sortRoutes } 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[];
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
schemas: ComponentRef[];
matchPage(pathname: string): RouteMatch | null;
matchApi(pathname: string): RouteMatch | null;
matchRealtime(pathname: string): RouteMatch | null;
}
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[];
}
/**
* 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")
*/
function fileToRoute(rel: string, prefix: string): string {
const withoutExt = rel.replace(/\.(tsx|ts|wrn)$/, "");
const segments = withoutExt.split("/").filter(Boolean);
if (segments[segments.length - 1] === "index") 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 } = compileRoutePattern(raw);
return { raw, file: f.file, regex, paramNames };
});
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 } = compileRoutePattern(raw);
return { raw, file, regex, paramNames };
}
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 };
}
/** 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 pages = buildRoutes(pageFiles, "");
const api = sortRoutes([...buildRoutes(scanDir(join(appDir, "api")), "/api"), ...embedded.api]);
const realtime = sortRoutes([
...buildRoutes(scanDir(join(appDir, "realtime")), "/realtime"),
...embedded.realtime,
]);
// Middleware runs in deterministic (alphabetical) order.
const middlewareFiles = scanDir(join(appDir, "middleware"))
.map((f) => f.file)
.sort();
// Components: `.wrn` files, keyed by name so later scans override earlier
// ones. Library dirs (e.g. @wrnexus/ui) are scanned first; app/components last,
// so an app component of the same name shadows the library's.
const componentMap = new Map<string, ComponentRef>();
const scanComponents = (dir: string): void => {
for (const f of scanDir(dir)) {
if (!f.file.endsWith(".wrn")) continue;
const name = basename(f.file).replace(/\.wrn$/, "");
if (!isSafeIslandName(name)) {
console.warn(`[wrnexus] skipping component with unsafe name: ${name}`);
continue;
}
componentMap.set(name, { 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 });
}
// 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 });
}
return {
pages,
api,
realtime,
middlewareFiles,
components,
layouts,
schemas,
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 };
+81
View File
@@ -0,0 +1,81 @@
/**
* Route compilation + matching.
*
* A "route" is a URL pattern compiled to a RegExp. We support static segments
* and dynamic `[param]` segments, e.g. `/users/[id]` -> `{ id }`.
*/
export interface Route {
/** The human-readable route pattern, e.g. `/users/[id]`. */
raw: string;
/** Absolute path to the module that handles this route. */
file: string;
/** Compiled matcher. */
regex: RegExp;
/** Ordered names of dynamic params captured by `regex`. */
paramNames: string[];
}
export interface RouteMatch {
route: Route;
params: Record<string, string>;
}
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
/** Compile a `/users/[id]` style pattern into a RegExp + param names. */
export function compileRoutePattern(raw: string): Pick<Route, "regex" | "paramNames"> {
if (raw === "/") {
return { regex: /^\/$/, paramNames: [] };
}
const paramNames: string[] = [];
const parts = raw
.split("/")
.filter(Boolean)
.map((segment) => {
const dynamic = segment.match(/^\[(.+)\]$/);
if (dynamic) {
paramNames.push(dynamic[1]!);
return "([^/]+)";
}
return segment.replace(ESCAPE_RE, "\\$&");
});
// Allow an optional trailing slash.
const regex = new RegExp("^/" + parts.join("/") + "/?$");
return { regex, paramNames };
}
/**
* Order routes so that static routes win over dynamic ones, and longer/more
* specific routes win over shorter ones. Sorting once keeps matching simple.
*/
export function sortRoutes(routes: Route[]): Route[] {
return [...routes].sort((a, b) => {
if (a.paramNames.length !== b.paramNames.length) {
return a.paramNames.length - b.paramNames.length; // fewer params first
}
return b.raw.length - a.raw.length; // longer/more specific first
});
}
/** Find the first route whose pattern matches `pathname`. */
export function matchRoute(routes: Route[], pathname: string): RouteMatch | null {
for (const route of routes) {
const m = route.regex.exec(pathname);
if (!m) continue;
const params: Record<string, string> = {};
try {
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(m[i + 1]!);
});
} catch {
// A malformed percent-encoded path is not a valid route match. Treat it
// as a 404 instead of allowing decodeURIComponent to become a 500.
return null;
}
return { route, params };
}
return null;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Typed-routes codegen. From the scanned page routes, emit `app/routes.gen.ts`
* with a `Routes` map (path → param types) and an `href()` builder — so links
* are checked at compile time (unknown path or missing param = type error).
*/
import type { Route } from "./match.ts";
export function generateRoutesFile(pages: Route[]): string {
const seen = new Set<string>();
const entries: string[] = [];
for (const page of [...pages].sort((a, b) => a.raw.localeCompare(b.raw))) {
if (seen.has(page.raw)) continue;
seen.add(page.raw);
const type = page.paramNames.length
? `{ ${page.paramNames.map((n) => `${JSON.stringify(n)}: string`).join("; ")} }`
: "Record<string, never>";
entries.push(` ${JSON.stringify(page.raw)}: ${type};`);
}
return `// AUTO-GENERATED by \`wrnexus dev\` — do not edit.
// Typed routes: a compile-time map of every page path to its [param] types,
// plus an href() builder that fills params and rejects unknown paths.
export interface Routes {
${entries.join("\n") || " [path: string]: Record<string, string>;"}
}
export type RoutePath = keyof Routes;
export function href<P extends RoutePath>(
path: P,
...args: Routes[P] extends Record<string, never> ? [] : [params: Routes[P]]
): string {
const params = (args[0] ?? {}) as Record<string, string>;
return String(path)
.split("/")
.map((seg) =>
seg.startsWith("[") && seg.endsWith("]")
? encodeURIComponent(params[seg.slice(1, -1)] ?? "")
: seg,
)
.join("/");
}
`;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Filesystem scanning for the file-based router.
*
* Scanning happens once at startup. We build a table of routes from the files
* that exist on disk; request paths are later matched against that table.
* Crucially, request input is NEVER turned into a file path — this is what
* makes the router immune to path-traversal.
*/
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
/** Extensions we are willing to load as route modules. */
const ALLOWED_EXTENSIONS = [".ts", ".tsx", ".wrn"] as const;
export interface ScannedFile {
/** Absolute path to the file on disk. */
file: string;
/** Path relative to the scanned base directory, using forward slashes. */
rel: string;
}
function hasAllowedExtension(name: string): boolean {
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext));
}
/** Hidden files/dirs (dotfiles) and underscore-prefixed files are ignored. */
function isIgnored(name: string): boolean {
return name.startsWith(".") || name.startsWith("_");
}
/**
* Recursively collect allowed route files under `baseDir`.
* Returns [] if the directory does not exist (a route kind may be unused).
*/
export function scanDir(baseDir: string): ScannedFile[] {
if (!existsSync(baseDir)) return [];
const out: ScannedFile[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (isIgnored(entry)) continue;
const abs = join(dir, entry);
const stats = statSync(abs);
if (stats.isDirectory()) {
walk(abs);
} else if (stats.isFile() && hasAllowedExtension(entry)) {
out.push({
file: abs,
rel: relative(baseDir, abs).split(sep).join("/"),
});
}
}
};
walk(baseDir);
return out;
}