@wrnexus/router
File-based router that maps an
app/directory onto route tables and matches request paths against them.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
@wrnexus/router scans an application's app/ directory once at startup and builds route tables for pages, API endpoints, realtime channels, middleware, server-rendered .wrn components, layouts, and validation schemas. It also compiles URL patterns (/users/[id]) into RegExps and matches request paths against them. Request input is never turned into a file path, which makes the router immune to path traversal. This is a server-side package used by the WrNexus runtime to resolve incoming requests, plus a codegen helper for compile-time typed links.
Installation
bun add @wrnexus/router
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
Directory conventions
The router maps files under appDir onto routes:
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 (rt) -> embedded /realtime/* routes
app/middleware/*.ts -> global middleware (alphabetical)
app/components/*.wrn -> server-rendered components (by basename)
app/layouts/*.wrn -> named page layouts
app/schemas/*.ts -> validation schemas
Allowed route extensions are .ts, .tsx, and .wrn. Dotfiles and underscore-prefixed files are ignored. A trailing index segment is dropped from the route. .wrn pages may embed api and realtime blocks, which the router extracts and mounts under /api/* and /realtime/*.
API
buildRouter(appDir, opts?): Router
Scan an app directory and build all route tables.
function buildRouter(appDir: string, opts?: RouterOptions): Router;
interface RouterOptions {
/** Extra dirs scanned for `.wrn` components (e.g. `@wrnexus/ui`), before
* `app/components`, so an app component of the same name wins. */
componentDirs?: string[];
}
The returned Router exposes the built tables plus per-kind matchers:
interface Router {
pages: Route[];
api: Route[];
realtime: Route[];
/** Absolute paths of middleware modules, in execution order (alphabetical). */
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;
}
interface ComponentRef {
/** Validated component name (matches a `data-component` attribute). */
name: string;
/** Absolute path to the component's `.wrn` module. */
file: string;
}
Component, layout, and schema names are validated with isSafeIslandName from @wrnexus/core; unsafe names are skipped with a warning. Realtime channel names are validated the same way.
Route matching
| Export | Signature | Description |
|---|---|---|
compileRoutePattern |
(raw: string) => Pick<Route, "regex" | "paramNames"> |
Compile a /users/[id] pattern into a RegExp (with optional trailing slash) plus ordered param names. |
matchRoute |
(routes: Route[], pathname: string) => RouteMatch | null |
Return the first route whose regex matches; captured params are decodeURIComponent-decoded. |
sortRoutes |
(routes: Route[]) => Route[] |
Order routes so static routes win over dynamic ones (fewer params first), then longer/more specific patterns first. |
interface Route {
raw: string; // e.g. "/users/[id]"
file: string; // absolute path to the handling module
regex: RegExp; // compiled matcher
paramNames: string[]; // ordered dynamic param names
}
interface RouteMatch {
route: Route;
params: Record<string, string>;
}
Typed-routes codegen
function generateRoutesFile(pages: Route[]): string;
Emits the source for app/routes.gen.ts: a Routes map (each page path → its [param] types), a RoutePath union, and an href() builder that fills params and rejects unknown paths at compile time. Entries are de-duplicated and sorted by path.
Re-exports
Middleware (the type from @wrnexus/core) is re-exported for callers that load middleware modules themselves.
Usage
import { buildRouter } from "@wrnexus/router";
const router = buildRouter("./app", {
componentDirs: ["./node_modules/@wrnexus/ui/components"],
});
// Resolve an incoming request.
const match = router.matchPage("/users/42");
if (match) {
console.log(match.route.file); // absolute path to the page module
console.log(match.params); // { id: "42" }
}
const api = router.matchApi("/api/hello");
const rt = router.matchRealtime("/realtime/chat");
Generating the typed-routes file (as wrnexus dev does):
import { generateRoutesFile } from "@wrnexus/router";
import { writeFileSync } from "node:fs";
const router = buildRouter("./app");
writeFileSync("./app/routes.gen.ts", generateRoutesFile(router.pages));
// Then, in app code, links are checked at compile time:
import { href } from "./routes.gen.ts";
href("/users/[id]", { id: "42" }); // "/users/42"
href("/about"); // "/about"
href("/nope"); // type error: unknown path
Lower-level pattern matching, if you need it directly:
import { compileRoutePattern, matchRoute, sortRoutes, type Route } from "@wrnexus/router";
const { regex, paramNames } = compileRoutePattern("/posts/[slug]");
const routes = sortRoutes([{ raw: "/posts/[slug]", file: "…", regex, paramNames }]);
const m = matchRoute(routes, "/posts/hello"); // { route, params: { slug: "hello" } }
Requirements / Notes
- Scanning uses
node:fs(existsSync,readdirSync,statSync) andnode:path— runs under Bun. - Depends on
@wrnexus/compilertoparse.wrnpages and extract embeddedapi/realtimeblocks. - Depends on
@wrnexus/coreforisSafeIslandName(name validation) and theMiddlewaretype. - Missing route directories are tolerated — a route kind you don't use simply yields an empty table.