fix: repair main after an unreviewed commit, and record the cause
Three separate problems, all traceable to `git add -A` sweeping up a working
tree I had not inspected.
Commit 69020b25 ("docs: make the component sections executable") committed far
more than docs: 79 files of a half-scaffolded inter-app example, and four of
those files were truncated mid-statement. That broke `bun run typecheck` on
main. The example is reverted to its last green six-file form. The truncated
fragments and the fuller working copy are NOT in this commit -- if any of that
workspace was wanted, it needs to be reconstructed deliberately and committed on
its own, not as a side effect of a docs change.
Separately, `scripts/generate-ui-complete-catalog.mjs` was run while checking
which helper scripts still work. It rewrites components in place, so it
flattened six of them to stubs, deleted 24 more and lower-cased four filenames
before crashing. Contents were restored from HEAD, but the renames survived
that restore: Windows is case-insensitive, so `git status` reported clean while
Card, Container, Divider and Grid sat on disk under the wrong names. The index
now tracks the capitalised names, which is what the components declare and what
ui-redesign-contract.test.ts reads -- that test would have failed on any
case-sensitive checkout.
Documented both as 4.7 and 4.8 in the remediation plan, with the general rule:
no script that rewrites packages/ui/components/ may write in place. Also fixes
the heading level on 4.6, which was rendering outside section 4.
bun run check is green: 1,433 pass, 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -581,7 +581,7 @@ confusing ways.
|
||||
|
||||
---
|
||||
|
||||
## 4.6 Runtime and client-module size — measured
|
||||
### 4.6 Runtime and client-module size — measured
|
||||
|
||||
A per-subsystem measurement of `reactive.js` and of the generated client
|
||||
modules, made by minifying the runtime repeatedly with one subsystem removed
|
||||
@@ -593,7 +593,7 @@ the real number.
|
||||
"about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped".
|
||||
Both figures were wrong, and the conclusion that followed from them was wrong.
|
||||
|
||||
### The runtime today
|
||||
#### The runtime today
|
||||
|
||||
`reactive.js` is **70,101 bytes minified, 21,736 gzipped**. Removing each
|
||||
subsystem and re-minifying gives its true cost:
|
||||
@@ -619,7 +619,7 @@ fetch, which are framework features) total **23,722 minified / 6,660 gzipped —
|
||||
15,076 gzipped: the expression engine, the scope and reactivity core, and loop
|
||||
diffing.
|
||||
|
||||
### How much of it a page actually uses
|
||||
#### How much of it a page actually uses
|
||||
|
||||
Measured against the example app by checking which controller markers appear in
|
||||
the served HTML:
|
||||
@@ -650,7 +650,7 @@ bun run scripts/lib/measure-runtime-size.ts # core must stay under budget
|
||||
|
||||
Plus a browser check on `/`: zero controller chunks requested.
|
||||
|
||||
### The bigger problem: generated client modules
|
||||
#### The bigger problem: generated client modules
|
||||
|
||||
The runtime is not where the weight is. On `/navigation`:
|
||||
|
||||
@@ -723,6 +723,80 @@ is duplicating itself and should fail the check. Existing behaviour is covered
|
||||
by the current suite, so correctness is the 1,427 tests; this is purely a size
|
||||
assertion on top.
|
||||
|
||||
### 4.7 `generate-ui-complete-catalog.mjs` is broken and destructive
|
||||
|
||||
**Issue.** The script fails partway through with
|
||||
`TypeError: factories[entry.category] is not a function`
|
||||
(`scripts/generate-ui-complete-catalog.mjs:156`) — but not before it has already
|
||||
started writing. It **overwrites real components with bare scaffolds and deletes
|
||||
others**, then crashes, leaving the library in a wrecked state.
|
||||
|
||||
**Evidence.** Running it on 2026-08-09 rewrote Accordion, alert, Badge,
|
||||
AvatarGroup, ToggleCount and LayoutSplitter down to ~15-line stubs, deleted 24
|
||||
component files, and renamed `Card`, `Container`, `Divider` and `Grid` to
|
||||
lowercase — 113 files changed in total. Nothing in `package.json` references it,
|
||||
so no gate runs it and no gate would have caught the damage.
|
||||
|
||||
**Why it is worse than it looks.** The rename is the dangerous part. Windows is
|
||||
case-insensitive, so after `git checkout -- .` the tree reported **clean** while
|
||||
four components were still misnamed on disk. Only a test failure exposed it. On
|
||||
a case-sensitive filesystem the same script produces duplicate files instead.
|
||||
|
||||
**Change.** Pick one:
|
||||
|
||||
- **Delete it.** `generate-ui-component-reference.mjs` is the maintained
|
||||
generator, it is wired into `release:prepare`, and it works. If this script is
|
||||
redundant, it is a loaded gun in the repo for no benefit.
|
||||
- **Or fix and gate it**: make it write to a temp directory and swap atomically
|
||||
only on success, so a mid-run crash cannot leave a partial library. Then add
|
||||
it to a check so it cannot rot again.
|
||||
|
||||
Whichever is chosen, **no script that rewrites `packages/ui/components/` should
|
||||
write in place.** Generate to a staging directory, validate, then move.
|
||||
|
||||
**Related, and worth doing regardless:** several other ungated scripts mutate
|
||||
the repository or start servers when run — `install-captcha.mjs`, the
|
||||
`validate-*.mjs` and `verify-*.mjs` families, and `benchmark-framework.mjs`. All
|
||||
of them fail today. They should either be repaired and gated, or removed. A
|
||||
`scripts/` directory where running a file at random can scaffold apps, start
|
||||
dev servers on ports 3000-3002 and rewrite the component library is a hazard to
|
||||
anyone exploring the repo, human or otherwise.
|
||||
|
||||
**How to test.** After fixing or deleting:
|
||||
|
||||
```bash
|
||||
git status --porcelain # must be empty after running any generator twice
|
||||
```
|
||||
|
||||
Add a check that runs each generator in `--check` mode and fails if it would
|
||||
modify tracked files, the way `check:workspace` and `check:public-api` already
|
||||
do.
|
||||
|
||||
### 4.8 Filename casing is not consistent with the git index
|
||||
|
||||
**Issue.** Four components were tracked in git under lowercase names
|
||||
(`card.wrn`, `container.wrn`, `divider.wrn`, `grid.wrn`) while existing on disk
|
||||
under capitalised ones. Windows hid the discrepancy; `git status` reported clean.
|
||||
|
||||
**Why it matters.** `ui-redesign-contract.test.ts` reads the real directory and
|
||||
expects `Card.wrn`. **On a fresh clone on Linux or in CI the files arrive
|
||||
lowercase and that test fails** — a latent break that could not reproduce on a
|
||||
Windows workstation.
|
||||
|
||||
**Change.** Done — the index now tracks the capitalised names, matching the
|
||||
component each file declares (`component Card`, `component Container`, and so
|
||||
on) and matching every other component in the library.
|
||||
|
||||
**How to test.**
|
||||
|
||||
```bash
|
||||
git ls-files packages/ui/components/ | grep -iE '/(card|container|divider|grid)\.wrn'
|
||||
```
|
||||
|
||||
must return the capitalised names. Better, set `git config core.ignorecase
|
||||
false` locally so a future rename cannot hide again, and consider a check that
|
||||
compares `git ls-files` against the on-disk listing byte for byte.
|
||||
|
||||
## 5. Order of work
|
||||
|
||||
Ranked by return, not by size. The first item changes the cost of every item
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,2 +0,0 @@
|
||||
REDIS_URL=redis://localhost:6379
|
||||
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
**/CLAUDE.md
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -1,44 +1,19 @@
|
||||
# inter-app-api-showcase
|
||||
# Inter-app + external API showcase
|
||||
|
||||
A WrNexus **workspace** — multiple apps, one gateway, interconnected.
|
||||
`GET /api/product-summary?sku=starter` demonstrates one request handler making:
|
||||
|
||||
```
|
||||
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
|
||||
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`).
|
||||
|
||||
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.
|
||||
|
||||
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"}
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
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`.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,3 +0,0 @@
|
||||
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL=file:./test.db
|
||||
AUTH_SECRET=test-only-secret-replace-outside-tests
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
CLAUDE.md
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const GET = async () => {
|
||||
return Response.json({ message: "Hello API" });
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Create application tables here.
|
||||
-- Run with: bunx wrnexus db migrate
|
||||
@@ -1,2 +0,0 @@
|
||||
// Add deterministic development seed data here.
|
||||
export async function seed(): Promise<void> {}
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "admin",
|
||||
"welcome": "Welcome to admin"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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;
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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 });
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export const contactSchema = v.object({
|
||||
email: v.string().email(),
|
||||
message: v.string().min(10).max(2_000),
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
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 Sta
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"@wrnexus/rpc": "file:../../../../packages/rpc",
|
||||
"@app/shared": "workspace:*"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -1,15 +0,0 @@
|
||||
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.",
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,3 +0,0 @@
|
||||
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL=file:./test.db
|
||||
AUTH_SECRET=test-only-secret-replace-outside-tests
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
CLAUDE.md
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const GET = async () => {
|
||||
return Response.json({ message: "Hello API" });
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Context } from "../../../../../../packages/core/src/index.ts";
|
||||
import {
|
||||
httpTransport,
|
||||
retryingTransport,
|
||||
serviceClient,
|
||||
} from "../../../../../../packages/rpc/src/index.ts";
|
||||
import { catalogService } from "../../../../packages/shared/src/index.ts";
|
||||
|
||||
/** GET /api/product?sku=starter — obtains 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(
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Create application tables here.
|
||||
-- Run with: bunx wrnexus db migrate
|
||||
@@ -1,2 +0,0 @@
|
||||
// Add deterministic development seed data here.
|
||||
export async function seed(): Promise<void> {}
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "web",
|
||||
"welcome": "Welcome to web"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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;
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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 });
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export const contactSchema = v.object({
|
||||
email: v.string().email(),
|
||||
message: v.string().min(10).max(2_000),
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"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": "file:../../../../packages/core",
|
||||
"@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": "file:../../../../packages/validation",
|
||||
"@wrnexus/authz": "0.8.6",
|
||||
"@wrnexus/rpc": "file:../../../../packages/rpc",
|
||||
"@app/shared": "workspace:*"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
});
|
||||
|
||||
test("web calls the generated admin app over private RPC", async () => {
|
||||
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
|
||||
process.env.WRNEXUS_APP_NAME = "web";
|
||||
const service = implement(
|
||||
catalogService,
|
||||
{ getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }) },
|
||||
{ selfApp: "admin" },
|
||||
);
|
||||
const server = 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 })
|
||||
);
|
||||
},
|
||||
});
|
||||
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: "u1" },
|
||||
locals: {},
|
||||
} as never);
|
||||
expect(await response.json()).toEqual({
|
||||
product: { sku: "starter", name: "WRNexus Starter", priceCents: 4900 },
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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.",
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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;
|
||||
@@ -1,25 +0,0 @@
|
||||
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: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,32 +1,22 @@
|
||||
{
|
||||
"name": "inter-app-api-showcase",
|
||||
"version": "0.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"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"
|
||||
"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:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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": "file:../../../../packages/pubsub",
|
||||
"@wrnexus/rpc": "file:../../../../packages/rpc",
|
||||
"@wrnexus/validation": "file:../../../../packages/validation"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Typed contract imported by both generated workspace apps. */
|
||||
export const catalogService = defineService({
|
||||
name: "catalog",
|
||||
procedures: {
|
||||
getProduct: procedure
|
||||
.input(v.object({ sku: v.string() }))
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
{
|
||||
"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"]
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] },
|
||||
"include": ["app"]
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user