1426 lines
59 KiB
Markdown
1426 lines
59 KiB
Markdown
# WrNexus — The Complete Guide
|
||
|
||
> One document to build WrNexus apps fast and correctly. It covers **every package**, the
|
||
> **whole `.wrn` language**, **all attributes and config properties**, and a **step‑by‑step**
|
||
> path from `create` to a full app: pages, components, reactivity, APIs, validation, database,
|
||
> auth, i18n, theming, realtime, testing, and deploy.
|
||
|
||
WrNexus is an **SSR‑first**, **Bun‑first** full‑stack framework. Pages render to HTML on the
|
||
server; interactivity is added by **server‑rendered components** hydrated in the browser by a
|
||
single generic runtime (no per‑component bundles). The `.wrn` language compiles to TypeScript.
|
||
|
||
**Conventions used here**
|
||
|
||
- `app/…` paths are relative to your app root (where `wrnexus.config.ts` lives).
|
||
- ⚠️ marks a gotcha that bites people.
|
||
- Reactive/runtime attributes owned by WrNexus are called **directives** (`data-for`, `@event`, …).
|
||
|
||
---
|
||
|
||
## Table of contents
|
||
|
||
**Part I — Getting started**
|
||
|
||
1. Install & create · 2. Run · 3. Project structure · 4. The request lifecycle
|
||
|
||
**Part II — The `.wrn` language (complete)**
|
||
|
||
- 5. `page` vs `component` · 6. Every block keyword · 7. The `view`: interpolation, events, directives · 8. Slots · 9. Gotchas
|
||
|
||
**Part III — Build an app, feature by feature**
|
||
|
||
- 10. A page · 11. Layouts · 12. Components & props · 13. Reactivity · 14. WrNexus UI · 15. Theming · 16. Styles · 17. API routes · 18. Validation (forms + API) · 19. Database · 20. Auth, sessions & CSRF · 21. Middleware · 22. i18n · 23. SEO · 24. Security headers & CORS · 25. Realtime · 26. SSR/CSR data bindings · 27. Optional packages · 28. Config profiles & env · 29. Testing · 30. Build & deploy
|
||
|
||
**Part IV — Reference**
|
||
|
||
- 31. CLI · 32. `wrnexus.config.ts` schema · 33. Directive & attribute cheat sheet · 34. Package API index · 35. The `Context` object · 36. Editor support
|
||
|
||
---
|
||
|
||
---
|
||
|
||
# Part I — Getting started
|
||
|
||
## 1. Install & create
|
||
|
||
Requires [Bun](https://bun.sh) ≥ 1.1.
|
||
|
||
```bash
|
||
# From the WrNexus repo, the CLI is packages/cli. In a published setup:
|
||
wrnexus create my-app
|
||
cd my-app
|
||
bun install
|
||
```
|
||
|
||
`wrnexus create <name>` scaffolds: `package.json`, ESLint/Prettier config, `wrnexus.config.ts`
|
||
(minimal), `public/robots.txt`, and starter `app/pages/index.tsx`, `app/pages/about.tsx`,
|
||
`app/api/hello.ts`, `app/middleware/logger.ts`, `app/realtime/chat.ts`, `app/client/counter.ts`.
|
||
It refuses to overwrite an existing directory.
|
||
|
||
## 2. Run
|
||
|
||
```bash
|
||
wrnexus dev . # dev server, live reload/HMR, auto-migrates DB, regenerates typed routes+queries
|
||
wrnexus dev . --port=4000 # pick a port (default 3000)
|
||
wrnexus build . # production bundle → dist/server.js (production profile auto-applied)
|
||
bun dist/server.js # run the built server (honors PORT env)
|
||
```
|
||
|
||
`wrnexus dev` is a supervisor: it regenerates typed DB queries and typed routes, then spawns the
|
||
dev server child (which owns file watching + HMR — CSS/component edits stream over WebSocket with
|
||
no full reload). On a crash it respawns.
|
||
|
||
## 3. Project structure
|
||
|
||
```
|
||
my-app/
|
||
wrnexus.config.ts # optional: seo, head, security, theme, i18n, db, styles, profiles, port
|
||
public/ # static assets → served at / (robots.txt, images, …)
|
||
app/
|
||
pages/ # file-based pages → routes (index.wrn → /, about.wrn → /about, users/[id].wrn → /users/:id)
|
||
components/ # reusable .wrn components (mount with data-component="name")
|
||
layouts/ # layout .wrn files (a component with a <slot>); page picks via layout = "name"
|
||
api/ # file-based API routes → /api/* (export GET/POST/… handlers)
|
||
middleware/ # global middleware (one file = one middleware; run in the chain)
|
||
realtime/ # WebSocket rooms → /realtime/* (export default defineRoom({...}))
|
||
schemas/ # validation schemas (v.object(...)) shared by forms + APIs
|
||
styles/ # global CSS (global.css → every page)
|
||
locales/ # i18n dictionaries <lang>.json (opt-in i18n)
|
||
db/
|
||
schema.ts # TS models — the source of truth
|
||
migrations/ # *.sql (-- +up / -- +down)
|
||
queries/ # *.sql (typed query definitions)
|
||
queries.gen.ts # AUTO-GENERATED from queries/*.sql
|
||
seed.ts # re-runnable dev seed data
|
||
routes.gen.ts # AUTO-GENERATED typed route table
|
||
```
|
||
|
||
The app folder is convention‑based: drop a file in the right directory and it becomes a route.
|
||
Requests are matched against a table scanned at startup — user input never becomes a file path.
|
||
|
||
## 4. The request lifecycle
|
||
|
||
1. Middleware chain (`app/middleware/*`) runs in order; any middleware may short‑circuit.
|
||
2. Router matches the path → page, API route, or realtime room.
|
||
3. **Pages**: the page renders HTML → components (`data-component`) are rendered on the server and
|
||
spliced in → the layout wraps it → SEO `<head>` is built → the reactive runtime is injected
|
||
**only if** the page contains reactive markup.
|
||
4. **API routes**: your `GET`/`POST`/… handler returns a `Response`.
|
||
5. Security headers are applied; the response is sent.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
# Part II — The `.wrn` language (complete)
|
||
|
||
## 5. `page` vs `component`
|
||
|
||
A `.wrn` file is **exactly one** top‑level block:
|
||
|
||
```
|
||
page Home { … } // a route (file path under app/pages/ → URL)
|
||
component Counter { … } // a reusable, prop-driven fragment (mounted via data-component)
|
||
```
|
||
|
||
Both parse to the same shape but compile differently:
|
||
|
||
| | `page` | `component` |
|
||
| ------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||
| Becomes a route | ✅ (from file path) | ❌ (mounted in a page) |
|
||
| `props` | ignored | ✅ coerced + injected |
|
||
| `layout` | ✅ selects a layout | ignored |
|
||
| `api` / `ssr` / `client` / `realtime` | ✅ emitted | ignored |
|
||
| Non‑state `{expr}` in view | live **client mustache** | **baked server‑side** (HTML‑escaped) |
|
||
| Ships JS? | only if it has `state`, `@event`, `{expr}`, or CSR bindings | only if it has `state`/`@event` (pure prop components ship **zero JS**) |
|
||
|
||
**Layouts are components.** A layout file is a `component` with a `<slot>` where the page body goes.
|
||
A page selects one with `layout = "public"` (→ `app/layouts/public.wrn`); `layout = "none"` opts out.
|
||
|
||
## 6. Every block keyword
|
||
|
||
Inside `page`/`component { … }`, these members are allowed (zero or more of each unless noted):
|
||
|
||
### `layout` (page only)
|
||
|
||
```
|
||
layout = "public"
|
||
```
|
||
|
||
### `props { }` (component) — ⚠️ one declaration per line
|
||
|
||
```
|
||
props {
|
||
title: string // required: no default
|
||
start: number = 0 // start="5" arrives as number 5
|
||
disabled: boolean = false
|
||
items: Array<Item> = []
|
||
}
|
||
```
|
||
|
||
Type annotations use TypeScript syntax. Legacy declarations without an annotation still infer their
|
||
runtime type from the default value.
|
||
|
||
### `types { }` — local TypeScript models
|
||
|
||
```ts
|
||
types {
|
||
interface Item { id: string; label: string }
|
||
type Status = "idle" | "loading" | "ready"
|
||
}
|
||
```
|
||
|
||
These declarations can be referenced by props, state, and function signatures in the same file.
|
||
|
||
### `state` — ⚠️ one per line
|
||
|
||
```
|
||
state count: number = start // may reference a prop or earlier state
|
||
state status: Status = "idle"
|
||
```
|
||
|
||
State seeds the reactive scope. On pages, seeds are also evaluated at compile time for SSR baking.
|
||
|
||
### `view { }`
|
||
|
||
```
|
||
view { <html> … </html> }
|
||
```
|
||
|
||
Plain HTML with interpolation, events, and directives — see §7.
|
||
|
||
### `seo { }` (page)
|
||
|
||
```
|
||
seo {
|
||
title = "Home"
|
||
description = "Welcome"
|
||
canonical = "/hello"
|
||
}
|
||
```
|
||
|
||
Keys are arbitrary and land in the page `meta` (merged with global `seo` config). Values may be
|
||
quoted or bare‑to‑end‑of‑line.
|
||
|
||
### `style { }` — scoped CSS, inlined with the page/component
|
||
|
||
```
|
||
style {
|
||
.box { background: var(--wrn-color-surface); border-radius: var(--wrn-radius); }
|
||
}
|
||
```
|
||
|
||
Multiple `style` blocks accumulate. Use theme tokens (`var(--wrn-*)`) so styles restyle on theme
|
||
change.
|
||
|
||
### `functions { }` — typed helpers
|
||
|
||
```
|
||
functions {
|
||
function slug(value: string): string {
|
||
return value.toLowerCase().replace(/\s+/g, "-");
|
||
}
|
||
}
|
||
```
|
||
|
||
Component functions are available to events, lifecycle hooks, and watchers in the browser as well as
|
||
server rendering. Page helpers are available to `api`, `ssr`/`client`, and `realtime` bodies.
|
||
|
||
### `api <METHOD> <path> { }` — colocated API route
|
||
|
||
```
|
||
api POST /subscribe {
|
||
const body = await ctx.req.json();
|
||
return Response.json({ ok: true });
|
||
}
|
||
```
|
||
|
||
Method is upper‑cased; path is auto‑prefixed to `/api` and traversal‑checked. `ctx` is in scope.
|
||
|
||
### `ssr { }` / `client { }` — named data bindings (see §26)
|
||
|
||
```
|
||
ssr {
|
||
functions { function names(u){ return u.map(x=>x.name).join(", "); } }
|
||
api userList GET /api/users/ssr { // ⚠️ note the NAME before the method
|
||
return names(users);
|
||
}
|
||
}
|
||
```
|
||
|
||
Each may contain only `api <name> <METHOD> <path> { }` and `functions { }`. Reference a binding
|
||
from the view with `api="userList"` on an element.
|
||
|
||
### `realtime <name> { on <evt>(args) { } }` — inline WS handlers (legacy form)
|
||
|
||
```
|
||
realtime chat {
|
||
on message(data) { broadcast(data); }
|
||
}
|
||
```
|
||
|
||
Compiles to `export const websocket = { message(ws, data) { … } }`. For real apps prefer a
|
||
standalone `app/realtime/<name>.ts` with `defineRoom` (§25); the inline form is the compiler's
|
||
original shape.
|
||
|
||
## 7. The `view`: interpolation, events, directives
|
||
|
||
The `view` body is a lenient HTML parser. Elements, self‑closing `<tag/>`, void elements
|
||
(`<br>`, `<img>`, …), and `<!-- comments -->` (dropped) all work. **Attribute values must be
|
||
quoted.** A valueless attribute is boolean (`<button disabled>`).
|
||
|
||
### Text interpolation `{expr}`
|
||
|
||
- **Page + references `state`** → baked to `<span data-text="expr">value</span>`; no‑JS clients see
|
||
the real value, the runtime keeps it live.
|
||
- **Page + non‑state** → left as a literal mustache; the runtime fills it at hydration.
|
||
- **Component + prop/constant** → baked server‑side (HTML‑escaped), **not** reactive.
|
||
- **Component + state** → reactive `data-text` span.
|
||
|
||
Both `{expr}` and `{{ expr }}` are accepted.
|
||
|
||
⚠️ **Attribute interpolation** (`href="{url}"`) is compiled **only in components**. On a **page**,
|
||
`{expr}` inside a normal attribute is inert (except inside a `data-for` item). Prefer components for
|
||
attribute interpolation.
|
||
|
||
### Translations `{t:key}`
|
||
|
||
```
|
||
<h1>{t:home.title}</h1>
|
||
<div data-component="counter" label="{t:nav.dashboard}"></div> <!-- works in prop values too -->
|
||
```
|
||
|
||
Compiles to `<span data-t="key">` and is resolved per request against the active language. It also
|
||
resolves inside component prop values.
|
||
|
||
### Event bindings `@event="statement"`
|
||
|
||
```
|
||
<button @click="count++">+</button>
|
||
<button @click="count = 0">reset</button>
|
||
<input @input="name = 'x'">
|
||
```
|
||
|
||
`@name` compiles to `data-on-name`. The event name is arbitrary (any DOM event, hyphens allowed).
|
||
The statement runs in the element's reactive scope. Supported statements: `x++`/`x--`,
|
||
`x = expr`, compound assign (`+= -= *= /= %=`), bare expressions/method calls, and multiple
|
||
statements separated by `;`. Expressions use a **CSP‑safe, eval‑free** parser: literals,
|
||
identifiers, member/index access, calls, arrays/objects, `+ - * / %`, comparisons, `== != === !==`,
|
||
`&& ||`, unary `! - +`, and ternary `?:`.
|
||
|
||
### Directives (the `data-*` the runtime understands)
|
||
|
||
| Directive | Syntax | What it does |
|
||
| ----------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `data-scope` | `data-scope="count: 0, name: 'x'"` | Declares reactive state on a subtree. The compiler emits this automatically when a page/component has state. A binding is owned by its **nearest** `data-scope` ancestor (nesting is safe). |
|
||
| `data-on-<event>` | `data-on-click="count++"` | Event handler (the compiled form of `@event`). |
|
||
| `data-text` | `data-text="count * 2"` | `textContent` follows the expression. Emitted by state interpolation; you can also hand‑write it. |
|
||
| `data-show` | `data-show="open"` | Toggles visibility while preserving interactive state. **Hand-authored** (no `{}` sugar). |
|
||
| `data-for` | `data-for="item in items"`, optionally `key item.id` or `data-key="item.id"` | Repeats the element per list item. A stable key preserves DOM identity during reorder; unkeyed loops retain legacy full rerendering. Inside, item/index locals work in bindings and handlers. **Hand-authored.** |
|
||
| `data-component` | `data-component="counter"` | Mounts a component (server‑rendered, then hydrated). See §12. |
|
||
| `data-slot` | `<div data-slot="header">…</div>` | Fills a named `<slot name="header">` of a component (see §8). |
|
||
|
||
`{#if}` and `<Component is={...}>` keep inactive elements out of the live DOM. `data-show` keeps
|
||
stateful controls mounted and only changes visibility. Client-side conditions are not
|
||
authorization: anything delivered to the browser can be recovered by the user. Enforce permissions
|
||
in server loaders and API handlers, and never send an unauthorized branch's sensitive data.
|
||
|
||
Example — a reactive list you write by hand:
|
||
|
||
```
|
||
view {
|
||
<div data-scope="items: [{t:'a'},{t:'b'}], open: true">
|
||
<button @click="open = !open">toggle</button>
|
||
<ul data-show="open">
|
||
<li data-for="i in items key i.id" data-text="i.t"></li>
|
||
</ul>
|
||
</div>
|
||
}
|
||
```
|
||
|
||
There are also framework attributes consumed by **other** runtimes (loaded only when present):
|
||
`data-wrn-theme-toggle`/`data-wrn-theme-set` (theme, §15), `data-schema`/`data-error`/
|
||
`data-success`/`data-redirect` (forms, §18), `data-wrn-lang-set`/`data-wrn-lang` (i18n, §22),
|
||
`data-room*` (realtime, §25).
|
||
|
||
Cross-platform pages can use `data-native-browser="capability"` and
|
||
`data-native-mobile="capability"` with JSON `data-native-options`. Use
|
||
`data-native-only="browser|mobile|ios|android"` for platform visibility,
|
||
`data-native-requires="capability"` for support gating, and `@browser-event` /
|
||
`@mobile-event` for platform-specific reactive handlers. These declarative capability
|
||
actions run in browsers and Capacitor WebViews. Expo compilation selects mobile markup
|
||
and handlers but requires installed Expo APIs to be called from native screen code.
|
||
|
||
## 8. Slots
|
||
|
||
A component's output may include slots; the mount's children fill them.
|
||
|
||
```
|
||
// app/layouts/dashboard.wrn
|
||
component Dashboard {
|
||
view {
|
||
<header><slot name="actions"></slot></header>
|
||
<main><slot></slot></main> <!-- default slot: the page body -->
|
||
}
|
||
}
|
||
```
|
||
|
||
```
|
||
// a page using it
|
||
page Reports {
|
||
layout = "dashboard"
|
||
view {
|
||
<div data-slot="actions"><button>New</button></div> <!-- fills the named slot; wrapper is dropped -->
|
||
<h1>Reports</h1> <!-- non-slotted content → default slot -->
|
||
}
|
||
}
|
||
```
|
||
|
||
`<slot>fallback</slot>` keeps its fallback when nothing is provided.
|
||
|
||
## 9. Gotchas
|
||
|
||
- **`props`/`state` are one‑per‑line** (the value is read to end‑of‑line). No commas/semicolons.
|
||
- **Comments:** `//` outside `view`; `<!-- … -->` inside `view` (a `//` in `view` is literal text).
|
||
- **`{`/`}` are reserved** in view text for interpolation.
|
||
- **Void elements** take no closing tag.
|
||
- **`ssr`/`client` `api` has a name before the method**; top‑level `api` does not.
|
||
- **`props` on a `page` are ignored** — they only matter for `component`.
|
||
- **Reserved JS words** as prop/state names are fine (auto‑renamed internally in components).
|
||
|
||
---
|
||
|
||
---
|
||
|
||
# Part III — Build an app, feature by feature
|
||
|
||
Each step is self‑contained. Do them in any order you need.
|
||
|
||
## 10. A page
|
||
|
||
`app/pages/index.wrn` → `/`. A trailing `index` segment is dropped; `[param]` makes a dynamic
|
||
segment (`app/pages/users/[id].wrn` → `/users/:id`, `ctx.params.id`).
|
||
|
||
```
|
||
page Home {
|
||
layout = "public"
|
||
seo { title = "Home" description = "Welcome to my app." }
|
||
view {
|
||
<h1>Hello WrNexus</h1>
|
||
<p><a href="/about">About</a></p>
|
||
}
|
||
}
|
||
```
|
||
|
||
Scaffold: `wrnexus generate page home` (alias `g p`).
|
||
|
||
## 11. Layouts
|
||
|
||
`app/layouts/public.wrn`:
|
||
|
||
```
|
||
layout Public {
|
||
view {
|
||
<nav><a href="/">Home</a> · <a href="/about">About</a></nav>
|
||
<main><slot></slot></main>
|
||
}
|
||
}
|
||
```
|
||
|
||
A page opts in with `layout = "public"`. Missing → a `default` layout if present; `layout = "none"`
|
||
disables layouts for that page.
|
||
|
||
`app/layouts/document.wrn` is the optional global outer layout. It wraps the
|
||
selected page layout and can define `<html>`, `<head>`, `<body>`, and `#app`.
|
||
It receives `cookies`, `theme`, `language`, `url`, and `pathname` during SSR.
|
||
Framework metadata, configured head content, styles, and scripts are merged into
|
||
the authored document automatically.
|
||
|
||
## 12. Components & props
|
||
|
||
`app/components/counter.wrn`:
|
||
|
||
```
|
||
component Counter {
|
||
props {
|
||
start = 0
|
||
label = "Count"
|
||
}
|
||
state count = start
|
||
view {
|
||
<button @click="count++">{label}: {count}</button>
|
||
}
|
||
}
|
||
```
|
||
|
||
Mount it in any page/view — pass props as attributes:
|
||
|
||
```
|
||
<div data-component="counter" start="5" label="Clicks"></div>
|
||
```
|
||
|
||
**Prop coercion:** each prop is coerced to the **type of its default** — number default → `Number(v)`
|
||
(so `start="5"` → `5`), boolean default → truthy check (`""`/`"true"`/`true` → true), else `String`.
|
||
Missing attribute → the default. Components can mount components (up to depth 15). Scaffold:
|
||
`wrnexus generate component counter`.
|
||
|
||
## 13. Reactivity
|
||
|
||
Everything is driven by `state` + directives (§7). Cheat sheet:
|
||
|
||
```
|
||
component Demo {
|
||
state count = 0
|
||
state open = true
|
||
state items = ["a", "b", "c"]
|
||
view {
|
||
<p>Count doubled: {count * 2}</p> <!-- reactive data-text -->
|
||
<button @click="count++">+</button>
|
||
<button @click="count = 0">reset</button>
|
||
|
||
<button @click="open = !open">toggle</button>
|
||
<ul data-show="open">
|
||
<li data-for="x in items" data-text="x"></li>
|
||
</ul>
|
||
}
|
||
}
|
||
```
|
||
|
||
The runtime tracks dependencies automatically and re‑renders only what changed. No `new Function`,
|
||
no `eval` — CSP‑safe.
|
||
|
||
## 14. WrNexus UI
|
||
|
||
`@wrnexus/ui` ships **24** themed components, auto‑discovered like your own. Mount with
|
||
`data-component`; every component accepts a `class` prop (appended to the root) and is styled via
|
||
`--wrn-*` tokens + `.wrn-*` classes you can override.
|
||
|
||
**Layout:** `container`, `stack` (`gap`), `hstack` (`gap`, `align`), `grid` (`cols`, `gap`),
|
||
`spacer`, `divider`, `card`.
|
||
**Form/controls:** `button` (`label`, `variant`, `size`, `type`), `input` (`type`, `name`, `value`,
|
||
`placeholder`), `textarea` (`name`, `placeholder`, `rows`), `select` (`name`; options in slot),
|
||
`checkbox` (`name`, `label`), `radio` (`name`, `value`, `label`), `switch` (`name`, `label`).
|
||
**Display/feedback:** `badge` (`label`, `variant`), `tag` (`label`, `variant`), `alert`
|
||
(`variant`, `title`, `message`), `avatar` (`src`, `alt`), `spinner`, `progress` (`value`, `max`),
|
||
`skeleton` (`width`, `height`), `tooltip` (`text`; trigger in slot), `disclosure` (`summary`),
|
||
`table` (thead/tbody in slot), `theme-toggle` (`label`).
|
||
|
||
Variants: buttons `default|primary|danger|ghost`, sizes `sm|md|lg`; badge/tag/alert
|
||
`default|primary|success|danger|warning` (alert uses `info|success|danger|warning`).
|
||
|
||
```
|
||
<div data-component="button" variant="primary" label="Save"></div>
|
||
<div data-component="alert" variant="success" title="Done" message="Saved."></div>
|
||
<div data-component="grid" cols="3" gap="4">…</div>
|
||
```
|
||
|
||
**Override styling**, in priority order: (1) theme tokens `--wrn-*`, (2) redefine `.wrn-*` in your
|
||
CSS (loads after `ui.css`), (3) the `class` prop, (4) `wrnexus eject <name>` to copy the component's
|
||
`.wrn` source into `app/components/` (your copy shadows the library one).
|
||
|
||
## 15. Theming
|
||
|
||
Themes are flat token maps rendered as `--wrn-<key>` CSS variables (server + client). Built‑in
|
||
`light` and `dark`; the cookie `wrn-theme` selects one.
|
||
|
||
```ts
|
||
// wrnexus.config.ts
|
||
theme: {
|
||
palette: "violet", // blue | indigo | violet | emerald | cyan | rose | amber | slate
|
||
default: "dark", // used when no cookie
|
||
themes: {
|
||
light: { "color-primary": "#2563eb" }, // deep-merged over built-in light
|
||
dark: { "color-primary": "#6c8cff" },
|
||
},
|
||
}
|
||
```
|
||
|
||
Built‑in token keys include: `color-scheme`, `color-bg`, `color-surface`, `color-surface-2`,
|
||
`color-text`, `color-muted`, `color-border`, `color-primary`, `color-primary-hover`,
|
||
`color-primary-contrast`, `color-danger`, `color-success`, `color-warning`, `radius`, `radius-sm`,
|
||
`font-sans`, `shadow-1`. (The reserved key `color-scheme` emits the native property.)
|
||
|
||
Toggle from the view with zero JS:
|
||
|
||
```
|
||
<button data-wrn-theme-toggle>Toggle theme</button>
|
||
<button data-wrn-theme-set="light">Light</button>
|
||
```
|
||
|
||
The client exposes `window.wrnTheme = { get, set, toggle, bind, themes }`.
|
||
|
||
## 16. Styles
|
||
|
||
Global CSS entry defaults to `app/styles/global.css` (or all `app/styles/*.css` aggregated). The
|
||
built‑in Bun bundler resolves `@import` (including node_modules) and minifies in production.
|
||
|
||
Add a CSS framework two ways:
|
||
|
||
- **CDN** (zero build): `head: ['<link rel="stylesheet" href="https://cdn…/bootstrap.min.css">']`.
|
||
- **Custom processor** (Tailwind/PostCSS/Sass):
|
||
|
||
```ts
|
||
styles: {
|
||
entry: "app/styles/global.css",
|
||
process: async ({ entryPath, appRoot, mode }) => {
|
||
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||
if (mode === "production") args.push("--minify");
|
||
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||
},
|
||
}
|
||
```
|
||
|
||
## 17. API routes
|
||
|
||
`app/api/<name>.ts` → `/api/<name>`. Export one function per HTTP method. Nested folders and
|
||
`[param]` segments work like pages.
|
||
|
||
```ts
|
||
// app/api/echo.ts → /api/echo
|
||
import type { Context } from "@wrnexus/core";
|
||
|
||
export const GET = async () => Response.json({ usage: "POST JSON here" });
|
||
|
||
export const POST = async (ctx: Context) => {
|
||
const body = await ctx.req.json();
|
||
return Response.json({ received: body });
|
||
};
|
||
```
|
||
|
||
`ctx` gives you `ctx.req`, `ctx.url`, `ctx.params`, `ctx.session`, `ctx.user`, `ctx.t`, `ctx.lang`,
|
||
`ctx.locals`, etc. (§35). Scaffold: `wrnexus generate api echo`.
|
||
|
||
You can also colocate an API in a page with the `api` block (§6).
|
||
|
||
## 18. Validation (forms + API)
|
||
|
||
**One schema, enforced on both sides.** Define it once in `app/schemas/<name>.ts`:
|
||
|
||
```ts
|
||
// app/schemas/login.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"),
|
||
});
|
||
```
|
||
|
||
**Builder API:**
|
||
|
||
- `v.string()` → `.min(n) .max(n) .length(n) .email() .url() .uuid() .date() .pattern(re,msg?)
|
||
.oneOf([...]) .trim()`
|
||
- `v.number()` → `.min(n) .max(n) .integer() .positive() .oneOf([...])`
|
||
- `v.boolean()`
|
||
- All types → `.optional() .label(text) .default(value) .refine(fn, msg?)` (⚠️ `refine` is
|
||
**server‑only**, not mirrored to the client).
|
||
- `v.object(fields)` → `.parse(input)` → `{ ok, value, errors }`, and `.describe()` (JSON descriptor
|
||
for the client).
|
||
|
||
**Server** — in an API handler:
|
||
|
||
```ts
|
||
import { parseBody } from "@wrnexus/validation";
|
||
import login from "../schemas/login.ts";
|
||
|
||
export async function POST(ctx: Context): Promise<Response> {
|
||
const result = await parseBody(login, ctx.req); // reads JSON / urlencoded / multipart
|
||
if (!result.ok) return result.response; // ready 400: { ok:false, errors:{field:msg} }
|
||
const { email, password } = result.value as { email: string; password: string };
|
||
// …
|
||
}
|
||
```
|
||
|
||
`parseEnv(schema, source?)` validates env vars and throws one readable multi‑line error.
|
||
|
||
**Client form flow** (zero JS you write). The schema's descriptor is baked into
|
||
`window.__wrnSchemas` by name (filename). WrNexus a form up with attributes:
|
||
|
||
```
|
||
<form data-schema="login" method="post" action="/api/login" data-redirect="/dashboard">
|
||
<div data-component="input" type="email" name="email"></div>
|
||
<span class="wrn-field-error" data-error="email"></span>
|
||
|
||
<div data-component="input" type="password" name="password"></div>
|
||
<span class="wrn-field-error" data-error="password"></span>
|
||
|
||
<div class="wrn-alert wrn-alert--success" data-success="Signed in!" hidden></div>
|
||
<button type="submit" class="wrn-btn wrn-btn--primary">Sign in</button>
|
||
</form>
|
||
```
|
||
|
||
- `data-schema="login"` — picks the descriptor.
|
||
- `name="…"` — matched to schema fields; validated on `blur` and `submit`.
|
||
- `data-error="field"` — receives the message; the input gets `aria-invalid` + `.wrn-invalid`.
|
||
- `data-success` — shown on success when there's no redirect.
|
||
- `data-redirect="/path"` (or `redirect` in the JSON response) — navigates on success.
|
||
|
||
On submit it validates client‑side, then POSTs `JSON` with `x-csrf-token` (from the `wrn-csrf`
|
||
cookie) and `credentials: same-origin`, and re‑surfaces server `errors` into the `data-error`
|
||
spans. It also dispatches `wrn:success` / `wrn:error` events. Scaffold: `wrnexus generate schema login`.
|
||
|
||
## 19. Database
|
||
|
||
Models in `app/db/schema.ts` are the **source of truth** for DDL, typing, and row mapping.
|
||
⚠️ **`ctx.db` does not exist yet** — use `getDb()` from `@wrnexus/db` in handlers.
|
||
|
||
### Define models
|
||
|
||
```ts
|
||
// app/db/schema.ts
|
||
import { v, table } from "@wrnexus/db";
|
||
|
||
export type User = { id: number; email: string; name: string; active: boolean; createdAt: Date };
|
||
|
||
export const users = table<User>("users", {
|
||
id: v.id(), // auto-increment PK
|
||
email: v.string().unique(),
|
||
name: v.string(),
|
||
active: v.boolean().default(true),
|
||
createdAt: v.timestamp().default("now"), // "now" → CURRENT_TIMESTAMP
|
||
});
|
||
```
|
||
|
||
Column builders: `id, text, string(=text), int, number(=real), real, bool, boolean(=bool),
|
||
timestamp, json`. Modifiers: `.optional() .unique() .default(value|"now") .primaryKey()
|
||
.references(table, column="id")`. ⚠️ `export const` each table so the CLI can discover it.
|
||
|
||
### Migrations
|
||
|
||
```bash
|
||
wrnexus db new init --from-models # scaffold up/down CREATE TABLE from schema.ts (topo-sorted)
|
||
wrnexus db migrate # apply pending (each in a transaction, recorded once)
|
||
wrnexus db status # [x]/[ ] applied
|
||
wrnexus db rollback # revert the last one
|
||
```
|
||
|
||
A migration is `.sql` split by markers:
|
||
|
||
```sql
|
||
-- +up
|
||
ALTER TABLE "users" ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
|
||
-- +down
|
||
ALTER TABLE "users" DROP COLUMN "passwordHash";
|
||
```
|
||
|
||
In dev the server auto‑migrates at startup; in production run `wrnexus db migrate` explicitly.
|
||
|
||
### Typed queries
|
||
|
||
```sql
|
||
-- app/db/queries/users.sql
|
||
-- name: GetUserByEmail :one
|
||
SELECT * FROM users WHERE email = :email;
|
||
|
||
-- name: ListUsers :many
|
||
SELECT id, name, active FROM users ORDER BY name;
|
||
|
||
-- name: CreateUser :exec
|
||
INSERT INTO users (email, name, active) VALUES (:email, :name, :active);
|
||
```
|
||
|
||
`:one` → `Row | null`, `:many` → `Row[]`, `:exec` → `ExecResult`. `:param` placeholders become
|
||
positional. Run `wrnexus db generate` → `app/db/queries.gen.ts` with fully‑typed functions:
|
||
|
||
```ts
|
||
import { getDb } from "@wrnexus/db";
|
||
import { ListUsers, GetUserByEmail } from "../db/queries.gen.ts";
|
||
|
||
const all = await ListUsers(getDb()); // typed rows
|
||
const user = await GetUserByEmail(getDb(), { email }); // args object; User | null
|
||
```
|
||
|
||
(`SELECT *` rows are mapped through the model; aggregates like `COUNT(*) AS n` are typed `number`.)
|
||
|
||
### Runtime DB API
|
||
|
||
`getDb()` returns the process‑wide `Db` (set once at startup from `config.db`). `Db`:
|
||
|
||
```ts
|
||
db.all<T>(sql, params?, model?): Promise<T[]>
|
||
db.one<T>(sql, params?, model?): Promise<T | null>
|
||
db.exec(sql, params?): Promise<{ changes: number; lastInsertId?: number }>
|
||
db.tx(async (tx) => { … }) // transaction, rolls back on throw (nested reuses current)
|
||
db.createTable(model): Promise<void> // CREATE TABLE IF NOT EXISTS
|
||
```
|
||
|
||
```ts
|
||
await getDb().exec("INSERT INTO users (email, name) VALUES (?, ?)", [email, name]);
|
||
const rows = await getDb().all("SELECT * FROM users", [], users); // User[]
|
||
```
|
||
|
||
### Pagination & relations
|
||
|
||
```ts
|
||
import { paginate, loadRelated } from "@wrnexus/db";
|
||
|
||
const page = await paginate(
|
||
getDb(),
|
||
{ sql: "SELECT * FROM users ORDER BY name", model: users }, // no LIMIT — it's added
|
||
{ page: 2, perPage: 25 },
|
||
); // → { items, page, perPage, total, totalPages, hasNext, hasPrev }
|
||
|
||
const list = await getDb().all("SELECT * FROM users", []);
|
||
await loadRelated(getDb(), list, { table: "posts", foreignKey: "userId", as: "posts" }); // hasMany → user.posts
|
||
// belongsTo: { table:"users", localKey:"userId", foreignKey:"id", as:"author", single:true, model:users }
|
||
```
|
||
|
||
`loadRelated` batches with one `WHERE fk IN (…)` (no N+1).
|
||
|
||
### Seeding
|
||
|
||
```ts
|
||
// app/db/seed.ts → wrnexus db seed
|
||
import type { Db } from "@wrnexus/db";
|
||
export default async function seed(db: Db): Promise<void> {
|
||
await db.exec("DELETE FROM users"); // make it re-runnable
|
||
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
||
"ada@x.dev",
|
||
"Ada",
|
||
1,
|
||
]);
|
||
}
|
||
```
|
||
|
||
Config: `db: { driver: "sqlite" | "postgres" | "mysql" | "mongo", url }`; sqlite `file:` URLs
|
||
resolve relative to the app root. `wrnexus db studio [table]` lists tables/row counts or dumps rows.
|
||
|
||
### Multiple databases
|
||
|
||
Connect to as many databases as you want and read/write any of them per request. The
|
||
`db` setting is the **default**; add named connections under `databases`:
|
||
|
||
```ts
|
||
// wrnexus.config.ts
|
||
db: { driver: "sqlite", url: "file:./dev.db" }, // default → getDb()
|
||
databases: {
|
||
analytics: { driver: "postgres", url: process.env.ANALYTICS_URL! }, // → getDb("analytics")
|
||
},
|
||
```
|
||
|
||
Reach them by name at runtime:
|
||
|
||
```ts
|
||
const users = await getDb().all("SELECT * FROM users"); // default
|
||
const hits = await getDb("analytics").all("SELECT * FROM events"); // named
|
||
```
|
||
|
||
Each named database has its **own** files under `app/db/<name>/` (`schema.ts`,
|
||
`migrations/`, `queries/` → `queries.gen.ts`, `seed.ts`). Target one with `--db=<name>`:
|
||
|
||
```bash
|
||
wrnexus db new init --from-models --db=analytics # scaffold app/db/analytics/migrations
|
||
wrnexus db migrate --db=analytics # migrate the named db
|
||
wrnexus db generate --db=analytics # regenerate its typed queries
|
||
wrnexus db studio users --db=analytics # inspect it
|
||
```
|
||
|
||
The generated query functions take a `Db` as their first argument, so pass the connection
|
||
you want: `await ListEvents(getDb("analytics"))`. In dev, every configured database is
|
||
auto-migrated at startup.
|
||
|
||
## 20. Auth, sessions & CSRF
|
||
|
||
Primitives from `@wrnexus/core`:
|
||
|
||
```ts
|
||
import {
|
||
hashPassword,
|
||
verifyPassword,
|
||
logIn,
|
||
logOut,
|
||
getUser,
|
||
requireAuth,
|
||
verifyCsrf,
|
||
} from "@wrnexus/core";
|
||
|
||
await hashPassword("secret"); // argon2id (Bun.password)
|
||
await verifyPassword(plain, hash); // boolean
|
||
logIn(ctx, { id, email, name }); // regenerates session id (fixation defense), stores user
|
||
logOut(ctx); // clears session
|
||
getUser(ctx); // current user or null
|
||
```
|
||
|
||
**Login route** (canonical):
|
||
|
||
```ts
|
||
export async function POST(ctx: Context): Promise<Response> {
|
||
if (!verifyCsrf(ctx)) return new Response("Invalid CSRF token", { status: 403 });
|
||
const result = await parseBody(login, ctx.req);
|
||
if (!result.ok) return result.response;
|
||
const { email, password } = result.value as { email: string; password: string };
|
||
const user = await GetUserByEmail(getDb(), { email });
|
||
if (!user || !(await verifyPassword(password, user.passwordHash)))
|
||
return Response.json({ ok: false, error: "Invalid email or password" }, { status: 401 });
|
||
logIn(ctx, { id: user.id, email: user.email, name: user.name }); // store only safe fields
|
||
return Response.json({ ok: true });
|
||
}
|
||
```
|
||
|
||
**Guarding:** `requireAuth({ loginPath: "/login" })` as middleware — API/JSON → 401, page nav → 302
|
||
to `${loginPath}?next=<target>`. Or inline: `if (getUser(ctx) == null) …`.
|
||
|
||
**CSRF** (double‑submit): cookie `wrn-csrf` (JS‑readable), header `x-csrf-token`. `csrfToken(ctx)`
|
||
returns/creates the token; `verifyCsrf(ctx)` passes GET/HEAD/OPTIONS, else compares header to cookie.
|
||
The WrNexus UI form runtime sends the header automatically. Add `csrfProtection()` middleware to enforce
|
||
globally.
|
||
|
||
**Sessions:** cookie `wrnexus.sid` (HttpOnly, Lax, Secure on HTTPS), 24h sliding TTL, 256‑bit id.
|
||
Default backend is in‑memory. Swap it:
|
||
|
||
```ts
|
||
import { setSessionBackend } from "@wrnexus/core";
|
||
import { sqliteSessionStore } from "@wrnexus/db/session";
|
||
setSessionBackend(sqliteSessionStore("./sessions.db")); // sync backend
|
||
// async/Redis-style: use the loadSession(asyncBackend) middleware instead
|
||
```
|
||
|
||
## 21. Middleware
|
||
|
||
Each `app/middleware/*.ts` file `export default`s one middleware — a value or a function
|
||
`(ctx, next) => Response | Promise<Response>`. Return `next()` to continue or a `Response` to
|
||
short‑circuit. Scope a middleware to a path by checking `ctx.url.pathname` / `ctx.req.method`.
|
||
|
||
```ts
|
||
// app/middleware/ratelimit.ts
|
||
import { rateLimit, type Context, type Next } from "@wrnexus/core";
|
||
const limiter = rateLimit({ max: 5, windowMs: 60_000, message: "Slow down" });
|
||
export default async function (ctx: Context, next: Next) {
|
||
if (ctx.req.method === "POST" && ctx.url.pathname === "/api/login") return limiter(ctx, next);
|
||
return next();
|
||
}
|
||
```
|
||
|
||
Core factories and their key options:
|
||
|
||
- `rateLimit({ windowMs=60000, max=60, key?, trustProxy=false, message?, headers=true, store? })` —
|
||
emits `RateLimit-*` headers + 429 with `Retry-After`.
|
||
- `requestLogger({ format="pretty"|"json", sink?, requestIdKey="requestId" })`.
|
||
- `csrfProtection()` — 403s unsafe requests failing CSRF.
|
||
- `sessionAuth()` — sets `ctx.user` from the session.
|
||
- `requireAuth({ loginPath="/login" })` — 401 (API) / 302 (page).
|
||
- `loadSession(backend, { ttlMs? })` — async session backend; register early.
|
||
- `authorize(policy)`, `requireRole(...roles)`, `requirePermission(rbac, permission)` from
|
||
`@wrnexus/authz`.
|
||
- `jwtAuth({ secret, getToken?, required=true })` from `@wrnexus/jwt`.
|
||
- `createTracker({ sinks }).middleware()` from `@wrnexus/tracking`.
|
||
|
||
## 22. i18n
|
||
|
||
Opt‑in: add dictionaries under `app/locales/<lang>.json`. Language resolves per request from the
|
||
`wrn-lang` cookie → `Accept-Language` → default.
|
||
|
||
```json
|
||
// app/locales/en.json
|
||
{ "home": { "title": "Home", "intro": "Welcome" }, "api": { "greeting": "Hello" } }
|
||
```
|
||
|
||
In views: `{t:home.title}`. In handlers: `ctx.t("api.greeting")` and `ctx.lang`.
|
||
|
||
```ts
|
||
export const GET = async (ctx: Context) =>
|
||
Response.json({ message: ctx.t("api.greeting"), lang: ctx.lang });
|
||
```
|
||
|
||
Config: `i18n: { default: "en", locales: ["en", "fr"] }` (both optional; inferred from files).
|
||
Switch language from the view with `data-wrn-lang-set="fr"`. Formatting helpers (Intl‑based) are
|
||
exported from `@wrnexus/i18n`: `formatNumber`, `formatCurrency`, `formatDate`, `formatRelativeTime`,
|
||
`plural`.
|
||
|
||
## 23. SEO
|
||
|
||
Per‑page `seo { }` merges over global `seo` config. Supported keys: `title`, `titleTemplate`
|
||
(`"%s | Site"`), `description`, `canonical`, `canonicalBase`, `robots`, `keywords`, `image`,
|
||
`siteName`, `type`, `locale`, `twitterCard`, `twitterSite`, `themeColor`. The server builds an
|
||
escaped `<head>` from the merged metadata.
|
||
|
||
## 24. Security headers & CORS
|
||
|
||
Secure defaults are on. Configure under `security` (§32 for the full shape):
|
||
|
||
```ts
|
||
security: {
|
||
cors: { enabled: true, origin: ["http://localhost:5173"], credentials: true, maxAge: 600 },
|
||
contentSecurityPolicy: { directives: { "script-src": ["'self'", "https://cdn.example.com"] } },
|
||
hsts: { maxAge: 31536000 }, // on in prod by default
|
||
frameOptions: "DENY",
|
||
}
|
||
```
|
||
|
||
Defaults include a strict CSP (`default-src 'self'`, `script-src 'self'`, `style-src 'self'
|
||
'unsafe-inline'`, `frame-ancestors 'none'`, …), `X-Frame-Options: DENY`, restrictive
|
||
Permissions‑Policy, COOP `same-origin`, and Referrer‑Policy. Set any directive/section to `false`
|
||
to remove it, or `security.headers = false` to disable all framework headers. WebSocket upgrades are
|
||
guarded against cross‑site hijacking (same‑origin + configured CORS origins + non‑browser clients).
|
||
|
||
## 25. Realtime
|
||
|
||
Author a room in `app/realtime/<name>.ts` → served at `ws://host/realtime/<name>`. Use `[room].ts`
|
||
for dynamic multi‑room.
|
||
|
||
```ts
|
||
// app/realtime/chat.ts
|
||
import { defineRoom } from "@wrnexus/core";
|
||
|
||
export default defineRoom({
|
||
authorize: (info) => true, // { user?, query, headers } — false → 403
|
||
onConnect(client) {
|
||
client.broadcast({ type: "system", text: "joined", online: client.room.count() });
|
||
},
|
||
onMessage(client, message) {
|
||
// JSON auto-parsed
|
||
client.room.broadcast({ type: "message", user: client.user, text: String(message.text) });
|
||
},
|
||
onLeave(client) {
|
||
client.broadcast({ type: "system", text: "left", online: client.room.count() - 1 });
|
||
},
|
||
});
|
||
```
|
||
|
||
`client` API: `send(msg)` (this conn), `broadcast(msg)` (others), `client.room.broadcast(msg)`
|
||
(everyone), `client.to(id|ids).send(msg)`, `client.toUser(u|users).send(msg)`, `client.close()`.
|
||
Set `client.user = "u1"` to enable `toUser`. State: `client.data` (per conn), `client.room.state`
|
||
(shared while ≥1 connected); `client.room.count()`, `client.room.clients()`.
|
||
|
||
**Client view** (zero JS):
|
||
|
||
```
|
||
<div data-room="chat" data-room-user="Ada">
|
||
<span data-room-status data-room-status-class="badge"></span>
|
||
<div data-room-log></div>
|
||
|
||
<template data-room-item="message"><div><strong>%user%</strong>: %text%</div></template>
|
||
<template data-room-item="system"><em>%text% (%online% online)</em></template>
|
||
|
||
<form data-room-send>
|
||
<input name="text" data-room-reset placeholder="Say something">
|
||
<button type="submit">Send</button>
|
||
</form>
|
||
</div>
|
||
```
|
||
|
||
- `data-room="name"` connects; optional `data-room-user` → `?user=`.
|
||
- `[data-room-log]` receives messages; `[data-room-status]` reflects `connected|disconnected|error`
|
||
(with `is-*` classes when `data-room-status-class` is set).
|
||
- `<template data-room-item="type">` renders each message of that `type`; `%field%` placeholders are
|
||
**HTML‑escaped**. `data-room-item=""` is the fallback template.
|
||
- `<form data-room-send>` sends all named fields as one JSON object; `data-room-reset` fields clear
|
||
after send.
|
||
|
||
### Realtime across multiple app processes
|
||
|
||
By default a room's `broadcast`/`toUser` reach only clients on **that** process. To make them reach
|
||
clients on **every** running app process/instance (multiple runs, or multiple apps behind the
|
||
gateway), enable realtime scaling — broadcasts are bridged over Redis pub/sub:
|
||
|
||
```ts
|
||
// wrnexus.config.ts
|
||
realtime: { scale: true, redisUrl: process.env.REDIS_URL }, // defaults to redis://localhost:6379
|
||
```
|
||
|
||
Now `client.room.broadcast(msg)` in one app instance is delivered to subscribers in all the
|
||
others. Connection‑targeted `send`/`to(id)` stay local (ids are per‑process). Under the hood it's
|
||
`bridgeRealtime(registry, bus)` from `@wrnexus/core` + the `@wrnexus/pubsub` Redis driver — you can
|
||
also wrn it manually for custom buses.
|
||
|
||
## 26. SSR/CSR data bindings
|
||
|
||
Fetch data for a page without hand‑writing fetch calls, using `ssr`/`client` blocks (§6) + `api="…"`
|
||
on an element:
|
||
|
||
```
|
||
page Users {
|
||
ssr {
|
||
api list GET /api/users/ssr { return users.map(u => u.name).join(", "); }
|
||
}
|
||
client {
|
||
api live GET /api/users/csr { return $data.count; }
|
||
}
|
||
view {
|
||
<p api="list">loading…</p> <!-- SSR: rendered before HTML is sent -->
|
||
<p api="live">…</p> <!-- CSR: fetched after hydration -->
|
||
}
|
||
}
|
||
```
|
||
|
||
- **`ssr`** bindings render on the server (replace an HTML comment marker with the value).
|
||
- **`client`** bindings render after hydration (the browser sees `data-wrnexus-csr` and fetches
|
||
`/__wrnexus/csr?…`). Inside a binding body, `$data` is the response, and `cookies`/`session`/
|
||
`localStorage` adapters are in scope.
|
||
|
||
## 27. Optional packages
|
||
|
||
Convenience helpers and dependency-free feature packages. Import only what you need.
|
||
|
||
- **`@wrnexus/helpers`** — context helpers including `getOriginalRequestUrl`,
|
||
`getOriginalRequestOrigin`, `getOriginalRequestPath`, `getOriginalRequestMethod`, and the safe
|
||
forward-auth `redirectToLogin(ctx, "/login", { allowedHosts })` response helper.
|
||
- **`@wrnexus/jwt`** — `signJwt(payload, secret, { expiresIn?, now? })`, `verifyJwt(token, secret)`
|
||
(throws `JwtError`), `jwtAuth({ secret, getToken?, required? })` middleware.
|
||
- **`@wrnexus/oauth`** — PKCE OAuth 2.0. Presets `google/github/discord(creds)` + `defineProvider`.
|
||
```ts
|
||
const p = google({ clientId, clientSecret });
|
||
const { url, state, verifier } = await startAuth(p, { redirectUri }); // store state+verifier, 302 to url
|
||
const { profile } = await completeAuth(p, { code, redirectUri, verifier }); // then logIn(ctx, profile)
|
||
```
|
||
- **`@wrnexus/authz`** — `defineRbac({ admin: ["post:*"] })`, `requireRole`, `requirePermission`,
|
||
policy combinators `any/all/attr`, `authorize(policy)`.
|
||
- **`@wrnexus/encryption`** — `encrypt/decrypt(text, key)` (AES‑256‑GCM), `generateKey`, `deriveKey`
|
||
(PBKDF2), `sha256`, `hmacSign/hmacVerify`.
|
||
- **`@wrnexus/pubsub`** — `createPubSub()` → `publish(topic, msg)` / `subscribe(pattern, handler)`
|
||
(supports `ns:*` and `*`). Default driver is in‑process. For **cross‑process** messaging
|
||
(multiple app runs / multiple domains) use the Redis driver — a self‑contained RESP client,
|
||
no extra dependency:
|
||
```ts
|
||
import { createPubSub } from "@wrnexus/pubsub";
|
||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
||
const bus = createPubSub(redisDriver(process.env.REDIS_URL)); // defaults to redis://localhost:6379
|
||
bus.subscribe("order:*", (msg, topic) => {
|
||
/* any app process receives it */
|
||
});
|
||
await bus.publish("order:created", { id: 7 });
|
||
```
|
||
- **`@wrnexus/queue`** — `createQueue()` → `add(name, data, { delayMs?, maxAttempts?, repeat? })`,
|
||
`process(name, handler)`; retries + backoff, recurring jobs, `drain()` for tests.
|
||
- **`@wrnexus/tracking`** — `createTracker({ sinks: [consoleSink] })` → `capture(err, ctx?)` and
|
||
`middleware()`.
|
||
|
||
## 28. Config profiles & env
|
||
|
||
Run the same app under named profiles (dev/prod/uat/test/…):
|
||
|
||
```ts
|
||
// wrnexus.config.ts
|
||
profiles: {
|
||
production: { db: { driver: "postgres", url: process.env.DATABASE_URL! } }, // auto-applied by `wrnexus build`
|
||
uat: { seo: { robots: "noindex,nofollow" } },
|
||
test: { db: { driver: "sqlite", url: "file:./test.db" } },
|
||
}
|
||
```
|
||
|
||
- **Select:** `--profile=<name>` on `dev`/`build`/`db`/`test`, or `WRNEXUS_PROFILE`. `build` defaults
|
||
to `production`, `test` to `test`.
|
||
- **Merge:** `profiles.<name>` is **deep‑merged** over the base config (objects merge; arrays/scalars
|
||
replace).
|
||
- **Env cascade** (low → high): `.env` < `.env.<profile>` < `.env.local` < `.env.<profile>.local`.
|
||
⚠️ **Real environment variables always win** over any `.env` file.
|
||
- `wrnexus profiles` lists profiles and which `.env` files exist.
|
||
|
||
## 29. Testing
|
||
|
||
Write app tests with `@wrnexus/test` (one import: `bun:test` primitives + helpers). Run with
|
||
`wrnexus test` (defaults to the `test` profile, loading `.env.test`).
|
||
|
||
```ts
|
||
// app/example.test.ts
|
||
import { test, expect, renderComponent, callRoute, createHarness } from "@wrnexus/test";
|
||
|
||
test("component renders", async () => {
|
||
const html = await renderComponent(COUNTER_SRC, { start: 5, label: "Clicks" });
|
||
expect(html).toContain("Clicks");
|
||
});
|
||
|
||
test("api handler", 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);
|
||
});
|
||
|
||
test("full app", async () => {
|
||
const app = await createHarness(import.meta.dir + "/.."); // boots on an ephemeral port
|
||
const res = await app.fetch("/");
|
||
expect(res.status).toBe(200);
|
||
app.close();
|
||
});
|
||
```
|
||
|
||
Also `mountHtml(html)` — mounts server HTML in happy‑dom with the reactive runtime hydrated, to
|
||
assert on `data-for`/`data-show`/`data-text`.
|
||
|
||
```bash
|
||
wrnexus test # once (test profile)
|
||
wrnexus test --watch # re-run on change
|
||
```
|
||
|
||
## 30. Build & deploy
|
||
|
||
```bash
|
||
wrnexus build . # → dist/server.js (self-contained, minified) + islands/reactive/theme/ui/styles + public/
|
||
bun dist/server.js # honors PORT
|
||
```
|
||
|
||
Containerize:
|
||
|
||
```bash
|
||
wrnexus generate docker # Dockerfile (multi-stage Bun), .dockerignore, docker-compose.yml (app + Postgres)
|
||
docker compose up --build
|
||
```
|
||
|
||
The Dockerfile builds with `oven/bun:1` → runs on `oven/bun:1-slim`, `EXPOSE 3000`, health‑checks
|
||
`/healthz`. Run migrations in production explicitly (`wrnexus db migrate`).
|
||
|
||
## Monorepo & multi‑domain SaaS
|
||
|
||
Build several interconnected WrNexus apps in one repo, and serve them behind one port routed by
|
||
domain — the foundation for multi‑domain SaaS.
|
||
|
||
```bash
|
||
wrnexus workspace acme # scaffold a monorepo
|
||
cd acme && bun install
|
||
bun run dev # = wrnexus gateway → http://localhost:3000
|
||
```
|
||
|
||
The scaffold:
|
||
|
||
```
|
||
acme/
|
||
wrnexus.workspace.ts # maps each app → the domains it serves
|
||
apps/
|
||
web/ # a WrNexus app → localhost, web.localhost
|
||
admin/ # a WrNexus app → admin.localhost
|
||
packages/
|
||
shared/ # @app/shared — shared code + a cross-app pubsub bus
|
||
```
|
||
|
||
`wrnexus.workspace.ts`:
|
||
|
||
```ts
|
||
import type { WorkspaceConfig } from "@wrnexus/cli/workspace";
|
||
export default {
|
||
apps: [
|
||
{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
|
||
{ name: "admin", dir: "apps/admin", domains: ["admin.localhost"] },
|
||
],
|
||
} satisfies WorkspaceConfig;
|
||
```
|
||
|
||
**The gateway** (`wrnexus gateway`) boots each app as its **own process** (full isolation — its
|
||
own database registry, pubsub, in‑memory state) and reverse‑proxies HTTP **and WebSocket** by the
|
||
`Host` header. Add the hosts to your machine (`/etc/hosts`): `127.0.0.1 web.localhost admin.localhost`.
|
||
Because apps are process‑isolated, one deploy can serve many tenants/domains safely.
|
||
|
||
**Gateway security & auth.** The gateway is the edge, so it can enforce access control before any
|
||
request reaches an app — set `security` (gateway‑wide) and per‑app `auth` in `wrnexus.workspace.ts`:
|
||
|
||
```ts
|
||
export default {
|
||
security: {
|
||
trustedHostsOnly: true, // reject unknown domains (404)
|
||
rateLimit: { max: 300, windowMs: 60_000 }, // per client IP → 429
|
||
headers: true, // baseline edge security headers
|
||
accessLog: true, // log host → app, method, path, status
|
||
// forwardedHeaders: true, // X-Forwarded-For/Host/Proto (default on)
|
||
},
|
||
apps: [
|
||
{ name: "web", dir: "apps/web", domains: ["localhost"] },
|
||
{
|
||
name: "admin",
|
||
dir: "apps/admin",
|
||
domains: ["admin.localhost"],
|
||
auth: { basic: { user: "admin", pass: "…" } }, // HTTP Basic
|
||
// auth: { allowIps: ["127.0.0.1", "::1"] }, // IP allowlist
|
||
// auth: { forward: { url: "https://auth/verify" } }, // forward-auth (SSO): 2xx = allow
|
||
},
|
||
],
|
||
} satisfies WorkspaceConfig;
|
||
```
|
||
|
||
Per‑app `auth` supports **HTTP Basic**, an **IP allowlist**, and **forward‑auth** (the gateway calls
|
||
your verify endpoint with the request's cookies/Authorization; a 2xx allows it — the hook for SSO).
|
||
The gateway also serves a health/status endpoint at **`/__gateway/health`**.
|
||
|
||
**Interconnecting apps:**
|
||
|
||
- **Shared code:** import a workspace package (`@app/shared`) from any app.
|
||
- **Runtime messaging:** the shared package exposes a pubsub `bus` backed by Redis, so an event
|
||
published in one app reaches subscribers in another:
|
||
```ts
|
||
import { bus } from "@app/shared";
|
||
await bus.publish("tenant:created", { id }); // app A
|
||
bus.subscribe("tenant:*", (msg) => { … }); // app B (needs Redis)
|
||
```
|
||
- **Shared databases:** point apps at the same `db`/`databases` in their configs.
|
||
|
||
Add another app any time: `wrnexus create apps/reports`, then list it in `wrnexus.workspace.ts`.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
# Part IV — Reference
|
||
|
||
## 31. CLI reference
|
||
|
||
```
|
||
wrnexus dev [app-dir] [--port=3000] [--profile=<name>] Dev server (live reload, auto-migrate, regen routes/queries)
|
||
wrnexus build [app-dir] [--profile=<name>] Production bundle → dist/ (production profile by default)
|
||
wrnexus create <app-name> Scaffold a new app
|
||
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
|
||
wrnexus workspace add <name> [--domain=name.localhost] Add an app to the current workspace
|
||
wrnexus gateway [--port=3000] [--prod] Serve every workspace app behind one port, routed by domain
|
||
wrnexus generate <type> <name> (alias: g) Scaffold: page | component | api | schema
|
||
wrnexus generate routes Regenerate app/routes.gen.ts
|
||
wrnexus generate docker Scaffold Dockerfile + compose
|
||
wrnexus eject <name...> Copy a WrNexus UI component into app/components/
|
||
wrnexus db new [name] [--from-models] [--db=<name>] Scaffold a migration (optionally from schema.ts)
|
||
wrnexus db generate [--db=<name>] Regenerate queries.gen.ts from queries/*.sql
|
||
wrnexus db migrate | rollback | status [--db=<name>] Apply / revert / list migrations
|
||
wrnexus db seed [--db=<name>] Run the seed script
|
||
wrnexus db studio [table] [--db=<name>] List tables + counts, or dump a table
|
||
(--db=<name> targets a named database under app/db/<name>/)
|
||
wrnexus test [app-dir] [--watch] [--profile=test] Run the app's tests (bun test, test profile)
|
||
wrnexus profiles [app-dir] List config profiles + their .env files
|
||
wrnexus help | --help | -h
|
||
```
|
||
|
||
`generate` types & aliases: `page`(`p`) → `app/pages/<name>.wrn`; `component`(`c`) →
|
||
`app/components/<name>.wrn`; `api`(`a`) → `app/api/<name>.ts`; `schema`(`s`) →
|
||
`app/schemas/<name>.ts`. Names may include slashes for nesting.
|
||
|
||
## 32. `wrnexus.config.ts` schema
|
||
|
||
`export default` an `AppConfig` (all fields optional). Searched: `wrnexus.config.{ts,js,mjs}`.
|
||
|
||
```ts
|
||
import type { AppConfig } from "@wrnexus/styles";
|
||
|
||
const config: AppConfig = {
|
||
compatibilityDate: "2026-08-02",
|
||
frameworkBehaviour: 1,
|
||
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
|
||
|
||
seo: { // SeoConfig (global defaults, merged per page)
|
||
title, titleTemplate, description, canonical, canonicalBase, robots,
|
||
keywords, image, siteName, type, locale, twitterCard, twitterSite, themeColor,
|
||
},
|
||
|
||
theme: {
|
||
palette: "violet",
|
||
default: "dark", // theme when no wrn-theme cookie
|
||
themes: { light: { "color-primary": "#2563eb" }, dark: { "color-primary": "#6c8cff" } },
|
||
},
|
||
|
||
i18n: { default: "en", locales: ["en", "fr"] }, // both optional (inferred from app/locales)
|
||
|
||
db: { driver: "sqlite" | "postgres" | "mysql" | "mongo", url: "file:./dev.db" }, // default → getDb()
|
||
databases: { // named connections → getDb("<name>")
|
||
analytics: { driver: "postgres", url: "…" }, // files under app/db/analytics/
|
||
},
|
||
realtime: { scale: true, redisUrl: "…" }, // bridge room broadcasts across app processes
|
||
|
||
port: 3000, // ⚠️ dev reads port only from --port=, not this
|
||
|
||
styles: {
|
||
entry: "app/styles/global.css", // default
|
||
process: async ({ entryPath, appDir, appRoot, mode }) => "/* final css */", // optional
|
||
},
|
||
|
||
security: { // SecurityConfig (secure defaults)
|
||
headers: true, // false → disable ALL framework headers
|
||
cors: false | { // opt-in
|
||
enabled: true, origin: "*" | string | string[], methods, allowedHeaders,
|
||
exposedHeaders, credentials, maxAge,
|
||
},
|
||
contentSecurityPolicy: false | { enabled, reportOnly, directives, useDefaults },
|
||
hsts: false | { enabled, maxAge, includeSubDomains, preload }, // on in prod
|
||
trustedTypes: false | { enabled, policyNames, requireForScript, allowDuplicates },
|
||
crossOriginOpenerPolicy: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none",
|
||
frameOptions: false | "DENY" | "SAMEORIGIN",
|
||
referrerPolicy: false | "strict-origin-when-cross-origin",
|
||
permissionsPolicy: false | { camera: [], geolocation: [], fullscreen: ["self"], … },
|
||
extraHeaders: { "X-Custom": "…" },
|
||
},
|
||
|
||
profiles: { // deep-merged over base when active
|
||
production: { /* Partial<AppConfig> */ },
|
||
uat: { … }, test: { … },
|
||
},
|
||
};
|
||
export default config;
|
||
```
|
||
|
||
**CSP defaults:** `default-src 'self'`; `script-src 'self'`; `style-src 'self' 'unsafe-inline'`;
|
||
`img-src 'self' data: blob:`; `font-src 'self' data:`; `connect-src 'self' ws: wss:`;
|
||
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`. Set a
|
||
directive to `false`/`null` to remove it.
|
||
|
||
## 33. Directive & attribute cheat sheet
|
||
|
||
**Reactive (client runtime):** `data-scope="k: v, …"` · `data-on-<event>` (via `@event`) ·
|
||
`data-text` · `data-show` · `data-for="item[, i] in list"` · `data-wrnexus-csr` (generated).
|
||
**Server/compiler:** `data-component` · `data-slot` · `<slot [name]>` · `{expr}` · `{{expr}}` ·
|
||
`{t:key}` (→ `data-t`) · `@event="stmt"` · `api="binding"`.
|
||
**Forms:** `data-schema` · `name` · `data-error` · `data-success` · `data-redirect`.
|
||
**Theme:** `data-wrn-theme-toggle` · `data-wrn-theme-set="name"`.
|
||
**i18n:** `data-wrn-lang-set="lang"` · `data-wrn-lang`.
|
||
**Realtime:** `data-room` · `data-room-user` · `data-room-log` · `data-room-status`
|
||
(+`data-room-status-class`) · `<template data-room-item="type">` (`%field%`) · `data-room-send` ·
|
||
`data-room-reset`.
|
||
|
||
## 34. Package API index
|
||
|
||
**Core / always present**
|
||
|
||
- `@wrnexus/core` — `Context`, `createContext`; security (`escapeHtml`, `isSafeRequestPath`); CSRF
|
||
(`csrfToken`, `verifyCsrf`, `csrfProtection`, `CSRF_COOKIE`, `CSRF_HEADER`); auth (`hashPassword`,
|
||
`verifyPassword`, `logIn`, `logOut`, `getUser`, `sessionAuth`, `requireAuth`); middleware
|
||
(`rateLimit`, `requestLogger`); cache (`TTLCache`, `cacheControl`, `etag`, `notModified`); uploads
|
||
(`saveUpload`, `collectUploads`, `sanitizeFilename`); streaming (`streamResponse`, `sse`); realtime
|
||
(`defineRoom`, `createRealtimeRegistry`, `bridgeRealtime`); errors (`renderError`, `renderNotFound`); headers
|
||
(`withSecurityHeaders`, CORS); sessions (`setSessionBackend`, `loadSession`); JSX runtime.
|
||
- `@wrnexus/compiler` — `compileWrnFile`, `compile`, `parse`, `generate`, `Lexer`, `ParseError`.
|
||
- `@wrnexus/router` — `buildRouter`, `matchRoute`, `generateRoutesFile`.
|
||
- `@wrnexus/ssr` — `renderDocument`.
|
||
- `@wrnexus/csr` — `getReactiveRuntime`, `getNavRuntime`, `getRealtimeRuntime`.
|
||
- `@wrnexus/reactive` — `signal`.
|
||
- `@wrnexus/styles` — `loadAppConfig`, `loadRawConfig`, `resolveProfile`, `loadEnv`, `headToString`,
|
||
`findStyleEntry`, `bundleCss`, `renderStyles`, theme helpers, `DEFAULT_THEMES`, `AppConfig`.
|
||
- `@wrnexus/dev-server` — `startServer(opts) → { url, router, stop }`, `createProductionServer`,
|
||
node adapter.
|
||
- `@wrnexus/cli` — the `wrnexus` binary.
|
||
|
||
**Feature packages**
|
||
|
||
- `@wrnexus/db` — `v`, `table`, `getDb(name?)`/`setDb`/`registerDb`/`hasDb`/`databaseNames`,
|
||
`createDb`, `paginate`, `loadRelated`, migrations, query generation; subpaths `/session`,
|
||
`/sqlite`, `/postgres`, `/mysql`, `/mongo`.
|
||
- `@wrnexus/dev-server` — `startServer`, `startGateway` (multi-app host router), prod handler.
|
||
- `@wrnexus/cli` — the `wrnexus` binary; subpath `/workspace` (`WorkspaceConfig`, `startGateway` integration).
|
||
- `@wrnexus/validation` — `v`, `parseBody`, `parseEnv`, `invalid`, `renderSchemasScript`.
|
||
- `@wrnexus/helpers` — original gateway URL/path/method helpers and `redirectToLogin`.
|
||
- `@wrnexus/ui` — `uiComponentsDir`, `uiCssPath`, `uiCss`, `uiComponentNames`.
|
||
- `@wrnexus/i18n` — `loadLocales`, `resolveI18n`, `makeT`, `resolveLang`, `translateHtml`,
|
||
`formatNumber`, `formatCurrency`, `formatDate`, `formatRelativeTime`, `plural`.
|
||
- `@wrnexus/pubsub` — `createPubSub`, `memoryDriver`; subpath `/redis` → `redisDriver(url?)` for
|
||
cross-process messaging.
|
||
- `@wrnexus/jwt`, `@wrnexus/oauth`, `@wrnexus/authz`, `@wrnexus/encryption`, `@wrnexus/queue`,
|
||
`@wrnexus/tracking` — see §27.
|
||
- `@wrnexus/test` — `renderComponent`, `mountHtml`, `callRoute`, `createHarness` + `bun:test`.
|
||
|
||
## 35. The `Context` object
|
||
|
||
Available in API handlers, page `api` blocks, and middleware (`ctx`):
|
||
|
||
- `ctx.req: Request`, `ctx.url: URL`, `ctx.params: Record<string,string>` (dynamic route segments).
|
||
- `ctx.session` — get/set/clear session values; `ctx.user` — the logged‑in user (or null).
|
||
- `ctx.t(key, params?)` — translate; `ctx.lang` — active language.
|
||
- `ctx.locals` — per‑request scratch space (e.g. `requestId`).
|
||
- Helpers: `verifyCsrf(ctx)`, `csrfToken(ctx)`, `getUser(ctx)`, `logIn/logOut(ctx, …)`.
|
||
- DB: use `getDb()` (⚠️ not `ctx.db`).
|
||
|
||
## 36. Editor support (VS Code)
|
||
|
||
The `editors/vscode` extension provides `.wrn` highlighting (embedded HTML/CSS/TypeScript), live
|
||
diagnostics from the real compiler, snippets, completions, and **distinct colors for WrNexus's own
|
||
attributes** (`@event`/`{t:}` in one accent, `data-*` directives in another). See
|
||
`editors/vscode/README.md`. The repo's `.vscode/settings.json` applies those attribute colors when
|
||
you edit `.wrn` files here.
|
||
|
||
---
|
||
|
||
_This guide is generated to be self‑contained. When in doubt, the source of truth is the code under
|
||
`packages/*` and the runnable reference app under `examples/basic-app`._
|