docs: rank the destructive generator as item 0
Quality / quality (ubuntu-latest) (push) Failing after 19m36s
Quality / quality (windows-latest) (push) Canceled after 0s

It was written up in 4.7 but never made the work order, which is exactly how it
stayed dangerous in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:18:45 +05:30
co-authored by Claude Opus 5
parent 790b81330a
commit 247f360ae7
79 changed files with 2759 additions and 146 deletions
+13 -12
View File
@@ -802,18 +802,19 @@ compares `git ls-files` against the on-disk listing byte for byte.
Ranked by return, not by size. The first item changes the cost of every item
below it, which is why it is first.
| # | Item | Why now |
| --- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. |
| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. |
| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. |
| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. |
| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. |
| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. |
| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. |
| 8 | §3.1 Group A: supersede the 5 duplicate scaffolds | 15 of the 22 dead outputs, and 5 fewer components to maintain. Needs a migration entry, not new code. |
| 9 | §3.2 scaffolds that need only their styles | Larger and cheaper than it looks; folds into item 6. |
| 10 | §3.1 Group B: build Chart, TreeView, Confetti, CopyMarkup | Real component work. Chart needs a rendering decision first. |
| # | Item | Why now |
| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | §4.7 neutralise the destructive generator | Safety, not improvement. Running one file in `scripts/` at random can shred the component library. Do this before anyone else touches the repo. |
| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. |
| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. |
| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. |
| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. |
| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. |
| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. |
| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. |
| 8 | §3.1 Group A: supersede the 5 duplicate scaffolds | 15 of the 22 dead outputs, and 5 fewer components to maintain. Needs a migration entry, not new code. |
| 9 | §3.2 scaffolds that need only their styles | Larger and cheaper than it looks; folds into item 6. |
| 10 | §3.1 Group B: build Chart, TreeView, Confetti, CopyMarkup | Real component work. Chart needs a rendering decision first. |
§2.2, §2.4 and §2.5 fold into item 1 as diagnostics first, then into item 2 as
model work.
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
@@ -0,0 +1,2 @@
REDIS_URL=redis://localhost:6379
AUTH_SECRET=replace-with-at-least-32-random-characters
@@ -0,0 +1,25 @@
node_modules/
dist/
.wrnexus/
**/.wrnexus/
coverage/
.env
.env.*
!.env.example
!.env.*.example
*.log
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
uploads/
mobile/android/
mobile/ios/
mobile/.expo/
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.tsbuildinfo
.eslintcache
@@ -0,0 +1,6 @@
node_modules/
dist/
.wrnexus/
**/.wrnexus/
*.log
**/CLAUDE.md
@@ -0,0 +1,9 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "lf"
}
@@ -0,0 +1,3 @@
{
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
"prettier.requireConfig": true,
"[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true }
}
+50 -13
View File
@@ -1,19 +1,56 @@
# Inter-app + external API showcase
# Inter-app API showcase
`GET /api/product-summary?sku=starter` demonstrates one request handler making:
Generated with `wrnexus workspace inter-app-api-showcase`.
1. an RPC request to the `catalog` app (`getProduct`);
2. an external HTTPS request to GitHub's public REST API; and
3. an RPC request to the `audit` app (`recordLookup`).
- `web` exposes `GET /api/product` and calls admin through private RPC.
- `admin` provides the `catalog` service.
- `packages/shared` owns the typed contract imported by both apps.
The handler deliberately forwards `as: ctx` only to WRNexus peer apps. The RPC package turns that into a short-lived subject/tenant token; it is never forwarded to GitHub. Each peer app must implement the same contract from `app/lib/contracts.ts` (normally a shared workspace package) under `app/services/`, and must authorize its own procedures.
Run the verification from the repository root:
Before running this app, configure all three apps with the same private internal-origin map and a distinct, 32+ character RPC secret:
```sh
WRNEXUS_RPC_SECRET=replace-with-a-private-32-character-minimum-secret
WRNEXUS_APP_NAME=product-summary
WRNEXUS_INTERNAL_ORIGINS={"catalog":"http://127.0.0.1:4101","audit":"http://127.0.0.1:4102"}
```bash
bun test examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts
```
The peer app processes must remain private; the public gateway blocks the RPC route by design. Run with `bun run --cwd examples/inter-app-api-showcase dev`.
A WrNexus **workspace** — multiple apps, one gateway, interconnected.
```
inter-app-api-showcase/
wrnexus.workspace.ts # apps ↔ domains map (used by `wrnexus gateway`)
apps/
web/ # a WrNexus app → localhost, web.localhost
admin/ # a WrNexus app → admin.localhost
packages/
shared/ # @app/shared — shared code + cross-app pubsub bus
```
## Run everything (one port, routed by domain)
```bash
bun install
bun run dev # = wrnexus gateway → http://127.0.0.1:3000
```
Add the hosts to your machine (e.g. /etc/hosts):
```
127.0.0.1 web.localhost admin.localhost
```
Open `http://localhost:3000` for the web app or
`http://admin.localhost:3000` for the admin app. The ports printed for individual
apps are internal gateway targets, not public workspace URLs.
## Interconnect
- **Shared code:** import `@app/shared` in any app.
- **Runtime messaging:** `import { bus } from "@app/shared"` then
`bus.publish("tenant:created", {...})` in one app and
`bus.subscribe("tenant:*", fn)` in another (needs Redis).
- **Databases:** point apps at the same `db`/`databases` in their config.
## Add another app
```bash
wrnexus workspace add reports --domain=reports.localhost
```
@@ -1,60 +0,0 @@
import type { Context } from "@wrnexus/core";
import { httpTransport, serviceClient } from "@wrnexus/rpc";
import { auditService, catalogService } from "../lib/contracts.ts";
interface GitHubRepository {
full_name?: unknown;
stargazers_count?: unknown;
}
function requestedSku(ctx: Context): string {
return new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter";
}
/** GET /api/product-summary?sku=starter */
export async function GET(ctx: Context): Promise<Response> {
const sku = requestedSku(ctx);
const transport = httpTransport();
// Inter-app call #1: query the catalog app. `{ as: ctx }` forwards the
// signed subject/tenant context; catalog still authorizes independently.
const catalog = serviceClient(catalogService, { app: "catalog", as: ctx, transport });
const product = await catalog.getProduct({ sku });
// External API call: GitHub's public repository endpoint. Do not send the
// user's RPC identity token or internal headers to external services.
let githubResponse: Response;
try {
githubResponse = await fetch("https://api.github.com/repos/octocat/Hello-World", {
headers: { accept: "application/vnd.github+json", "user-agent": "wrnexus-example" },
signal: ctx.req.signal,
});
} catch {
return Response.json({ error: "External repository lookup failed." }, { status: 502 });
}
if (!githubResponse.ok) {
return Response.json({ error: "External repository lookup failed." }, { status: 502 });
}
const github = (await githubResponse.json()) as GitHubRepository;
if (typeof github.full_name !== "string" || typeof github.stargazers_count !== "number") {
return Response.json(
{ error: "External repository returned an unexpected response." },
{ status: 502 },
);
}
// Inter-app call #2: record the completed lookup in the audit app. This is
// intentionally awaited: callers learn whether the audit record was saved.
const audit = serviceClient(auditService, { app: "audit", as: ctx, transport });
const receipt = await audit.recordLookup({
sku: product.sku,
repository: github.full_name,
stars: github.stargazers_count,
});
return Response.json({
product,
external: { repository: github.full_name, stars: github.stargazers_count },
audit: receipt,
});
}
@@ -1,11 +0,0 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
test("product summary composes one external request with two peer-app calls", () => {
const source = readFileSync(join(import.meta.dir, "api", "product-summary.ts"), "utf8");
expect(source).toContain('serviceClient(catalogService, { app: "catalog", as: ctx, transport })');
expect(source).toContain('serviceClient(auditService, { app: "audit", as: ctx, transport })');
expect(source).toContain('fetch("https://api.github.com/repos/octocat/Hello-World"');
expect(source).toContain("ctx.req.signal");
});
@@ -1,34 +0,0 @@
import { defineService, procedure } from "@wrnexus/rpc";
import { v } from "@wrnexus/validation";
/**
* In a real multi-app workspace, put these contracts in a shared package and
* import that package from this app and each peer. They live together here so
* the example is self-contained.
*/
export const catalogService = defineService({
name: "catalog",
procedures: {
getProduct: procedure
.input(v.object({ sku: v.string() }))
.output<{ sku: string; displayName: string; enabled: boolean }>()
.idempotent()
.build(),
},
});
export const auditService = defineService({
name: "audit",
procedures: {
recordLookup: procedure
.input(
v.object({
sku: v.string(),
repository: v.string(),
stars: v.number(),
}),
)
.output<{ eventId: string }>()
.build(),
},
});
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
@@ -0,0 +1,8 @@
# Copy to .env for local development. Never commit real secrets.
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
DATABASE_URL=file:./dev.db
REDIS_URL=redis://localhost:6379
AUTH_SECRET=replace-with-at-least-32-random-characters
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
ANTHROPIC_API_KEY=
OTEL_EXPORTER_OTLP_ENDPOINT=
@@ -0,0 +1,3 @@
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
DATABASE_URL=file:./test.db
AUTH_SECRET=test-only-secret-replace-outside-tests
@@ -0,0 +1,48 @@
# Dependencies
node_modules/
# WRNexusJS and production builds
dist/
.wrnexus/
**/.wrnexus/
coverage/
# Environment files and local secrets
.env
.env.*
!.env.example
!.env.*.example
# Logs and runtime files
*.log
logs/
*.pid
*.pid.lock
# Local databases
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
uploads/
# Generated native projects
mobile/android/
mobile/ios/
mobile/.expo/
# Editors and operating systems
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.swp
*.swo
.DS_Store
Thumbs.db
# TypeScript and test caches
*.tsbuildinfo
.eslintcache
.nyc_output/
@@ -0,0 +1,6 @@
node_modules/
dist/
.wrnexus/
**/.wrnexus/
*.log
CLAUDE.md
@@ -0,0 +1,9 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "lf"
}
@@ -0,0 +1,3 @@
{
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
@@ -0,0 +1,12 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"prettier.requireConfig": true,
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": true
}
}
@@ -0,0 +1,295 @@
# WrNexus app - instructions for AI coding assistants
This is a **WrNexus** app. When creating or editing pages, components, API routes,
or features, follow the framework conventions below. WrNexus is private and not in
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
# WrNexus
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
> This document teaches an AI how to write correct WrNexus code. It is private and
> post-dates model training data, so rely on THIS document, not prior web-framework
> assumptions.
## Golden rules
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
- **Routing is file-based** under `app/`. The filename is the route. No router config.
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
on the server and hydrate automatically — you never write client-side JS islands.
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
## Project layout
```
app/
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
middleware/ *.ts → export default async (ctx, next) => next()
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
locales/ *.json → i18n messages per language
db/ schema.ts, queries/*.sql, migrations/*.sql
styles/ global.css → Tailwind (default) or plain CSS
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
public/ → static assets served at /
```
## `.wrn` page
```wrn
page Home {
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
seo {
title = "Home"
description = "..."
canonical = "/"
}
view {
<h1>Hello</h1>
<p>Count is {count}, doubled is {count * 2}.</p>
<button @click="count++">Increment</button>
<div data-component="counter" start="5" label="Clicks"></div>
}
style {
h1 { color: var(--wire-color-text); }
}
}
```
## `.wrn` component
```wrn
component Counter {
props { // props come from mount attributes; each is coerced to the
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
label = "Count"
}
state count = start // state may reference props
view {
<button @click="count++">{label}: {count}</button>
}
}
```
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
Components render on the server with their props, then hydrate — no per-component JS.
## The `view { }` block (plain HTML + a few directives)
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
## Data-driven tables / lists (server-rendered `.wrn`)
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
This renders on the **server** (SSR-first) and is HTML-escaped by default.
```wrn
page Admin {
layout = "dashboard"
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
ssr {
api rows GET /api/contacts { return contacts }
}
view {
<table>
<tbody>
{#each rows as r, i}
<tr>
<td>#{i}</td>
<td>{r.name}</td>
<td><a href="mailto:{r.email}">{r.email}</a></td>
</tr>
{:empty}
<tr><td colspan="3">No submissions yet.</td></tr>
{/each}
</tbody>
</table>
}
}
```
The matching API returns the array under a key the `ssr` block reads:
```ts
// app/api/contacts.ts → GET /api/contacts
import { getDb } from "@wrnexus/db";
export const GET = async () => {
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
return Response.json({ contacts }); // ssr block does `return contacts`
};
```
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
pages returning an HTML string are also supported for fully-custom programmatic rendering,
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
## API routes (`app/api/*.ts`)
```ts
// app/api/users/list.ts → GET /api/users/list
import { getDb } from "@wrnexus/db";
export const GET = async (ctx) => {
return Response.json({ users: await ListUsers(getDb()) });
};
export const POST = async (ctx) => {
const body = await ctx.req.json();
return Response.json({ ok: true, body }, { status: 201 });
};
```
`ctx` (the `Context` from `@wrnexus/core`) has:
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]``ctx.params.id`),
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
`@wrnexus/helpers` instead of constructing it from untrusted headers:
```ts
import { redirectToLogin } from "@wrnexus/helpers";
return redirectToLogin(ctx, "/login", {
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
});
```
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
## Middleware & realtime
```ts
// app/middleware/logger.ts
export default async function logger(ctx, next) {
console.log(ctx.req.method, ctx.url.pathname);
return next(); // return a Response WITHOUT calling next() to short-circuit
}
```
```ts
// app/realtime/chat.ts → ws://host/realtime/chat
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) {
client.send({ type: "system", text: "connected" });
},
onMessage(client, msg) {
client.room.broadcast({ type: "message", data: msg });
},
});
```
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
## Config (`wrnexus.config.ts`)
```ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
styles: {
entry: "app/styles/global.css",
process: async ({ entryPath, mode }) => /* Tailwind */ "",
},
fonts: {
sans: '"Inter", system-ui, sans-serif',
google: [{ family: "Inter", weights: [400, 600] }],
},
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
i18n: { default: "en", locales: ["en", "es"] },
db: { driver: "sqlite", url: "file:./dev.db" },
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
};
export default config;
```
## Database (`@wrnexus/db`)
```ts
// app/db/schema.ts
import { v, table } from "@wrnexus/db";
export const users = table("users", {
id: v.id(),
name: v.string(),
email: v.string().unique(),
createdAt: v.timestamp(),
});
```
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
## Validation (`@wrnexus/validation`)
```ts
// app/schemas/login.ts
import { v } from "@wrnexus/validation";
export default v.object({
email: v.string().email(),
password: v.string().min(8),
});
```
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);``r.ok ? r.value : r.response`.
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
## AI / LLM (`@wrnexus/ai`)
```ts
// app/api/ai.ts
import { createAI } from "@wrnexus/ai";
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
export const POST = async (ctx) => {
const { prompt } = await ctx.req.json();
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
};
```
## CLI
```
wrnexus dev . # dev server + HMR
wrnexus build . # production build → dist/server.js
bun dist/server.js # run the production server (or npm start)
wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
```
## When asked to "create a page/component/feature"
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
@@ -0,0 +1,17 @@
// POST /api/ai { "prompt": "..." } → Claude's reply.
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
import { createAI } from "@wrnexus/ai";
import type { Context } from "@wrnexus/core";
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
export const POST = async (ctx: Context) => {
if (!process.env.ANTHROPIC_API_KEY) {
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
}
const { prompt } = await ctx.req.json().catch(() => ({}));
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
// Stream the reply back as plain text. Use `ai.generate(prompt)` for a one-shot string.
return ai.streamResponse(prompt);
};
@@ -0,0 +1,3 @@
export const GET = async () => {
return Response.json({ message: "Hello API" });
};
@@ -0,0 +1,20 @@
// A reusable component. Route: none — mounted inside a page with
// <div data-component="counter" ...props></div>.
//
// Components render on the SERVER (with their props applied) and are hydrated in
// the browser by the generic reactive runtime — they ship no JS of their own.
component Counter {
// Props arrive as mount attributes, each coerced to the type of its default
// (so start="5" arrives as the number 5).
props {
start = 0
label = "Count"
}
// State can reference props. `count` seeds the reactive scope.
state count = start
view {
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
}
}
@@ -0,0 +1,2 @@
-- Create application tables here.
-- Run with: bunx wrnexus db migrate
@@ -0,0 +1,2 @@
// Add deterministic development seed data here.
export async function seed(): Promise<void> {}
@@ -0,0 +1,22 @@
// Global document layout. The framework renders this once around the selected
// page layout and merges SEO metadata, styles, and scripts into <head>/<body>.
// Request cookies, resolved theme, language, URL, and pathname are available
// as SSR props, so document attributes do not need a client-side correction.
layout Document {
props {
cookies = {}
theme = "light"
language = "en"
url = ""
pathname = "/"
}
view {
<html>
<head></head>
<body>
<div id="app"><slot /></div>
</body>
</html>
}
}
@@ -0,0 +1,6 @@
{
"common": {
"appName": "admin",
"welcome": "Welcome to admin"
}
}
@@ -0,0 +1,8 @@
import type { Middleware } from "@wrnexus/core";
const logger: Middleware = async (ctx, next) => {
console.log(ctx.req.method, ctx.url.pathname);
return next();
};
export default logger;
@@ -0,0 +1,20 @@
page About {
seo {
title = "About"
description = "Learn how admin is built with WrNexus."
}
view {
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
<article class="mx-auto max-w-2xl">
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
<h1 class="mt-4 text-4xl font-bold tracking-tight">About admin</h1>
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
events, components, APIs, and data without switching to another UI framework.
</p>
</article>
</main>
}
}
@@ -0,0 +1,63 @@
// Home page (route: /). SSR-first: the view is server-rendered, then components
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
page Home {
seo {
title = "Home"
description = "admin — built with WrNexus, an SSR-first Bun framework."
}
view {
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
<header class="flex items-center justify-between py-6">
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
admin
</span>
<button data-wire-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
Toggle theme
</button>
</header>
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
Server-rendered.<br />
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
</h1>
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
admin runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
<a href="/api/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View API</a>
</div>
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
live · hydrated on the server
</div>
<div class="mt-4 flex items-center justify-between gap-4">
<div data-component="counter" start="0" label="Clicks"></div>
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
</div>
</div>
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
</p>
</section>
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
</footer>
</div>
</main>
}
}
@@ -0,0 +1,20 @@
// ws://<host>/realtime/chat — a simple broadcast room.
//
// The client side is the framework's realtime runtime; a page opts in with
// `data-room="chat"`. Here we only handle room events.
//
// client.send(msg) → just this connection
// client.broadcast(msg) → everyone else in the room
// client.room.broadcast(msg) → everyone, including the sender
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) {
client.send({ type: "system", text: "connected" });
},
onMessage(client, msg) {
// Echo each message to the whole room so every tab stays in sync.
client.room.broadcast({ type: "message", data: msg });
},
});
@@ -0,0 +1,6 @@
import { v } from "@wrnexus/validation";
export const contactSchema = v.object({
email: v.string().email(),
message: v.string().min(10).max(2_000),
});
@@ -0,0 +1,16 @@
import { implement } from "../../../../../../packages/rpc/src/index.ts";
import { catalogService } from "../../../../packages/shared/src/index.ts";
/** Private service consumed by the workspace's web app. */
export default implement(
catalogService,
{
getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }),
},
{
selfApp: "admin",
// Demo policy. Use the admin app's database-backed permission store in production.
checkPermission: (permission, subject) =>
permission === "catalog:read" && subject?.subjectId === "demo-user",
},
);
@@ -0,0 +1,19 @@
/*
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
*
* @source tells Tailwind which files to scan for class names.
*/
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@source "../**/*.wrn";
@source "../**/*.tsx";
/* Make Tailwind's `dark:` variant follow the framework's data-theme attribute
* (set on <html> by the theme system), not the OS setting. Any element with
* data-wire-theme-toggle flips it. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
body {
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
}
@@ -0,0 +1,44 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
export default tseslint.config(
{
ignores: [
"node_modules/**",
"dist/**",
".wrnexus/**",
"**/.wrnexus/**",
"mobile/android/**",
"mobile/ios/**",
],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir,
},
},
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-undef": "off",
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
);
@@ -0,0 +1,63 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"type": "module",
"wrnexus": {
"version": "0.8.6"
},
"scripts": {
"dev": "wrnexus dev .",
"build": "wrnexus build .",
"start": "bun dist/server.js",
"production": "bun run build && bun run start",
"typecheck": "tsc --noEmit",
"test": "wrnexus test .",
"test:watch": "wrnexus test . --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"doctor": "wrnexus doctor .",
"analyze": "wrnexus analyze .",
"inspect": "wrnexus inspect packages .",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "0.8.6",
"@wrnexus/auth": "0.8.6",
"@wrnexus/captcha": "0.8.6",
"@wrnexus/core": "0.8.6",
"@wrnexus/csr": "0.8.6",
"@wrnexus/db": "0.8.6",
"@wrnexus/dev-server": "0.8.6",
"@wrnexus/encryption": "0.8.6",
"@wrnexus/helpers": "0.8.6",
"@wrnexus/i18n": "0.8.6",
"@wrnexus/image": "0.8.6",
"@wrnexus/jwt": "0.8.6",
"@wrnexus/observability": "0.8.6",
"@wrnexus/realtime": "0.8.6",
"@wrnexus/security": "0.8.6",
"@wrnexus/store": "0.8.6",
"@wrnexus/styles": "0.8.6",
"@wrnexus/tracking": "0.8.6",
"@wrnexus/ui": "0.8.6",
"@wrnexus/uploader": "0.8.6",
"@wrnexus/validation": "0.8.6",
"@wrnexus/authz": "0.8.6"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
}
}
@@ -0,0 +1,276 @@
# WrNexus
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
> This document teaches an AI how to write correct WrNexus code. It is private and
> post-dates model training data, so rely on THIS document, not prior web-framework
> assumptions.
## Golden rules
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
- **Routing is file-based** under `app/`. The filename is the route. No router config.
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
on the server and hydrate automatically — you never write client-side JS islands.
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
## Project layout
```
app/
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
middleware/ *.ts → export default async (ctx, next) => next()
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
locales/ *.json → i18n messages per language
db/ schema.ts, queries/*.sql, migrations/*.sql
styles/ global.css → Tailwind (default) or plain CSS
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
public/ → static assets served at /
```
## `.wrn` page
```wrn
page Home {
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
seo {
title = "Home"
description = "..."
canonical = "/"
}
view {
<h1>Hello</h1>
<p>Count is {count}, doubled is {count * 2}.</p>
<button @click="count++">Increment</button>
<div data-component="counter" start="5" label="Clicks"></div>
}
style {
h1 { color: var(--wire-color-text); }
}
}
```
## `.wrn` component
```wrn
component Counter {
props { // props come from mount attributes; each is coerced to the
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
label = "Count"
}
state count = start // state may reference props
view {
<button @click="count++">{label}: {count}</button>
}
}
```
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
Components render on the server with their props, then hydrate — no per-component JS.
## The `view { }` block (plain HTML + a few directives)
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
## Data-driven tables / lists (server-rendered `.wrn`)
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
This renders on the **server** (SSR-first) and is HTML-escaped by default.
```wrn
page Admin {
layout = "dashboard"
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
ssr {
api rows GET /api/contacts { return contacts }
}
view {
<table>
<tbody>
{#each rows as r, i}
<tr>
<td>#{i}</td>
<td>{r.name}</td>
<td><a href="mailto:{r.email}">{r.email}</a></td>
</tr>
{:empty}
<tr><td colspan="3">No submissions yet.</td></tr>
{/each}
</tbody>
</table>
}
}
```
The matching API returns the array under a key the `ssr` block reads:
```ts
// app/api/contacts.ts → GET /api/contacts
import { getDb } from "@wrnexus/db";
export const GET = async () => {
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
return Response.json({ contacts }); // ssr block does `return contacts`
};
```
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
pages returning an HTML string are also supported for fully-custom programmatic rendering,
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
## API routes (`app/api/*.ts`)
```ts
// app/api/users/list.ts → GET /api/users/list
import { getDb } from "@wrnexus/db";
export const GET = async (ctx) => {
return Response.json({ users: await ListUsers(getDb()) });
};
export const POST = async (ctx) => {
const body = await ctx.req.json();
return Response.json({ ok: true, body }, { status: 201 });
};
```
`ctx` (the `Context` from `@wrnexus/core`) has:
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
`@wrnexus/helpers` instead of constructing it from untrusted headers:
```ts
import { redirectToLogin } from "@wrnexus/helpers";
return redirectToLogin(ctx, "/login", {
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
});
```
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
## Middleware & realtime
```ts
// app/middleware/logger.ts
export default async function logger(ctx, next) {
console.log(ctx.req.method, ctx.url.pathname);
return next(); // return a Response WITHOUT calling next() to short-circuit
}
```
```ts
// app/realtime/chat.ts → ws://host/realtime/chat
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) { client.send({ type: "system", text: "connected" }); },
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
});
```
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
## Config (`wrnexus.config.ts`)
```ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
i18n: { default: "en", locales: ["en", "es"] },
db: { driver: "sqlite", url: "file:./dev.db" },
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
};
export default config;
```
## Database (`@wrnexus/db`)
```ts
// app/db/schema.ts
import { v, table } from "@wrnexus/db";
export const users = table("users", {
id: v.id(),
name: v.string(),
email: v.string().unique(),
createdAt: v.timestamp(),
});
```
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
## Validation (`@wrnexus/validation`)
```ts
// app/schemas/login.ts
import { v } from "@wrnexus/validation";
export default v.object({
email: v.string().email(),
password: v.string().min(8),
});
```
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
## AI / LLM (`@wrnexus/ai`)
```ts
// app/api/ai.ts
import { createAI } from "@wrnexus/ai";
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
export const POST = async (ctx) => {
const { prompt } = await ctx.req.json();
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
};
```
## CLI
```
wrnexus dev . # dev server + HMR
wrnexus build . # production build → dist/server.js
bun dist/server.js # run the production server (or npm start)
wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
```
## When asked to "create a page/component/feature"
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test";
import { parseOrThrow } from "@wrnexus/validation";
import { contactSchema } from "../app/schemas/contact.ts";
test("starter validation schema accepts a contact request", () => {
expect(
parseOrThrow(contactSchema, {
email: "hello@example.com",
message: "Hello from the generated application.",
}),
).toEqual({
email: "hello@example.com",
message: "Hello from the generated application.",
});
});
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["bun"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "@wrnexus/core"
},
"include": ["app", "test", "wrnexus.config.ts"],
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
}
@@ -0,0 +1,153 @@
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
compatibilityDate: "2026-08-02",
frameworkBehaviour: 1,
// v0.8 defaults: explicit imports, strict template types, safe stores, and
// automatic progressive navigation. Package plugins are discovered from the
// installed packages above; add custom plugins to this array when needed.
plugins: [],
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
types: {
strict: true,
noImplicitAny: true,
strictNullChecks: true,
checkTemplates: true,
checkComponentProps: true,
generateDeclarations: true,
},
functions: { legacyDefaultRuntime: "current" },
stores: { strictMutations: true, persistence: true },
compatibility: {
legacyEmit: false,
legacyEventProps: false,
legacyComponentDiscovery: false,
stringLayouts: false,
},
experimental: {},
performance: {
enforcement: "warn",
analyze: true,
budgets: {
routeJsBytes: 50 * 1024,
routeCssBytes: 25 * 1024,
lcpMs: 2_500,
inpMs: 200,
cls: 0.1,
},
},
observability: {
enabled: true,
serviceName: "admin",
serverTiming: true,
sampleRate: 1,
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
webVitals: true,
},
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
navigation: { mode: "auto" },
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
mobile: {
enabled: true,
appId: "com.example.admin",
appName: "admin",
userAgent: "WrNexusMobile",
backgroundColor: "#0f172a",
// layout: "mobile", // app/layouts/mobile.wrn
// icon: "resources/icon.png",
},
// PWA support is enabled automatically. Override any install metadata here.
pwa: {
name: "admin",
shortName: "admin",
display: "standalone",
themeColor: "#6366f1",
backgroundColor: "#0f172a",
},
seo: {
title: "admin",
titleTemplate: "%s | admin",
// Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy.
canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN,
description: "An SSR-first WrNexus app.",
robots: "index,follow",
themeColor: "#6366f1",
},
styles: {
entry: "app/styles/global.css",
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
// and at `wrnexus build`. `@tailwindcss/cli` writes to stdout, so we capture
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
// still bundled and served as-is.
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();
},
},
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
fonts: {
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
},
//
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
theme: { palette: "violet", default: "light" },
i18n: { default: "en", locales: ["en"] },
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
databases: {},
storage: {
default: "public",
stores: {
public: {
driver: "local",
access: "public",
dir: "uploads/public",
maxBytes: 10_000_000,
accept: ["image/*", "application/pdf"],
},
private: {
driver: "local",
access: "private",
dir: "uploads/private",
maxBytes: 10_000_000,
},
},
},
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
port: Number(process.env.PORT ?? 3000),
security: {
cors: { enabled: false },
},
profiles: {
development: {},
test: {
db: { driver: "sqlite", url: "file:./test.db" },
observability: { exporter: "none", sampleRate: 0 },
},
staging: {
seo: { robots: "noindex,nofollow" },
performance: { enforcement: "error" },
build: { sourceMaps: true, report: true },
},
production: {
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
performance: { enforcement: "error" },
build: { sourceMaps: false, report: true },
devToolbar: false,
},
},
};
export default config;
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
@@ -0,0 +1,8 @@
# Copy to .env for local development. Never commit real secrets.
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
DATABASE_URL=file:./dev.db
REDIS_URL=redis://localhost:6379
AUTH_SECRET=replace-with-at-least-32-random-characters
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
ANTHROPIC_API_KEY=
OTEL_EXPORTER_OTLP_ENDPOINT=
@@ -0,0 +1,3 @@
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
DATABASE_URL=file:./test.db
AUTH_SECRET=test-only-secret-replace-outside-tests
@@ -0,0 +1,48 @@
# Dependencies
node_modules/
# WRNexusJS and production builds
dist/
.wrnexus/
**/.wrnexus/
coverage/
# Environment files and local secrets
.env
.env.*
!.env.example
!.env.*.example
# Logs and runtime files
*.log
logs/
*.pid
*.pid.lock
# Local databases
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
uploads/
# Generated native projects
mobile/android/
mobile/ios/
mobile/.expo/
# Editors and operating systems
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.swp
*.swo
.DS_Store
Thumbs.db
# TypeScript and test caches
*.tsbuildinfo
.eslintcache
.nyc_output/
@@ -0,0 +1,6 @@
node_modules/
dist/
.wrnexus/
**/.wrnexus/
*.log
CLAUDE.md
@@ -0,0 +1,9 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "lf"
}
@@ -0,0 +1,3 @@
{
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
@@ -0,0 +1,12 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"prettier.requireConfig": true,
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": true
}
}
@@ -0,0 +1,295 @@
# WrNexus app - instructions for AI coding assistants
This is a **WrNexus** app. When creating or editing pages, components, API routes,
or features, follow the framework conventions below. WrNexus is private and not in
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
# WrNexus
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
> This document teaches an AI how to write correct WrNexus code. It is private and
> post-dates model training data, so rely on THIS document, not prior web-framework
> assumptions.
## Golden rules
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
- **Routing is file-based** under `app/`. The filename is the route. No router config.
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
on the server and hydrate automatically — you never write client-side JS islands.
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
## Project layout
```
app/
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
middleware/ *.ts → export default async (ctx, next) => next()
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
locales/ *.json → i18n messages per language
db/ schema.ts, queries/*.sql, migrations/*.sql
styles/ global.css → Tailwind (default) or plain CSS
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
public/ → static assets served at /
```
## `.wrn` page
```wrn
page Home {
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
seo {
title = "Home"
description = "..."
canonical = "/"
}
view {
<h1>Hello</h1>
<p>Count is {count}, doubled is {count * 2}.</p>
<button @click="count++">Increment</button>
<div data-component="counter" start="5" label="Clicks"></div>
}
style {
h1 { color: var(--wire-color-text); }
}
}
```
## `.wrn` component
```wrn
component Counter {
props { // props come from mount attributes; each is coerced to the
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
label = "Count"
}
state count = start // state may reference props
view {
<button @click="count++">{label}: {count}</button>
}
}
```
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
Components render on the server with their props, then hydrate — no per-component JS.
## The `view { }` block (plain HTML + a few directives)
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
## Data-driven tables / lists (server-rendered `.wrn`)
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
This renders on the **server** (SSR-first) and is HTML-escaped by default.
```wrn
page Admin {
layout = "dashboard"
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
ssr {
api rows GET /api/contacts { return contacts }
}
view {
<table>
<tbody>
{#each rows as r, i}
<tr>
<td>#{i}</td>
<td>{r.name}</td>
<td><a href="mailto:{r.email}">{r.email}</a></td>
</tr>
{:empty}
<tr><td colspan="3">No submissions yet.</td></tr>
{/each}
</tbody>
</table>
}
}
```
The matching API returns the array under a key the `ssr` block reads:
```ts
// app/api/contacts.ts → GET /api/contacts
import { getDb } from "@wrnexus/db";
export const GET = async () => {
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
return Response.json({ contacts }); // ssr block does `return contacts`
};
```
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
pages returning an HTML string are also supported for fully-custom programmatic rendering,
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
## API routes (`app/api/*.ts`)
```ts
// app/api/users/list.ts → GET /api/users/list
import { getDb } from "@wrnexus/db";
export const GET = async (ctx) => {
return Response.json({ users: await ListUsers(getDb()) });
};
export const POST = async (ctx) => {
const body = await ctx.req.json();
return Response.json({ ok: true, body }, { status: 201 });
};
```
`ctx` (the `Context` from `@wrnexus/core`) has:
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]``ctx.params.id`),
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
`@wrnexus/helpers` instead of constructing it from untrusted headers:
```ts
import { redirectToLogin } from "@wrnexus/helpers";
return redirectToLogin(ctx, "/login", {
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
});
```
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
## Middleware & realtime
```ts
// app/middleware/logger.ts
export default async function logger(ctx, next) {
console.log(ctx.req.method, ctx.url.pathname);
return next(); // return a Response WITHOUT calling next() to short-circuit
}
```
```ts
// app/realtime/chat.ts → ws://host/realtime/chat
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) {
client.send({ type: "system", text: "connected" });
},
onMessage(client, msg) {
client.room.broadcast({ type: "message", data: msg });
},
});
```
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
## Config (`wrnexus.config.ts`)
```ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
styles: {
entry: "app/styles/global.css",
process: async ({ entryPath, mode }) => /* Tailwind */ "",
},
fonts: {
sans: '"Inter", system-ui, sans-serif',
google: [{ family: "Inter", weights: [400, 600] }],
},
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
i18n: { default: "en", locales: ["en", "es"] },
db: { driver: "sqlite", url: "file:./dev.db" },
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
};
export default config;
```
## Database (`@wrnexus/db`)
```ts
// app/db/schema.ts
import { v, table } from "@wrnexus/db";
export const users = table("users", {
id: v.id(),
name: v.string(),
email: v.string().unique(),
createdAt: v.timestamp(),
});
```
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
## Validation (`@wrnexus/validation`)
```ts
// app/schemas/login.ts
import { v } from "@wrnexus/validation";
export default v.object({
email: v.string().email(),
password: v.string().min(8),
});
```
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);``r.ok ? r.value : r.response`.
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
## AI / LLM (`@wrnexus/ai`)
```ts
// app/api/ai.ts
import { createAI } from "@wrnexus/ai";
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
export const POST = async (ctx) => {
const { prompt } = await ctx.req.json();
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
};
```
## CLI
```
wrnexus dev . # dev server + HMR
wrnexus build . # production build → dist/server.js
bun dist/server.js # run the production server (or npm start)
wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
```
## When asked to "create a page/component/feature"
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
@@ -0,0 +1,17 @@
// POST /api/ai { "prompt": "..." } → Claude's reply.
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
import { createAI } from "@wrnexus/ai";
import type { Context } from "@wrnexus/core";
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
export const POST = async (ctx: Context) => {
if (!process.env.ANTHROPIC_API_KEY) {
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
}
const { prompt } = await ctx.req.json().catch(() => ({}));
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
// Stream the reply back as plain text. Use `ai.generate(prompt)` for a one-shot string.
return ai.streamResponse(prompt);
};
@@ -0,0 +1,3 @@
export const GET = async () => {
return Response.json({ message: "Hello API" });
};
@@ -0,0 +1,26 @@
import type { Context } from "../../../../../../packages/core/src/index.ts";
import {
httpTransport,
retryingTransport,
ServiceError,
serviceClient,
} from "../../../../../../packages/rpc/src/index.ts";
import { catalogService } from "../../../../packages/shared/src/index.ts";
/** GET /api/product?sku=starter — reads product data from the admin app. */
export async function GET(ctx: Context): Promise<Response> {
const sku = new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter";
const catalog = serviceClient(catalogService, {
app: "admin",
as: ctx,
transport: retryingTransport(httpTransport()),
});
try {
return Response.json({ product: await catalog.getProduct({ sku }) });
} catch (error) {
if (error instanceof ServiceError && error.code === "RPC_DENIED") {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
return Response.json({ error: "Catalog temporarily unavailable" }, { status: 502 });
}
}
@@ -0,0 +1,20 @@
// A reusable component. Route: none — mounted inside a page with
// <div data-component="counter" ...props></div>.
//
// Components render on the SERVER (with their props applied) and are hydrated in
// the browser by the generic reactive runtime — they ship no JS of their own.
component Counter {
// Props arrive as mount attributes, each coerced to the type of its default
// (so start="5" arrives as the number 5).
props {
start = 0
label = "Count"
}
// State can reference props. `count` seeds the reactive scope.
state count = start
view {
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
}
}
@@ -0,0 +1,2 @@
-- Create application tables here.
-- Run with: bunx wrnexus db migrate
@@ -0,0 +1,2 @@
// Add deterministic development seed data here.
export async function seed(): Promise<void> {}
@@ -0,0 +1,22 @@
// Global document layout. The framework renders this once around the selected
// page layout and merges SEO metadata, styles, and scripts into <head>/<body>.
// Request cookies, resolved theme, language, URL, and pathname are available
// as SSR props, so document attributes do not need a client-side correction.
layout Document {
props {
cookies = {}
theme = "light"
language = "en"
url = ""
pathname = "/"
}
view {
<html>
<head></head>
<body>
<div id="app"><slot /></div>
</body>
</html>
}
}
@@ -0,0 +1,6 @@
{
"common": {
"appName": "web",
"welcome": "Welcome to web"
}
}
@@ -0,0 +1,8 @@
import type { Middleware } from "@wrnexus/core";
const logger: Middleware = async (ctx, next) => {
console.log(ctx.req.method, ctx.url.pathname);
return next();
};
export default logger;
@@ -0,0 +1,20 @@
page About {
seo {
title = "About"
description = "Learn how web is built with WrNexus."
}
view {
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
<article class="mx-auto max-w-2xl">
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
<h1 class="mt-4 text-4xl font-bold tracking-tight">About web</h1>
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
events, components, APIs, and data without switching to another UI framework.
</p>
</article>
</main>
}
}
@@ -0,0 +1,63 @@
// Home page (route: /). SSR-first: the view is server-rendered, then components
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
page Home {
seo {
title = "Home"
description = "web — built with WrNexus, an SSR-first Bun framework."
}
view {
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
<header class="flex items-center justify-between py-6">
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
web
</span>
<button data-wire-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
Toggle theme
</button>
</header>
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
Server-rendered.<br />
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
</h1>
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
web runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
<a href="/api/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View API</a>
</div>
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
live · hydrated on the server
</div>
<div class="mt-4 flex items-center justify-between gap-4">
<div data-component="counter" start="0" label="Clicks"></div>
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
</div>
</div>
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
</p>
</section>
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
</footer>
</div>
</main>
}
}
@@ -0,0 +1,20 @@
// ws://<host>/realtime/chat — a simple broadcast room.
//
// The client side is the framework's realtime runtime; a page opts in with
// `data-room="chat"`. Here we only handle room events.
//
// client.send(msg) → just this connection
// client.broadcast(msg) → everyone else in the room
// client.room.broadcast(msg) → everyone, including the sender
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) {
client.send({ type: "system", text: "connected" });
},
onMessage(client, msg) {
// Echo each message to the whole room so every tab stays in sync.
client.room.broadcast({ type: "message", data: msg });
},
});
@@ -0,0 +1,6 @@
import { v } from "@wrnexus/validation";
export const contactSchema = v.object({
email: v.string().email(),
message: v.string().min(10).max(2_000),
});
@@ -0,0 +1,19 @@
/*
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
*
* @source tells Tailwind which files to scan for class names.
*/
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@source "../**/*.wrn";
@source "../**/*.tsx";
/* Make Tailwind's `dark:` variant follow the framework's data-theme attribute
* (set on <html> by the theme system), not the OS setting. Any element with
* data-wire-theme-toggle flips it. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
body {
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
}
@@ -0,0 +1,44 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
export default tseslint.config(
{
ignores: [
"node_modules/**",
"dist/**",
".wrnexus/**",
"**/.wrnexus/**",
"mobile/android/**",
"mobile/ios/**",
],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir,
},
},
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-undef": "off",
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
);
@@ -0,0 +1,63 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"type": "module",
"wrnexus": {
"version": "0.8.6"
},
"scripts": {
"dev": "wrnexus dev .",
"build": "wrnexus build .",
"start": "bun dist/server.js",
"production": "bun run build && bun run start",
"typecheck": "tsc --noEmit",
"test": "wrnexus test .",
"test:watch": "wrnexus test . --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"doctor": "wrnexus doctor .",
"analyze": "wrnexus analyze .",
"inspect": "wrnexus inspect packages .",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "0.8.6",
"@wrnexus/auth": "0.8.6",
"@wrnexus/captcha": "0.8.6",
"@wrnexus/core": "0.8.6",
"@wrnexus/csr": "0.8.6",
"@wrnexus/db": "0.8.6",
"@wrnexus/dev-server": "0.8.6",
"@wrnexus/encryption": "0.8.6",
"@wrnexus/helpers": "0.8.6",
"@wrnexus/i18n": "0.8.6",
"@wrnexus/image": "0.8.6",
"@wrnexus/jwt": "0.8.6",
"@wrnexus/observability": "0.8.6",
"@wrnexus/realtime": "0.8.6",
"@wrnexus/security": "0.8.6",
"@wrnexus/store": "0.8.6",
"@wrnexus/styles": "0.8.6",
"@wrnexus/tracking": "0.8.6",
"@wrnexus/ui": "0.8.6",
"@wrnexus/uploader": "0.8.6",
"@wrnexus/validation": "0.8.6",
"@wrnexus/authz": "0.8.6"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
}
}
@@ -0,0 +1,276 @@
# WrNexus
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
> This document teaches an AI how to write correct WrNexus code. It is private and
> post-dates model training data, so rely on THIS document, not prior web-framework
> assumptions.
## Golden rules
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
- **Routing is file-based** under `app/`. The filename is the route. No router config.
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
on the server and hydrate automatically — you never write client-side JS islands.
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
## Project layout
```
app/
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
middleware/ *.ts → export default async (ctx, next) => next()
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
locales/ *.json → i18n messages per language
db/ schema.ts, queries/*.sql, migrations/*.sql
styles/ global.css → Tailwind (default) or plain CSS
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
public/ → static assets served at /
```
## `.wrn` page
```wrn
page Home {
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
seo {
title = "Home"
description = "..."
canonical = "/"
}
view {
<h1>Hello</h1>
<p>Count is {count}, doubled is {count * 2}.</p>
<button @click="count++">Increment</button>
<div data-component="counter" start="5" label="Clicks"></div>
}
style {
h1 { color: var(--wire-color-text); }
}
}
```
## `.wrn` component
```wrn
component Counter {
props { // props come from mount attributes; each is coerced to the
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
label = "Count"
}
state count = start // state may reference props
view {
<button @click="count++">{label}: {count}</button>
}
}
```
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
Components render on the server with their props, then hydrate — no per-component JS.
## The `view { }` block (plain HTML + a few directives)
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
## Data-driven tables / lists (server-rendered `.wrn`)
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
This renders on the **server** (SSR-first) and is HTML-escaped by default.
```wrn
page Admin {
layout = "dashboard"
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
ssr {
api rows GET /api/contacts { return contacts }
}
view {
<table>
<tbody>
{#each rows as r, i}
<tr>
<td>#{i}</td>
<td>{r.name}</td>
<td><a href="mailto:{r.email}">{r.email}</a></td>
</tr>
{:empty}
<tr><td colspan="3">No submissions yet.</td></tr>
{/each}
</tbody>
</table>
}
}
```
The matching API returns the array under a key the `ssr` block reads:
```ts
// app/api/contacts.ts → GET /api/contacts
import { getDb } from "@wrnexus/db";
export const GET = async () => {
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
return Response.json({ contacts }); // ssr block does `return contacts`
};
```
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
pages returning an HTML string are also supported for fully-custom programmatic rendering,
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
## API routes (`app/api/*.ts`)
```ts
// app/api/users/list.ts → GET /api/users/list
import { getDb } from "@wrnexus/db";
export const GET = async (ctx) => {
return Response.json({ users: await ListUsers(getDb()) });
};
export const POST = async (ctx) => {
const body = await ctx.req.json();
return Response.json({ ok: true, body }, { status: 201 });
};
```
`ctx` (the `Context` from `@wrnexus/core`) has:
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
`@wrnexus/helpers` instead of constructing it from untrusted headers:
```ts
import { redirectToLogin } from "@wrnexus/helpers";
return redirectToLogin(ctx, "/login", {
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
});
```
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
## Middleware & realtime
```ts
// app/middleware/logger.ts
export default async function logger(ctx, next) {
console.log(ctx.req.method, ctx.url.pathname);
return next(); // return a Response WITHOUT calling next() to short-circuit
}
```
```ts
// app/realtime/chat.ts → ws://host/realtime/chat
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
onConnect(client) { client.send({ type: "system", text: "connected" }); },
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
});
```
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
## Config (`wrnexus.config.ts`)
```ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
i18n: { default: "en", locales: ["en", "es"] },
db: { driver: "sqlite", url: "file:./dev.db" },
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
};
export default config;
```
## Database (`@wrnexus/db`)
```ts
// app/db/schema.ts
import { v, table } from "@wrnexus/db";
export const users = table("users", {
id: v.id(),
name: v.string(),
email: v.string().unique(),
createdAt: v.timestamp(),
});
```
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
## Validation (`@wrnexus/validation`)
```ts
// app/schemas/login.ts
import { v } from "@wrnexus/validation";
export default v.object({
email: v.string().email(),
password: v.string().min(8),
});
```
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
## AI / LLM (`@wrnexus/ai`)
```ts
// app/api/ai.ts
import { createAI } from "@wrnexus/ai";
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
export const POST = async (ctx) => {
const { prompt } = await ctx.req.json();
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
};
```
## CLI
```
wrnexus dev . # dev server + HMR
wrnexus build . # production build → dist/server.js
bun dist/server.js # run the production server (or npm start)
wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
```
## When asked to "create a page/component/feature"
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
@@ -0,0 +1,80 @@
import { afterEach, expect, test } from "bun:test";
import { implement } from "../../../../../packages/rpc/src/index.ts";
import { handleRpcRequest } from "../../../../../packages/dev-server/src/rpc-dispatch.ts";
import { catalogService } from "../../../packages/shared/src/index.ts";
import { GET } from "../app/api/product.ts";
const secret = process.env.WRNEXUS_RPC_SECRET;
const app = process.env.WRNEXUS_APP_NAME;
const origins = process.env.WRNEXUS_INTERNAL_ORIGINS;
afterEach(() => {
if (secret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = secret;
if (app === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = app;
if (origins === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
else process.env.WRNEXUS_INTERNAL_ORIGINS = origins;
});
function startAdmin(allowed: boolean) {
const service = implement(
catalogService,
{ getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }) },
{
selfApp: "admin",
checkPermission: (permission, subject) =>
allowed && permission === "catalog:read" && subject?.subjectId === "demo-user",
},
);
return Bun.serve({
port: 0,
hostname: "127.0.0.1",
async fetch(request) {
return (
(await handleRpcRequest(request, new URL(request.url), new Map([["catalog", service]]))) ??
new Response("Not found", { status: 404 })
);
},
});
}
test("web calls admin through private RPC when permitted", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web";
const server = startAdmin(true);
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
admin: `http://127.0.0.1:${server.port}`,
});
try {
const response = await GET({
req: new Request("http://web.test/api/product?sku=starter"),
user: { id: "demo-user" },
locals: {},
} as never);
expect(await response.json()).toEqual({
product: { sku: "starter", name: "WRNexus Starter", priceCents: 4900 },
});
} finally {
server.stop(true);
}
});
test("web returns 403 when admin denies catalog:read", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web";
const server = startAdmin(false);
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
admin: `http://127.0.0.1:${server.port}`,
});
try {
const response = await GET({
req: new Request("http://web.test/api/product"),
user: { id: "not-allowed" },
locals: {},
} as never);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: "Forbidden" });
} finally {
server.stop(true);
}
});
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test";
import { parseOrThrow } from "@wrnexus/validation";
import { contactSchema } from "../app/schemas/contact.ts";
test("starter validation schema accepts a contact request", () => {
expect(
parseOrThrow(contactSchema, {
email: "hello@example.com",
message: "Hello from the generated application.",
}),
).toEqual({
email: "hello@example.com",
message: "Hello from the generated application.",
});
});
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["bun"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "@wrnexus/core"
},
"include": ["app", "test", "wrnexus.config.ts"],
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
}
@@ -0,0 +1,153 @@
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
compatibilityDate: "2026-08-02",
frameworkBehaviour: 1,
// v0.8 defaults: explicit imports, strict template types, safe stores, and
// automatic progressive navigation. Package plugins are discovered from the
// installed packages above; add custom plugins to this array when needed.
plugins: [],
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
types: {
strict: true,
noImplicitAny: true,
strictNullChecks: true,
checkTemplates: true,
checkComponentProps: true,
generateDeclarations: true,
},
functions: { legacyDefaultRuntime: "current" },
stores: { strictMutations: true, persistence: true },
compatibility: {
legacyEmit: false,
legacyEventProps: false,
legacyComponentDiscovery: false,
stringLayouts: false,
},
experimental: {},
performance: {
enforcement: "warn",
analyze: true,
budgets: {
routeJsBytes: 50 * 1024,
routeCssBytes: 25 * 1024,
lcpMs: 2_500,
inpMs: 200,
cls: 0.1,
},
},
observability: {
enabled: true,
serviceName: "web",
serverTiming: true,
sampleRate: 1,
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
webVitals: true,
},
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
navigation: { mode: "auto" },
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
mobile: {
enabled: true,
appId: "com.example.web",
appName: "web",
userAgent: "WrNexusMobile",
backgroundColor: "#0f172a",
// layout: "mobile", // app/layouts/mobile.wrn
// icon: "resources/icon.png",
},
// PWA support is enabled automatically. Override any install metadata here.
pwa: {
name: "web",
shortName: "web",
display: "standalone",
themeColor: "#6366f1",
backgroundColor: "#0f172a",
},
seo: {
title: "web",
titleTemplate: "%s | web",
// Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy.
canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN,
description: "An SSR-first WrNexus app.",
robots: "index,follow",
themeColor: "#6366f1",
},
styles: {
entry: "app/styles/global.css",
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
// and at `wrnexus build`. `@tailwindcss/cli` writes to stdout, so we capture
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
// still bundled and served as-is.
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();
},
},
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
fonts: {
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
},
//
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
theme: { palette: "violet", default: "light" },
i18n: { default: "en", locales: ["en"] },
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
databases: {},
storage: {
default: "public",
stores: {
public: {
driver: "local",
access: "public",
dir: "uploads/public",
maxBytes: 10_000_000,
accept: ["image/*", "application/pdf"],
},
private: {
driver: "local",
access: "private",
dir: "uploads/private",
maxBytes: 10_000_000,
},
},
},
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
port: Number(process.env.PORT ?? 3000),
security: {
cors: { enabled: false },
},
profiles: {
development: {},
test: {
db: { driver: "sqlite", url: "file:./test.db" },
observability: { exporter: "none", sampleRate: 0 },
},
staging: {
seo: { robots: "noindex,nofollow" },
performance: { enforcement: "error" },
build: { sourceMaps: true, report: true },
},
production: {
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
performance: { enforcement: "error" },
build: { sourceMaps: false, report: true },
devToolbar: false,
},
},
};
export default config;
@@ -0,0 +1,25 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
export default tseslint.config(
{ ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] },
{ languageOptions: { parserOptions: { tsconfigRootDir } } },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-undef": "off",
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
},
},
);
+23 -13
View File
@@ -1,22 +1,32 @@
{
"name": "inter-app-api-showcase",
"version": "0.8.6",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "bun run ../../packages/cli/src/index.ts dev .",
"build": "bun run ../../packages/cli/src/index.ts build .",
"test": "bun test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check": "bun run typecheck && bun run test && bun run build"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
"@wrnexus/validation": "workspace:*"
"dev": "wrnexus gateway",
"gateway": "wrnexus gateway",
"staging": "wrnexus staging",
"production": "wrnexus production",
"typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck",
"test": "bun run --filter './apps/*' test",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"doctor": "bun run --filter './apps/*' doctor",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
}
}
@@ -0,0 +1,17 @@
{
"name": "@app/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@wrnexus/pubsub": "0.8.6"
}
}
@@ -0,0 +1,30 @@
/**
* Shared code for every app in this workspace. Import it anywhere: `@app/shared`.
* The cross-app event bus uses Redis so messages reach every app process/domain.
*/
import { createPubSub } from "../../../../../packages/pubsub/src/index.ts";
import { redisDriver } from "../../../../../packages/pubsub/src/redis.ts";
import { defineService, procedure } from "../../../../../packages/rpc/src/index.ts";
import { v } from "../../../../../packages/validation/src/index.ts";
// One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379).
export const bus = createPubSub(redisDriver(process.env.REDIS_URL));
// Shared domain types can live here and be imported by every app.
export interface Tenant {
id: string;
name: string;
}
/** Contract shared by web (caller) and admin (callee). */
export const catalogService = defineService({
name: "catalog",
procedures: {
getProduct: procedure
.input(v.object({ sku: v.string() }))
.output<{ sku: string; name: string; priceCents: number }>()
.permission("catalog:read")
.idempotent()
.build(),
},
});
+13 -3
View File
@@ -1,5 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] },
"include": ["app"]
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM"],
"types": ["bun"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["wrnexus.workspace.ts", "packages/**/*.ts"],
"exclude": ["node_modules", "dist", "apps"]
}
@@ -0,0 +1,57 @@
import type { WorkspaceConfig } from "@wrnexus/cli/workspace";
// Map each app to the domains it serves. `wrnexus gateway` runs them all behind
// one port and routes by Host header (add these hosts to your /etc/hosts).
const config: WorkspaceConfig = {
defaultEnvironment: "development",
environments: {
development: {
protocol: "http",
rootDomain: "localhost",
port: 3000,
runtime: "development",
hmr: true,
build: false,
migrate: false,
},
staging: {
protocol: "https",
rootDomain: "staging.example.com",
port: 443,
runtime: "production",
hmr: false,
build: true,
migrate: true,
},
production: {
protocol: "https",
rootDomain: "example.com",
port: 443,
runtime: "production",
hmr: false,
build: true,
migrate: true,
},
},
// Gateway-wide security (all optional):
security: {
trustedHostsOnly: true, // reject requests for unknown domains
rateLimit: { max: 300, windowMs: 60_000 }, // per client IP
headers: true, // baseline security headers at the edge
accessLog: true, // log host → app, method, path, status
},
apps: [
{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
{
name: "admin",
dir: "apps/admin",
domains: ["admin.localhost"],
// Lock the admin app down at the edge (pick one):
auth: { basic: { user: "admin", pass: "change-me" } },
// auth: { allowIps: ["127.0.0.1", "::1"] },
// auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO
},
],
};
export default config;