Files
WRNexusJS/README.md
T

54 KiB

WrNexus

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, and opt-in client hydration — with a small, readable codebase designed so a custom .wrn language/compiler can be layered on later.

📘 The Complete Guide — one document covering every package, the whole .wrn language, all attributes/config properties, and a step-by-step path from create to 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.1.

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/mainView, text tags → Text, buttonPressable, inputTextInput, and imgImage). 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.

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 }) =>
      await Bun.$`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-Policy with script-src 'self', object-src 'none', base-uri 'self', and frame-ancestors 'none'
  • Strict-Transport-Security in production with one-year max-age, includeSubDomains, and preload
  • Cross-Origin-Opener-Policy: same-origin
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy with risky browser capabilities disabled, including unload=()
  • Trusted Types enforcement in production, defaulting to extension-compatible policy creation. Set trustedTypes.policyNames for 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/.wrn route files are loaded; isSafeRequestPath rejects .. and null bytes as defense-in-depth.
  • HTML-escaped metadata. Titles/descriptions are escaped before entering the document head.
  • Validated component names. data-component values 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.cors handles preflight requests and applies Access-Control-* headers only for allowed origins.
  • Server-only .wrn helpers. functions {}, ssr { ... }, and client { ... } data bindings stay in the server module and are never serialized into HTML. Client data binding elements receive only an opaque data-wrnexus-csr id; the browser calls /__wrnexus/csr, and WrNexus resolves the real API path/render helper on the server. Inline client directives such as @click should 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 + bundled dist/server.js)
  • Real .wrn compiler (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.tsparser.tscodegen.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 IncomingMessageRequest/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 seed runs a re-runnable app/db/seed.ts (a default async (db) => {}).
  • wrnexus db studio introspects the connected database — list tables with row counts, or dump a table's first rows.
  • wrnexus dev regenerates app/db/queries.gen.ts from your .sql at 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-for renders 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 dev writes app/routes.gen.ts (a Routes map + href()), so href("/users/[id]", { id }) is checked at compile time.
  • WebSocket origin check rejects cross-site handshakes (CSWSH); rooms gate with authorize; requests over maxBodyBytes get 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 inside style, and TypeScript inside functions/api/ssr/client/realtime.
  • WrNexus attributes stand out in their own colors so you can spot them at a glance: @event bindings 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 under editor.tokenColorCustomizations → the entity.other.attribute-name.wrn.* scopes.
  • Inline diagnostics — parse errors from the real @wrnexus/compiler appear as you type, anchored to the exact offset.
  • Snippetspage, component, view, state, props, seo, api, ssr, client, realtime, functions, style, plus view helpers mount, for, show, t.
  • Completions — block keywords at file scope; data-* attributes and @event bindings inside a view; HTTP methods after api.

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.