WrNexus
WRNexusJS 0.3 architecture: see the language specification, architecture guide, upgrade guide, and 40-point implementation matrix. Validate a release with the one-by-one test checklist and audit record. An SSR-first full-stack web framework MVP with server-rendered, reactive components. Built in TypeScript, Bun-first, Node-friendly where possible.
It gives you file-based pages and API routes, middleware, realtime WebSocket
routes, opt-in client hydration, and the custom .wrn language/compiler through
one shared syntax and runtime model.
📘 The Complete Guide — one document covering every package, the whole
.wrnlanguage, all attributes/config properties, and a step-by-step path fromcreateto a full app (pages, APIs, DB, auth, realtime, deploy).
Why SSR-first with reactive components?
- Fast, robust default. Pages render to HTML on the server, so users get content immediately and pages work even before (or without) JavaScript.
- Ship JS only where needed. A page is plain HTML until it mounts a component
with
data-component="<name>". Components render on the server too; only their reactive state and forms run on the browser — the rest ships zero JavaScript. - Clean separation. Server rendering (
@wrnexus/ssr) never touches the DOM or client runtime; client hydration (@wrnexus/csr) never runs on the server. They meet only at one seam: a single runtime,/__wrnexus/reactive.js.
Installation
Requires Bun ≥ 1.3.0.
bun install
bun run dev # runs examples/basic-app at http://localhost:3000
CLI commands
wrnexus dev [app-dir] [--port=3000] # start the dev server (HMR + regenerates typed queries)
wrnexus build [app-dir] # build the production server bundle + assets
wrnexus generate mobile # scaffold a Capacitor iOS/Android shell
wrnexus mobile add @capacitor/camera # install a native plugin + sync projects
wrnexus create <app-name> # scaffold a new app
wrnexus generate <type> <name> # scaffold a page | component | api | schema (alias: g)
wrnexus eject <name...> # copy a Wire UI component into app/components
wrnexus test [app-dir] [--watch] # run the app's tests (bun test, `test` profile)
wrnexus profiles [app-dir] # list config profiles (dev/prod/uat/…) + their env files
# Database
wrnexus db migrate # apply pending migrations
wrnexus db rollback # revert the last migration
wrnexus db status # list applied / pending migrations
wrnexus db new <name> [--from-models] # scaffold a migration (optionally from TS models)
wrnexus db generate # (re)generate typed queries from app/db/queries/*.sql
wrnexus db seed # run app/db/seed.ts (re-runnable dev data)
wrnexus db studio [table] # list tables + row counts, or dump a table's rows
Mobile and PWA
The same view HTML renders in browsers and in the Capacitor app. Add device
directives directly to normal elements. Camera directives use the native camera
in Capacitor, phone camera capture in supported mobile browsers, and a file
picker in desktop browsers. The markup and responsive theme remain shared.
Cross-platform capabilities can also be declared directly in .wrn markup:
<button
data-native-browser="share"
data-native-mobile="share"
data-native-options='{"title":"WrNexus","url":"https://example.com"}'
@browser-click="browserShares++"
@mobile-click="mobileShares++"
>
Share
</button>
<p data-native-only="browser">Browser help</p>
<nav data-native-only="mobile" hidden>Native navigation</nav>
<button data-native-requires="haptics" data-native-mobile="haptics">Tap</button>
Use wrnexus native list to see built-in capabilities and
wrnexus native add camera share to install the packages required by the configured
Capacitor or Expo mode. Advanced browser code can use native.run() and register
custom adapters from @wrnexus/native.
The declarative data-native-browser and data-native-mobile capability actions run
in browser and Capacitor/WebView pages. Fully native Expo compilation supports
data-native-only and @mobile-* event selection, but capability calls must currently
use the installed Expo module from native screen code; the compiler reports an
actionable error instead of silently dropping a data-native-mobile action.
page Home {
view {
<main>
<img data-mobile-photo alt="Captured photo" />
<button data-mobile-camera="[data-mobile-photo]">Take photo</button>
<nav data-mobile-only hidden class="bottom-nav">Home · Profile · Settings</nav>
</main>
}
}
Configure native and installable-web behavior in wrnexus.config.ts:
const config: AppConfig = {
mobile: {
mode: "webview", // "webview" (Capacitor) or "native" (Expo/React Native)
enabled: true,
appId: "com.example.app",
appName: "Example",
serverUrl: "http://192.168.0.10:3000",
// Native mode uses this backend URL instead of rendering server HTML:
apiUrl: "https://api.example.com",
scheme: "example",
layout: "mobile", // optional app/layouts/mobile.wrn
icon: "resources/icon.png",
// Mode-specific advanced settings:
capacitor: { ios: { contentInset: "automatic" } },
expo: { orientation: "portrait" },
},
pwa: {
enabled: true,
id: "/",
name: "Example",
shortName: "Example",
startUrl: "/",
scope: "/",
orientation: "any",
display: "standalone",
themeColor: "#6366f1",
backgroundColor: "#0f172a",
offlineUrl: "/",
cacheUrls: ["/", "/about"],
cacheName: "wrnexus-pwa-v1", // change to invalidate installed caches
},
};
PWA support is on by default: the framework serves /site.webmanifest, /sw.js,
injects install metadata, precaches configured offline URLs, and registers a
network-first service worker. Set pwa.serviceWorker: false to keep only the
manifest, or pwa: false to disable PWA support. Generated Capacitor shells include error.html, so
an unreachable server shows a retry screen instead of a blank WebView.
Generate the configured mode with wrnexus generate mobile. You can override it
once with --mode=webview or --mode=native. WebView mode shares .wrn pages
through Capacitor. Native mode creates an Expo/React Native project with native
controls, file-based screens, and a typed backend helper; it shares WrNexus API,
upload, authentication, and realtime endpoints, but not HTML views.
In native mode, wrnexus mobile compile converts app/pages/**/*.wrn into
matching Expo Router routes under mobile/app/. Portable tags are mapped to
React Native primitives (div/main → View, text tags → Text, button →
Pressable, input → TextInput, and img → Image). State, interpolation,
events, {#if}, {#each}, class styles, and links are compiled to native JSX.
Unsupported DOM elements, inline CSS strings, SSR/data blocks, and browser APIs
produce compile errors instead of silently falling back to a WebView.
From this repo the same commands are wired as root scripts:
bun run dev # = wrnexus dev examples/basic-app
bun run build # = wrnexus build examples/basic-app
bun run create # = wrnexus create
Quality checks are wired at the repo root:
bun run typecheck # TypeScript check
bun run lint # ESLint
bun run lint:fix # ESLint autofix
bun run format # Prettier write
bun run format:check # Prettier check
bun run check # typecheck + lint + format:check
Apps created with wrnexus create <app-name> include the same ESLint/Prettier
baseline for app code.
Project structure
myframework/
packages/
core/ # Context, Middleware types, security + error helpers
cli/ # `wrnexus` command (dev / build / create)
dev-server/ # Bun.serve HTTP + WebSocket pipeline
router/ # file-based router (scan + safe match)
ssr/ # server-side document rendering
csr/ # browser reactive runtime (hydrates components)
styles/ # global stylesheet pipeline + app config (CSS frameworks)
compiler/ # `.wrn` language compiler (lexer/parser/codegen)
reactive/ # tiny signal() implementation
examples/
basic-app/ # reference app exercising every feature
app/
pages/ # file-based pages -> /, /about
api/ # file-based API routes -> /api/*
middleware/ # global middleware (alphabetical order)
realtime/ # WebSocket routes -> /realtime/*
components/ # reactive components (.wrn, data-component="name")
styles/ # global CSS (global.css -> every page)
public/ # static assets -> /robots.txt, /images/logo.svg
wrnexus.config.ts # optional: SEO, head injection, CSS, security
package.json
tsconfig.json
README.md
The app folder is convention-based — drop a file in the right directory and it becomes a route. Routes are resolved against a table scanned at startup; request input is never turned into a file path.
Pages
app/pages/index.tsx → /
export const meta = {
title: "Home",
description: "Welcome to WrNexus",
};
export default function Home() {
return `<h1>Hello from WrNexus</h1>
<div data-component="counter" start="0" label="Count"></div>`;
}
app/pages/about.tsx → /about. Dynamic segments use brackets:
app/pages/users/[id].tsx → /users/:id, with ctx.params.id available.
SSR
The server renders each page to a full HTML document. Page metadata is escaped
and placed in the <head>; the body goes inside #app:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Home</title>
<meta name="description" content="Welcome to WrNexus" />
</head>
<body>
<div id="app">
<h1>Hello from WrNexus</h1>
<div data-scope="start: 0, label: 'Count', count: 0">
<button data-on-click="count++">{label}: {count}</button>
</div>
</div>
<script type="module" src="/__wrnexus/reactive.js"></script>
</body>
</html>
The <div data-component="counter"> mount was replaced by the component's
server-rendered HTML, and the reactive runtime is injected only because the page
now contains a data-scope (see "Per-page code-splitting" below). Everything in
a page module is server-only — it is never bundled to the browser. Errors
are readable in development and generic (no file paths) in production.
Components (server-rendered, hydrated on the browser)
Components are .wrn files under app/components. They render on the server
(with their props applied) and are hydrated on the browser by the single generic
reactive runtime — they ship no JS of their own. Only forms and reactive state
run in the browser.
app/components/counter.wrn:
component Counter {
props {
start = 0 // default's type drives coercion (number)
label = "Count" // (string)
}
state count = start
view {
<button @click="count++">{label}: {count}</button>
}
}
Mount it in any page and pass props as attributes — pass as many as you like:
<div data-component="counter" start="5" label="Clicks"></div>
Each attribute becomes a prop, coerced to the type of its declared default (so
start="5" arrives as the number 5). Prop-driven text and attributes are
baked server-side (a static component ships zero JS); text that references
state stays reactive. Components (and layouts) can take children via <slot> —
including named slots: put <slot name="header"> in the component and fill it
from the mount with <div data-slot="header">…</div> (anything else fills the
default <slot>). Component names are validated before rendering.
Wire UI, theming & layouts
Wire UI (@wrnexus/ui) ships common components — button, input, select,
checkbox, radio, switch, textarea, badge, tag, alert, card,
avatar, spinner, skeleton, progress, tooltip, table, disclosure,
theme-toggle — plus layout primitives container, stack, hstack, grid,
divider, spacer. They are auto-discovered, so you mount them straight away:
<div data-component="stack" gap="4">
<div data-component="alert" variant="info" title="Hi" message="Server-rendered."></div>
<div data-component="button" variant="primary" label="Save"></div>
</div>
Override styles four ways, least → most control: change a theme token,
redefine a .wire-* class in your CSS, pass a class prop (appended to
the root), or wrnexus eject <name> to copy a component into app/components
and own it (an app component shadows the library one of the same name).
Theming. Design tokens (--wire-*) come from wrnexus.config.ts theme (deep
-merged over built-in light/dark), served at /__wrnexus/theme.css. The server
sets <html data-theme> from the wire-theme cookie (no flash); a theme-toggle
component (or any [data-wire-theme-toggle] element) switches and persists it.
Choose a complete semantic palette with theme: { palette: "violet" }. Available
palettes are blue, indigo, violet, emerald, cyan, rose, amber, and
slate; a custom palette must provide every primary, secondary, info, success,
warning, danger, hover, and contrast color.
Page layouts. Add named layouts under app/layouts/ — e.g. public.wrn,
dashboard.wrn, auth.wrn — each a component with a <slot></slot> where the
page body goes. A page picks one by name:
page Dashboard {
layout = "dashboard"
view { <h1>Overview</h1> ... }
}
Pages with no layout fall back to a default layout if one exists; layout = "none"
opts out. Layouts can mount components (nav, theme-toggle) like any page.
API routes
app/api/hello.ts → /api/hello:
export const GET = async () => Response.json({ message: "Hello API" });
Method-specific handlers live in one file; unsupported methods return 405
with an Allow header:
export const POST = async (ctx) => {
const body = await ctx.req.json();
return Response.json({ received: body });
};
Validation (one schema, form + API)
Define a schema once in app/schemas/<name>.ts with the fluent builder:
import { v } from "@wrnexus/validation";
export default v.object({
email: v.string().email(),
password: v.string().min(8, "Password must be at least 8 characters"),
});
Use it on the server in an API route — bad input is rejected even if the client is bypassed:
import { parseBody } from "@wrnexus/validation";
import login from "../schemas/login.ts";
export const POST = async (ctx) => {
const r = await parseBody(login, ctx.req);
if (!r.ok) return r.response; // 400 { ok:false, errors }
return Response.json({ ok: true });
};
And on the client by naming it on a form — the framework injects the schema
descriptor + a tiny, eval-free validator that checks on submit/blur and writes
messages into [data-error] spans (no per-form JS, CSP-safe):
<form data-schema="login" method="post" action="/api/login">
<input name="email"><span data-error="email"></span>
<input name="password" type="password"><span data-error="password"></span>
<button type="submit">Sign in</button>
</form>
Valid forms submit via fetch (JSON) and stay on the page: on success they
redirect (data-redirect) or reveal a [data-success] message; server-returned
field errors map back to the [data-error] spans.
i18n (pages + API)
Put translations in app/locales/<lang>.json (nested keys, {param} placeholders):
// app/locales/es.json
{ "home": { "title": "¡Hola desde WrNexus!" }, "api": { "greeting": "Hola" } }
Translate in views with the {t:key} sugar (and t:<attr>="key" for
attributes), and in API/handlers with ctx.t:
<h1>{t:home.title}</h1>
<input t:placeholder="email_ph">
export const GET = (ctx) => Response.json({ message: ctx.t("api.greeting"), lang: ctx.lang });
The active language is resolved per request from the wire-lang cookie → the
browser's Accept-Language → the config default (wrnexus.config.ts i18n.default),
and set on <html lang> (no flash). A [data-wire-lang-set="es"] element (or
<select data-wire-lang>) switches language and reloads. Missing keys fall back
to the default language, then to the key itself.
Database (@wrnexus/db)
Define models once in app/db/schema.ts — the source of truth for DDL,
migrations, and result typing:
import { v, table } from "@wrnexus/db";
export const users = table("users", {
id: v.id(),
email: v.string().unique(),
name: v.string(),
active: v.boolean().default(true),
createdAt: v.timestamp().default("now"),
});
Configure a connection in wrnexus.config.ts (db: { driver: "sqlite", url: "file:./dev.db" })
and run migrations with the CLI:
wrnexus db new init --from-models # generate a migration (CREATE TABLE) from your models
wrnexus db migrate # apply pending migrations (tracked in _wire_migrations)
wrnexus db status # [x] applied / [ ] pending
wrnexus db rollback # revert the last migration
Query with the client; results are mapped back through your model (SQLite 0/1 →
booleans, timestamps → Date, etc.):
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
const db = createDb(sqlite("file:./dev.db"));
const list = await db.all("SELECT * FROM users WHERE active = ?", [1], users);
await db.tx(async (t) => {
await t.exec("INSERT INTO users (email,name) VALUES (?,?)", [e, n]);
});
Typed queries (sqlc-style). Write annotated SQL in app/db/queries/*.sql and run
wrnexus db generate (also runs during wrnexus build) to get typed functions whose
params + results are inferred from your models:
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = :email;
-- name: CreateUser :exec
INSERT INTO users (email, name) VALUES (:email, :name);
import { GetUserByEmail, CreateUser } from "./db/queries.gen.ts";
const user = await GetUserByEmail(db, { email }); // Promise<{…} | null>, mapped through the model
await CreateUser(db, { email, name }); // Promise<ExecResult>
From pages & API routes. The framework opens the connection at startup (and
auto-migrates in dev), so routes just call getDb():
import { getDb } from "@wrnexus/db";
import { ListUsers } from "../../db/queries.gen.ts";
export const GET = async () => Response.json({ users: await ListUsers(getDb()) });
Adapters. SQLite (@wrnexus/db/sqlite, built on bun:sqlite) and — via Bun's
native Bun.SQL, no external driver — PostgreSQL (@wrnexus/db/postgres) and
MySQL (@wrnexus/db/mysql). Switch by setting db.driver and regenerating
migrations (wrnexus db new --from-models) for the new dialect. MongoDB
(@wrnexus/db/mongo) has a document collection API (db.collection(model).find(…))
and lazily loads the mongodb driver.
Middleware
app/middleware/logger.ts — runs before pages and API routes, in alphabetical
filename order:
export default async function logger(ctx, next) {
console.log(ctx.req.method, ctx.url.pathname);
return next();
}
Middleware receives a typed Context, can read/write ctx.locals, call
next() to continue, or return a Response early:
export default async function auth(ctx, next) {
if (ctx.url.pathname.startsWith("/dashboard")) {
return new Response("Unauthorized", { status: 401 });
}
return next();
}
Realtime rooms
A file in app/realtime/ exports default defineRoom({ … }) and is served at
ws://host/realtime/<name>. You write only the room logic — the framework
owns the entire client side. Each handler gets a RoomClient with everything
you need:
// app/realtime/chat.ts
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) {
client.broadcast({ type: "system", text: "A user joined", online: client.room.count() });
},
onMessage(client, msg) {
const text = String(msg.text ?? "")
.slice(0, 500)
.trim();
if (!text) return;
client.room.broadcast({ type: "message", user: msg.user ?? "anon", text });
},
onLeave(client) {
client.broadcast({ type: "system", text: "A user left" });
},
});
The client / client.room API:
| Call | Reaches |
|---|---|
client.send(msg) |
this connection |
client.broadcast(msg) |
everyone else in the room |
client.room.broadcast(msg) |
everyone, including the sender |
client.to(id | ids).send(msg) |
specific connection(s) |
client.toUser(u | users).send() |
a user / selected users (all their tabs) |
client.user = "u1" |
identify a connection for targeting |
client.data, client.room.state |
per-connection / shared room state |
Messages are objects (auto-serialized to JSON; incoming JSON is parsed). A
connection is identified by the authenticated session user, else ?user=.
Because the room name is the URL path, a dynamic route app/realtime/[room].ts
gives one handler many independent rooms (/realtime/lobby, /realtime/game-7).
Zero client JS. A page just declares a room; the framework's realtime runtime
(/__wrnexus/realtime.js, auto-injected) connects, renders incoming messages into
your <template>s (fields via %field%, HTML-escaped), reflects connection
state, and sends the form:
<div data-room="chat">
<span data-room-status data-room-status-class="wire-badge" class="wire-badge">…</span>
<div data-room-log></div>
<template data-room-item="message"
><div><strong>%user%</strong>: %text%</div></template
>
<template data-room-item="system"><div class="muted">%text%</div></template>
<form data-room-send>
<input name="user" placeholder="Name" />
<input name="text" placeholder="Message…" data-room-reset />
<button>Send</button>
</form>
</div>
Need imperative control? const room = wire.room("chat"); room.on("message", fn); room.send({ … }) — same runtime, no boilerplate. The example ships this exact
chat at /chat (app/pages/chat.wrn + app/realtime/chat.ts, no client file).
The raw websocket export (with Bun pub/sub via RealtimeSocket) is still
supported for low-level needs.
Reactive signals
@wrnexus/reactive ships a tiny, type-safe signal (no dependencies):
import { signal } from "@wrnexus/reactive";
const count = signal(0);
count.get(); // 0
count.set(1); // notifies subscribers
const off = count.subscribe((v) => console.log(v));
off(); // unsubscribe
This is the seed for richer reactive client state and the .wrn state block.
Global styles & CSS frameworks
Styles are global by default and work for both SSR and CSR (the stylesheet
is linked in every page's <head>, so it styles server-rendered markup and
hydrated components alike).
Put CSS in app/styles/global.css. It is bundled by Bun (which resolves
@import, including from node_modules), served at /__wrnexus/styles.css,
and linked into every page automatically.
Use any npm CSS framework by importing it in global.css:
/* app/styles/global.css */
@import "bootstrap/dist/css/bootstrap.min.css"; /* after: bun add bootstrap */
@import "./theme.css";
.card {
padding: 1rem;
& strong {
color: #fff;
}
} /* nesting works */
Use any framework via CDN (zero build) with wrnexus.config.ts:
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
head: [
`<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />`,
// or Tailwind Play CDN (dev/prototyping):
// `<script src="https://cdn.tailwindcss.com"></script>`,
],
};
export default config;
Use Tailwind / PostCSS / Sass for real via the styles.process hook — it
runs at dev-serve time and at wrnexus build, and returns the final CSS:
const config: AppConfig = {
styles: {
entry: "app/styles/global.css",
process: async ({ entryPath, appRoot }) =>
await Bun.$.cwd(appRoot)`bunx @tailwindcss/cli -i ${entryPath} --minify`.text(),
},
};
In production, wrnexus build bundles the stylesheet to dist/styles.css
(minified) and the server serves it from disk.
Public Assets
Put static files in public/ next to app/. They are served from the site root
and are copied to dist/public during wrnexus build:
public/
favicon.ico -> /favicon.ico
robots.txt -> /robots.txt
site.webmanifest -> /site.webmanifest
images/logo.svg -> /images/logo.svg
Static asset resolution is path-safe: no traversal, no hidden files, and no
request path is ever converted into a route module path. API routes keep
priority over public files; public files are checked before page routes. If an
app does not provide public/favicon.ico, WrNexus serves a tiny default favicon
so browsers do not log a missing favicon request.
SEO
Global SEO defaults live in wrnexus.config.ts and are merged with each page's
meta export:
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
seo: {
title: "WrNexus Basic App",
titleTemplate: "%s | WrNexus",
description: "A Bun-first SSR framework demo.",
canonicalBase: "https://example.com",
robots: "index,follow",
siteName: "WrNexus",
type: "website",
twitterCard: "summary",
themeColor: "#6c8cff",
},
};
export default config;
Pages can override these values:
export const meta = {
title: "About",
description: "Learn about this WrNexus app.",
canonical: "/about",
};
.wrn pages use a page-level seo block:
page Hello {
seo {
title = "Hello from .wrn"
description = "A WrNexus .wrn page with SSR and CSR data."
canonical = "/hello"
}
view {
<h1>Hello</h1>
}
}
WrNexus renders standard description/canonical tags plus Open Graph, Twitter, robots, keywords, and theme color tags when configured.
Security Headers And CORS
WrNexus applies framework-level security headers to pages, API responses, assets, errors, and framework endpoints. Defaults include:
Content-Security-Policywithscript-src 'self',object-src 'none',base-uri 'self', andframe-ancestors 'none'Strict-Transport-Securityin production with one-yearmax-age,includeSubDomains, andpreloadCross-Origin-Opener-Policy: same-originX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policywith risky browser capabilities disabled, includingunload=()- Trusted Types enforcement in production, defaulting to extension-compatible
policy creation. Set
trustedTypes.policyNamesfor a stricter allow-list.
CORS is opt-in from wrnexus.config.ts:
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
security: {
cors: {
enabled: true,
origin: ["https://app.example.com", "http://localhost:5173"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 600,
},
trustedTypes: {
// Optional stricter production mode:
// policyNames: ["wrnexus", "default"],
},
},
};
export default config;
If you inject CDN scripts/styles through head, extend the CSP explicitly:
const config: AppConfig = {
head: [`<script src="https://cdn.tailwindcss.com"></script>`],
security: {
contentSecurityPolicy: {
directives: {
"script-src": ["'self'", "https://cdn.tailwindcss.com"],
"style-src": ["'self'", "'unsafe-inline'"],
},
},
},
};
The Context object
export type Context = {
req: Request;
url: URL;
params: Record<string, string>;
locals: Record<string, unknown>;
cookies: CookieStore;
session: SessionStore;
localStorage: LocalStorageSnapshot;
};
export type Middleware = (
ctx: Context,
next: () => Promise<Response>,
) => Promise<Response> | Response;
Storage helpers are available in pages, API routes, middleware, and .wrn data
bindings:
ctx.cookies.get("theme");
ctx.cookies.set("theme", "dark", { sameSite: "Lax" });
ctx.session.set("userId", "42");
ctx.session.get<string>("userId");
ctx.localStorage.get("wrnexus.label"); // read-only snapshot from CSR requests
Security notes
- Safe route resolution. Routes are matched against a table scanned at startup; request paths are never concatenated into file paths.
- No path traversal. Hidden/underscore files are ignored; only
.ts/.tsx/.wrnroute files are loaded;isSafeRequestPathrejects..and null bytes as defense-in-depth. - HTML-escaped metadata. Titles/descriptions are escaped before entering the document head.
- Validated component names.
data-componentvalues and component filenames must match[A-Za-z0-9_-]+; arbitrary imports from request input are impossible. - Security headers by default. CSP, HSTS in production, COOP, X-Frame-Options, Trusted Types in production, nosniff, referrer policy, and Permissions-Policy are applied centrally by the runtime.
- Configurable CORS.
security.corshandles preflight requests and appliesAccess-Control-*headers only for allowed origins. - Server-only
.wrnhelpers.functions {},ssr { ... }, andclient { ... }data bindings stay in the server module and are never serialized into HTML. Client data binding elements receive only an opaquedata-wrnexus-csrid; the browser calls/__wrnexus/csr, and WrNexus resolves the real API path/render helper on the server. Inline client directives such as@clickshould contain only behavior that is safe to reveal to the browser. - Readable vs. safe errors. Development shows the stack; production returns a generic page and never leaks internal file paths.
- 405 with
Allow. Unsupported API methods are reported correctly; unknown routes return 404.
Future .wrn language vision
A single-file language that compiles down to the primitives above (pages, components, API routes, realtime handlers, signals):
page Home {
state count = 0
ssr {
api users GET /api/users {
return users.map((user) => user.name).join(", ")
}
}
client {
api latestUsers GET /api/users/latest {
return users.map((user) => user.name).join(", ")
}
}
view {
<h1>Hello</h1>
<button @click="count++">Count: {count}</button>
<div api="users">Loading users...</div>
<div api="latestUsers">Loading latest users...</div>
}
realtime chat {
on message(data) {
broadcast(data)
}
}
}
@wrnexus/compiler now parses a small real subset of .wrn; the longer-term vision
is still captured in packages/compiler/VISION.md.
Roadmap
- File-based pages, API routes, middleware, realtime
- SSR-first rendering with escaped metadata
- Server-rendered reactive components with props (
.wrn,data-component) - Type-safe signals
- HMR over WebSocket (instant CSS swap; soft DOM morph that keeps state — no visible refresh)
- Reactive directives bound to signals (
data-scope,data-on-*,data-text,{expr}) - Per-page code-splitting (the reactive runtime ships only where used)
- Production build (
wrnexus build→ static manifest + bundleddist/server.js) - Real
.wrncompiler (lexer → parser → codegen, compiled in dev and prod) - Global styles + any CSS framework (Bun CSS bundler, CDN
head, Tailwind/PostCSS hook) - Scoped / per-component CSS
- Fine-grained reactivity (per-binding dependency tracking)
- In-process HMR for page modules too (avoid restart via versioned recompile)
- Node adapter for
Bun.serve-free deployment
Newer capabilities
HMR over WebSocket (dev)
wrnexus dev aims for updates that never look like a refresh. The page holds a
WebSocket to /__wrnexus/hmr, and the server watches app/ and picks the cheapest
update per change:
| You edit… | What happens | Reload? |
|---|---|---|
app/styles/*.css |
server pushes {type:"css"}; the browser hot-swaps the stylesheet |
none — instant |
| pages / components / api / middleware / realtime | module can't be re-imported in-process, so the child restarts; the browser reconnects and morphs the new HTML into the live DOM | none — soft morph |
The soft morph is an index-based DOM diff that preserves scroll, focus, and
client-owned state: hydrated subtrees (data-scope regions) are left
untouched, so a counter at 5 stays at 5 instead of snapping back to the
server-rendered 0. CSS edits — the most common tweak — never restart the
process and apply with zero flash.
Reactive directives
A page subtree can be made reactive declaratively — the same mechanism that
hydrates components. The
generic reactive runtime (/__wrnexus/reactive.js, injected only when a page uses
data-scope) turns each scope key into a signal():
<div data-scope="count: 0">
Count is <strong data-text="count">0</strong>, doubled is {count * 2}.
<button data-on-click="count++">+1</button>
<button data-on-click="count = 0">reset</button>
</div>
data-on-<event> runs a statement, data-text binds an element's text, and
{expr} interpolates inside text nodes. This is exactly what the .wrn compiler
emits.
In TSX pages, raw {count * 2} is parsed by TypeScript as a server-side
JavaScript expression. Use the helper to emit client-side mustache text:
import { mustache } from "@wrnexus/core";
export default function Page() {
return <p>Doubled is {mustache`count * 2`}.</p>;
}
The browser receives {{count * 2}}, and the reactive runtime evaluates it
against the nearest data-scope.
Per-page code-splitting
Components render on the server, so the only browser script is the reactive
runtime (/__wrnexus/reactive.js), injected only when the rendered page contains
a data-scope. /about ships zero JS; / and /reactive ship only the shared
reactive.js.
Production build
wrnexus build examples/basic-app # → examples/basic-app/dist
bun examples/basic-app/dist/server.js # PORT optional
dist/server.js is a single bundled Bun server with a static manifest of
every route and component module (no runtime filesystem scan, no on-the-fly
bundling). It serves production error pages and ships no live-reload client.
Framework JS/CSS/public assets use long production cache headers, and every
output is minified: server.js (which bundles all page/component/route
modules), the reactive runtime (dist/reactive.js), and the stylesheet
(dist/styles.css).
Writing a page in .wrn
The view block is plain HTML — nothing new to learn. Text may contain
{expr} interpolation, attributes can be hyphenated (data-*, aria-*), void
elements (<br>, <img>) and self-closing tags (<x/>) work, and
@event="..." declares a client event binding (compiled to data-on-<event>).
app/pages/hello.wrn → /hello:
page Hello {
state count = 0
seo {
title = "Hello from .wrn"
description = "A WrNexus .wrn page showing SSR data, CSR hydration, cookies, sessions, and localStorage."
canonical = "/hello"
}
ssr {
functions {
function userNames(users) {
return users.map((user) => user.name).join(", ")
}
}
api ssrUsers GET /api/users/ssr {
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
session.set("lastHelloVisit", visits)
return `${userNames(users)} - visit ${visits}`
}
}
client {
functions {
function userNames(users) {
return users.map((user) => user.name).join(", ")
}
}
api csrUsers GET /api/users/csr {
const label = localStorage.get("wrnexus.label") ?? "browser"
session.set("lastClientLabel", label)
return `${userNames(users)} - ${label}`
}
}
view {
<h1>Hello from .wrn</h1>
<p>Hello, WrNexus. Count is {count}, doubled is {count * 2}.</p>
<div api="ssrUsers">Loading SSR users...</div>
<div api="csrUsers">Loading CSR users...</div>
<div class="my-actions">
<button @click="count++">Increment</button>
<button @click="count = 0">Reset</button>
</div>
}
style {
.my-actions {
display: flex;
gap: 0.5rem;
}
}
realtime hello {
on message(data) {
console.log(data)
}
}
}
The business logic and data definitions live in normal file-based API routes:
// app/api/users/ssr.ts -> /api/users/ssr
export const GET = async () => {
return Response.json({
users: [
{ id: 1, name: "Ada SSR" },
{ id: 2, name: "Grace SSR" },
],
});
};
ssr { api ... } runs before the HTML is sent. client { api ... } hydrates
after the first paint through /__wrnexus/csr?route=<page>&id=<binding>.
Inside .wrn data blocks, cookies, session, and localStorage are available
directly. localStorage is a read-only snapshot sent by the browser for CSR
data bindings; SSR cannot read browser localStorage before the browser makes a
request.
The compiler (@wrnexus/compiler: tokenizer.ts → parser.ts → codegen.ts)
lowers view to SSR HTML, style to a page-local inline stylesheet, state to
data-scope, functions to server-only helpers, @click to data-on-click,
and keeps {expr} as mustache — all hydrated by the reactive runtime above.
Server helper code is never serialized into HTML. SSR bindings replace the
element content before HTML is returned. Client bindings keep the loading text
in the SSR HTML, then fetch after hydration. The target element receives only an
opaque data-wrnexus-csr marker; the real API path, response mapping, and helper
functions stay server-side. Legacy ssrGet/csrGet attributes still work, but
new code should prefer named ssr/client blocks.
Embedded realtime blocks register /realtime/<name> WebSocket routes. .wrn
files are compiled transparently in both dev and build.
Authentication, CSRF, sessions
Password hashing (argon2id via Bun.password), session login/logout, and a
route guard live in @wrnexus/core:
import {
hashPassword,
verifyPassword,
logIn,
logOut,
getUser,
requireAuth,
sessionAuth,
} from "@wrnexus/core";
// Registration
const passwordHash = await hashPassword(password);
// Login (API route)
const user = await GetUserByEmail(getDb(), { email });
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return Response.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
}
logIn(ctx, { id: user.id, email: user.email }); // stores a safe user object in the session
// Read the current user anywhere
const me = getUser(ctx); // null when anonymous
Add sessionAuth() early in the middleware chain to hydrate ctx.user on every
request, and requireAuth() to protect routes — it returns 401 JSON for
/api/* (and Accept: application/json) requests and a 302 redirect to
/login?next=… for page navigations. logOut(ctx) clears the session.
CSRF protection uses the double-submit-cookie pattern and is on by default:
page loads set a readable wire-csrf cookie, the form runtime echoes it as the
x-csrf-token header, and verifyCsrf(ctx) (or the csrfProtection()
middleware) rejects mismatches with 403. Safe methods (GET/HEAD/OPTIONS) always
pass.
Client-side navigation
Served on every page as /__wrnexus/nav.js — a progressive enhancement that
intercepts same-origin <a> clicks, fetches the target, swaps the #app
container, and updates history/title/scroll with no full reload. It ensures any
framework runtimes the new page needs are loaded on demand, re-hydrates reactive
scopes, and falls back to a full navigation on cross-origin links, modified
clicks, non-HTML responses, or a missing #app. Opt a link out with
data-no-nav. Programmatic navigation: window.__wrnexusNavigate(url); forms
with data-redirect use it automatically. Server data loads on the server (SSR
api bindings), so the fetched HTML already carries fresh data.
Environment configuration
Validate environment variables at startup with the same schema builder used for forms — one readable error lists every problem:
// app/env.ts
import { v, parseEnv } from "@wrnexus/validation";
export const env = parseEnv<{ DATABASE_URL: string; PORT?: number }>(
v.object({ DATABASE_URL: v.string().min(1), PORT: v.number().optional() }),
);
Values are read from Bun.env / process.env and coerced by the schema
(PORT → number, flags → boolean).
Config profiles (dev / prod / uat / test / …)
Run the same project under different named profiles — each bundles config
overrides and an .env cascade. Select one with --profile=<name> (on
dev, build, db) or the WRNEXUS_PROFILE env var:
wrnexus dev --profile=uat # dev server with UAT config + .env.uat
wrnexus build --profile=production # prod build with production overrides
wrnexus db migrate --profile=uat # migrate the UAT database
wrnexus profiles # list profiles + their env files, mark the active one
Define profiles in wrnexus.config.ts — each block is deep-merged over the
base config when active:
export default {
db: { driver: "sqlite", url: "file:./dev.db" }, // base (development)
profiles: {
production: { db: { driver: "postgres", url: process.env.DATABASE_URL! } },
uat: {
db: { driver: "postgres", url: process.env.DATABASE_URL! },
seo: { robots: "noindex,nofollow" },
},
test: { db: { driver: "sqlite", url: "file:./test.db" } },
},
};
Env files load in precedence order (later wins, and real env vars always win):
.env → .env.<profile> → .env.local → .env.<profile>.local. Keep secrets
in the .local files (git-ignored); commit .env / .env.<profile> for shared
non-secret defaults. In production the built server also loads the deployment's
.env.<profile> at startup for runtime secrets.
Deployment adapters
The production server is split into a portable request handler and a Bun server:
import { createProductionHandlers, serveNode } from "@wrnexus/dev-server";
const { fetch, websocket } = createProductionHandlers(manifest, options); // WinterCG (req) => Response
Bun.serve({ fetch, websocket }); // Bun (default, includes WebSockets)
await serveNode(fetch, { port: 3000 }); // node:http bridge (HTTP only)
serveNode / nodeListener bridge the fetch handler onto node:http
(converting IncomingMessage ↔ Request/Response, preserving multiple
Set-Cookie headers). Note the production handler uses Bun-native APIs for
assets, WebSockets, and the database, so full Node hosting needs Bun-compatible
globals; the bridge is ideal for WinterCG hosts and embedding.
Rate limiting, logging, caching, uploads, streaming
All in @wrnexus/core:
import {
rateLimit,
requestLogger,
TTLCache,
cacheControl,
withCacheControl,
etag,
notModified,
saveUpload,
collectUploads,
streamResponse,
sse,
} from "@wrnexus/core";
// Rate limit (fixed window, per client IP) — sets RateLimit-* + Retry-After
export default rateLimit({ max: 60, windowMs: 60_000 });
// Structured request logging — pretty in dev, JSON in prod; adds a request id
export default requestLogger({ format: "json" });
// Data cache with TTL (memoise expensive work)
const cache = new TTLCache<User[]>(30_000);
const users = await cache.getOrLoad("active", () => ListActiveUsers(getDb()));
// HTTP caching + conditional requests
const body = renderPage();
const tag = etag(body);
if (notModified(ctx.req, tag)) return new Response(null, { status: 304 });
return withCacheControl(new Response(body, { headers: { etag: tag } }), { maxAge: 60 });
// File uploads (Bun parses multipart natively)
for (const { file } of collectUploads(await ctx.req.formData())) {
await saveUpload(file, {
dir: "./uploads",
maxBytes: 5_000_000,
allowedTypes: ["image/png", ".jpg"],
});
}
// Streaming SSR / SSE
return streamResponse(
(async function* () {
yield "<h1>";
yield await slowPart();
yield "</h1>";
})(),
);
return sse(
(async function* () {
yield { event: "tick", data: String(Date.now()) };
})(),
);
Database: pagination, relations, seeding, studio
import { paginate, loadRelated } from "@wrnexus/db";
// Offset pagination with metadata (total, totalPages, hasNext/hasPrev)
const page = await paginate(
getDb(),
{ sql: "SELECT * FROM users ORDER BY name", model: users },
{ page: 2, perPage: 20 },
);
// Batched relation loading (no N+1)
const authors = await getDb().all("SELECT * FROM users", [], users);
const withPosts = await loadRelated(getDb(), authors, {
table: "posts",
foreignKey: "userId",
as: "posts",
});
wrnexus db seedruns a re-runnableapp/db/seed.ts(adefault async (db) => {}).wrnexus db studiointrospects the connected database — list tables with row counts, or dump a table's first rows.wrnexus devregeneratesapp/db/queries.gen.tsfrom your.sqlat startup.
SSR state-text baking
Reactive interpolations in pages and components bake their initial value into
a data-text span, so no-JS clients see real content (Count: 0, not
Count: {count}) and the reactive runtime keeps it live after hydration.
Component props also resolve {t:key} i18n markers per request, so you can pass
localized text into a component: <div data-component="badge" label="{t:status.new}">.
Production hardening
Shared, pluggable stores. Sessions and rate-limit counters default to process-local memory; swap in a shared backend so they survive restarts and work across instances. A persistent SQLite backend ships built-in:
import { setSessionBackend, rateLimit } from "@wrnexus/core";
import { sqliteSessionStore } from "@wrnexus/db/session";
setSessionBackend(sqliteSessionStore("./sessions.db")); // or implement SessionBackend (Redis/SQL)
rateLimit({ max: 100, store: myRateLimitStore }); // store implements RateLimitStore
Sessions regenerate their id on login (fixation defense), expire on a 24h idle
TTL, and are GC'd; ids are 256-bit. rateLimit keys on the non-spoofable socket
peer IP by default (trustProxy: true to honour x-forwarded-for behind a proxy).
ETag / 304. Rendered pages send a content ETag and answer conditional GETs with a 304 when unchanged — hashed on page content, so the per-request CSP nonce doesn't defeat it.
CSP nonces. Every request gets a nonce (ctx.locals.cspNonce); framework
inline scripts carry it and CSP script-src uses 'nonce-…' instead of
'unsafe-inline'. Use it for your own inline scripts under a strict policy.
gzip. Text responses are gzipped when the client accepts it (~65% smaller
HTML); streaming/SSE opt out via no-transform.
Live Postgres / MySQL. bun run test:db:live starts both via
docker-compose.yml, runs the gated adapter tests, and tears down. The suite
self-skips without WRNEXUS_PG_URL / WRNEXUS_MYSQL_URL.
Dynamic lists, typed routes, WS security
data-forrenders reactive lists —<li data-for="t in todos">{t.text}</li>. The client expression language now supports member access, calls, arrays, objects, comparison, logical, and ternary — all eval-free (strict-CSP safe).- Typed routes.
wrnexus devwritesapp/routes.gen.ts(aRoutesmap +href()), sohref("/users/[id]", { id })is checked at compile time. - WebSocket origin check rejects cross-site handshakes (CSWSH); rooms gate
with
authorize; requests overmaxBodyBytesget a 413;/healthz+ graceful shutdown for containers.
Optional packages
Opt-in helpers and feature packages — import only what you need:
| Package | What it gives you |
|---|---|
@wrnexus/helpers |
Context URL helpers and safe forward-auth redirectToLogin responses |
@wrnexus/jwt |
HS256 signJwt/verifyJwt + jwtAuth bearer middleware (stateless auth) |
@wrnexus/oauth |
OAuth 2.0 sign-in with PKCE — Google/GitHub/Discord presets + defineProvider |
@wrnexus/authz |
Authorization: RBAC (defineRbac), PBAC/ABAC policies (any/all/attr), guards |
@wrnexus/encryption |
AES-256-GCM encrypt/decrypt, generateKey, PBKDF2 deriveKey, sha256, HMAC |
@wrnexus/pubsub |
Topic pub/sub with wildcards + a pluggable driver (Redis/NATS) |
@wrnexus/queue |
Background job queue — delays, retries + backoff, recurring jobs, workers |
@wrnexus/tracking |
Error tracking — capture, pluggable sinks, and a request-capturing middleware |
@wrnexus/test |
App testing — renderComponent, mountHtml, callRoute, createHarness + bun:test |
import { signJwt, jwtAuth } from "@wrnexus/jwt";
import { getOriginalRequestUrl, redirectToLogin } from "@wrnexus/helpers";
import { google, startAuth, completeAuth } from "@wrnexus/oauth";
import { defineRbac, requirePermission, any, attr } from "@wrnexus/authz";
import { encrypt, decrypt, generateKey, sha256, hmacSign } from "@wrnexus/encryption";
import { createPubSub } from "@wrnexus/pubsub";
import { createQueue } from "@wrnexus/queue";
import { createTracker, consoleSink } from "@wrnexus/tracking";
OAuth in three lines (Google shown; GitHub/Discord/custom are identical):
const provider = google({ clientId, clientSecret });
const { url, state, verifier } = await startAuth(provider, { redirectUri }); // store state+verifier, 302 to url
const { profile } = await completeAuth(provider, { code, redirectUri, verifier }); // on the callback → logIn(ctx, profile)
Validation gained rules — url(), uuid(), date(), length(),
oneOf([...]), trim(), .default(v), and server-only .refine(fn) — plus
v.number().positive(). All (except refine) mirror to the client validator.
i18n gained locale formatting: formatNumber, formatCurrency, formatDate,
formatRelativeTime, and CLDR plural(n, forms, lang). The reactive runtime
gained data-show="expr" for conditional visibility (tabs, toggles).
Containerization. wrnexus generate docker scaffolds a multi-stage
Dockerfile (build with Bun → slim runtime, health-checked on /healthz), a
.dockerignore, and a docker-compose.yml (app + Postgres). Then
docker compose up --build.
Testing
Write tests for your app with @wrnexus/test — one import gives you the
bun:test primitives (test, expect, describe, …) plus WrNexus helpers.
Run them with wrnexus test (which defaults to the test profile, so it loads
.env.test and your config's test overrides):
// app/example.test.ts
import { test, expect, renderComponent, callRoute, createHarness } from "@wrnexus/test";
// 1. Render a component to HTML (fast, no server)
test("counter shows its label", async () => {
const html = await renderComponent(COUNTER_SRC, { start: 5, label: "Clicks" });
expect(html).toContain("Clicks");
});
// 2. Call an API handler with a fake Request
test("echo route", async () => {
const { POST } = await import("./api/echo.ts");
const res = await callRoute(
POST,
new Request("http://t/api/echo", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(200);
});
// 3. Boot the whole app on an ephemeral port and fetch real routes
test("home page responds", async () => {
const app = await createHarness(import.meta.dir + "/..");
const res = await app.fetch("/");
expect(res.status).toBe(200);
app.close();
});
@wrnexus/test also exports mountHtml(html) — mounts server-rendered HTML in a
happy-dom window with the reactive runtime hydrated, so you can assert on
data-for/data-show/data-text behaviour.
wrnexus test # run once (test profile)
wrnexus test --watch # re-run on change
The framework itself ships a bun test suite across the compiler, reactive
runtime (with dependency tracking), client navigation, validation, i18n,
theming, auth/CSRF/sessions, security headers, middleware
(rate-limit/logging/cache/uploads/streaming), the node adapter, and the database
(CRUD, migrations, query generation, pagination, relations, SQLite sessions).
Run bun run check for typecheck + lint + tests + format.
Editor support (VS Code)
The editors/vscode extension gives .wrn files first-class editing:
- Syntax highlighting with embedded languages — HTML inside
view(including{expr}interpolation,{t:key}translations and@event=bindings), CSS insidestyle, and TypeScript insidefunctions/api/ssr/client/realtime. - WrNexus attributes stand out in their own colors so you can spot them at a
glance:
@eventbindings and{t:…}translations render in one accent, and runtime directives (data-component,data-for,data-show,data-text,data-scope,data-slot,data-on-*,data-wire-*) in another — via a grammar injection, so they're distinct even inside ordinary HTML. Override the colors in your settings undereditor.tokenColorCustomizations→ theentity.other.attribute-name.wrn.*scopes. - Inline diagnostics — parse errors from the real
@wrnexus/compilerappear as you type, anchored to the exact offset. - Snippets —
page,component,view,state,props,seo,api,ssr,client,realtime,functions,style, plus view helpersmount,for,show,t. - Completions — block keywords at file scope;
data-*attributes and@eventbindings inside aview; HTTP methods afterapi.
Load it from editors/vscode (press F5 for an Extension Development
Host). Diagnostics use a bundled copy of the compiler — regenerate it with
bun run build:compiler after changing @wrnexus/compiler.