1517 lines
55 KiB
Markdown
1517 lines
55 KiB
Markdown
# WrNexus
|
|
|
|
> **WRNexusJS 0.3 architecture:** see [the language specification](docs/WRN-LANGUAGE-SPEC-1.0.md),
|
|
> [architecture guide](docs/ARCHITECTURE-0.3.md), [upgrade guide](docs/UPGRADE-0.3.md), and
|
|
> [40-point implementation matrix](docs/40-POINT-IMPLEMENTATION-0.3.md). Validate a release with
|
|
> the [one-by-one test checklist](docs/TEST-CHECKLIST-0.3.md) and [audit record](docs/AUDIT-0.3.md).
|
|
> 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](docs/GUIDE.md)** — 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](https://bun.sh) ≥ 1.3.0.
|
|
|
|
```bash
|
|
bun install
|
|
bun run dev # runs examples/basic-app at http://localhost:3000
|
|
```
|
|
|
|
## CLI commands
|
|
|
|
```bash
|
|
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:
|
|
|
|
```html
|
|
<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.
|
|
|
|
```wrn
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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` → `/`
|
|
|
|
```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`:
|
|
|
|
```html
|
|
<!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`:
|
|
|
|
```ts
|
|
export const GET = async () => Response.json({ message: "Hello API" });
|
|
```
|
|
|
|
Method-specific handlers live in one file; unsupported methods return **405**
|
|
with an `Allow` header:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```json
|
|
// 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">
|
|
```
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```bash
|
|
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.):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```sql
|
|
-- name: GetUserByEmail :one
|
|
SELECT * FROM users WHERE email = :email;
|
|
-- name: CreateUser :exec
|
|
INSERT INTO users (email, name) VALUES (:email, :name);
|
|
```
|
|
|
|
```ts
|
|
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()`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```html
|
|
<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):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```tsx
|
|
export const meta = {
|
|
title: "About",
|
|
description: "Learn about this WrNexus app.",
|
|
canonical: "/about",
|
|
};
|
|
```
|
|
|
|
`.wrn` pages use a page-level `seo` block:
|
|
|
|
```my
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```my
|
|
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`](packages/compiler/VISION.md).
|
|
|
|
---
|
|
|
|
## Roadmap
|
|
|
|
- [x] File-based pages, API routes, middleware, realtime
|
|
- [x] SSR-first rendering with escaped metadata
|
|
- [x] Server-rendered reactive components with props (`.wrn`, `data-component`)
|
|
- [x] Type-safe signals
|
|
- [x] **HMR over WebSocket** (instant CSS swap; soft DOM morph that keeps state — no visible refresh)
|
|
- [x] **Reactive directives bound to signals** (`data-scope`, `data-on-*`, `data-text`, `{expr}`)
|
|
- [x] **Per-page code-splitting** (the reactive runtime ships only where used)
|
|
- [x] **Production build** (`wrnexus build` → static manifest + bundled `dist/server.js`)
|
|
- [x] **Real `.wrn` compiler** (lexer → parser → codegen, compiled in dev and prod)
|
|
- [x] **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()`:
|
|
|
|
```html
|
|
<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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```ts
|
|
// 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`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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 |
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```bash
|
|
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.
|
|
- **Snippets** — `page`, `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 <kbd>F5</kbd> for an Extension Development
|
|
Host). Diagnostics use a bundled copy of the compiler — regenerate it with
|
|
`bun run build:compiler` after changing `@wrnexus/compiler`.
|