From 247f360ae7700077265b07b38522e93f570b801f Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sun, 9 Aug 2026 11:18:45 +0530 Subject: [PATCH] docs: rank the destructive generator as item 0 It was written up in 4.7 but never made the work order, which is exactly how it stayed dangerous in the first place. Co-Authored-By: Claude Opus 5 --- docs/framework-remediation-plan.md | 25 +- examples/inter-app-api-showcase/.editorconfig | 9 + examples/inter-app-api-showcase/.env.example | 2 + examples/inter-app-api-showcase/.gitignore | 25 ++ .../inter-app-api-showcase/.prettierignore | 6 + .../inter-app-api-showcase/.prettierrc.json | 9 + .../.vscode/extensions.json | 3 + .../.vscode/settings.json | 7 + examples/inter-app-api-showcase/README.md | 63 +++- .../app/api/product-summary.ts | 60 ---- .../app/example.test.ts | 11 - .../app/lib/contracts.ts | 34 -- .../apps/admin/.editorconfig | 9 + .../apps/admin/.env.example | 8 + .../apps/admin/.env.test.example | 3 + .../apps/admin/.gitignore | 48 +++ .../apps/admin/.prettierignore | 6 + .../apps/admin/.prettierrc.json | 9 + .../apps/admin/.vscode/extensions.json | 3 + .../apps/admin/.vscode/settings.json | 12 + .../apps/admin/CLAUDE.md | 295 ++++++++++++++++++ .../apps/admin/app/api/ai.ts | 17 + .../apps/admin/app/api/hello.ts | 3 + .../apps/admin/app/components/counter.wrn | 20 ++ .../admin/app/db/migrations/0001_init.sql | 2 + .../apps/admin/app/db/seed.ts | 2 + .../apps/admin/app/layouts/document.wrn | 22 ++ .../apps/admin/app/locales/en.json | 6 + .../apps/admin/app/middleware/logger.ts | 8 + .../apps/admin/app/pages/about.wrn | 20 ++ .../apps/admin/app/pages/index.wrn | 63 ++++ .../apps/admin/app/realtime/chat.ts | 20 ++ .../apps/admin/app/schemas/contact.ts | 6 + .../apps/admin/app/services/catalog.ts | 16 + .../apps/admin/app/styles/global.css | 19 ++ .../apps/admin/eslint.config.js | 44 +++ .../apps/admin/package.json | 63 ++++ .../apps/admin/public/llms.txt | 276 ++++++++++++++++ .../apps/admin/public/robots.txt | 2 + .../apps/admin/test/smoke.test.ts | 15 + .../apps/admin/tsconfig.json | 20 ++ .../apps/admin/wrnexus.config.ts | 153 +++++++++ .../apps/web/.editorconfig | 9 + .../apps/web/.env.example | 8 + .../apps/web/.env.test.example | 3 + .../apps/web/.gitignore | 48 +++ .../apps/web/.prettierignore | 6 + .../apps/web/.prettierrc.json | 9 + .../apps/web/.vscode/extensions.json | 3 + .../apps/web/.vscode/settings.json | 12 + .../inter-app-api-showcase/apps/web/CLAUDE.md | 295 ++++++++++++++++++ .../apps/web/app/api/ai.ts | 17 + .../apps/web/app/api/hello.ts | 3 + .../apps/web/app/api/product.ts | 26 ++ .../apps/web/app/components/counter.wrn | 20 ++ .../apps/web/app/db/migrations/0001_init.sql | 2 + .../apps/web/app/db/seed.ts | 2 + .../apps/web/app/layouts/document.wrn | 22 ++ .../apps/web/app/locales/en.json | 6 + .../apps/web/app/middleware/logger.ts | 8 + .../apps/web/app/pages/about.wrn | 20 ++ .../apps/web/app/pages/index.wrn | 63 ++++ .../apps/web/app/realtime/chat.ts | 20 ++ .../apps/web/app/schemas/contact.ts | 6 + .../apps/web/app/styles/global.css | 19 ++ .../apps/web/eslint.config.js | 44 +++ .../apps/web/package.json | 63 ++++ .../apps/web/public/llms.txt | 276 ++++++++++++++++ .../apps/web/public/robots.txt | 2 + .../apps/web/test/inter-app.test.ts | 80 +++++ .../apps/web/test/smoke.test.ts | 15 + .../apps/web/tsconfig.json | 20 ++ .../apps/web/wrnexus.config.ts | 153 +++++++++ .../inter-app-api-showcase/eslint.config.js | 25 ++ examples/inter-app-api-showcase/package.json | 36 ++- .../packages/shared/package.json | 17 + .../packages/shared/src/index.ts | 30 ++ examples/inter-app-api-showcase/tsconfig.json | 16 +- .../wrnexus.workspace.ts | 57 ++++ 79 files changed, 2759 insertions(+), 146 deletions(-) create mode 100644 examples/inter-app-api-showcase/.editorconfig create mode 100644 examples/inter-app-api-showcase/.env.example create mode 100644 examples/inter-app-api-showcase/.gitignore create mode 100644 examples/inter-app-api-showcase/.prettierignore create mode 100644 examples/inter-app-api-showcase/.prettierrc.json create mode 100644 examples/inter-app-api-showcase/.vscode/extensions.json create mode 100644 examples/inter-app-api-showcase/.vscode/settings.json delete mode 100644 examples/inter-app-api-showcase/app/api/product-summary.ts delete mode 100644 examples/inter-app-api-showcase/app/example.test.ts delete mode 100644 examples/inter-app-api-showcase/app/lib/contracts.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/.editorconfig create mode 100644 examples/inter-app-api-showcase/apps/admin/.env.example create mode 100644 examples/inter-app-api-showcase/apps/admin/.env.test.example create mode 100644 examples/inter-app-api-showcase/apps/admin/.gitignore create mode 100644 examples/inter-app-api-showcase/apps/admin/.prettierignore create mode 100644 examples/inter-app-api-showcase/apps/admin/.prettierrc.json create mode 100644 examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json create mode 100644 examples/inter-app-api-showcase/apps/admin/.vscode/settings.json create mode 100644 examples/inter-app-api-showcase/apps/admin/CLAUDE.md create mode 100644 examples/inter-app-api-showcase/apps/admin/app/api/ai.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/api/hello.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql create mode 100644 examples/inter-app-api-showcase/apps/admin/app/db/seed.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/locales/en.json create mode 100644 examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/styles/global.css create mode 100644 examples/inter-app-api-showcase/apps/admin/eslint.config.js create mode 100644 examples/inter-app-api-showcase/apps/admin/package.json create mode 100644 examples/inter-app-api-showcase/apps/admin/public/llms.txt create mode 100644 examples/inter-app-api-showcase/apps/admin/public/robots.txt create mode 100644 examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/tsconfig.json create mode 100644 examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts create mode 100644 examples/inter-app-api-showcase/apps/web/.editorconfig create mode 100644 examples/inter-app-api-showcase/apps/web/.env.example create mode 100644 examples/inter-app-api-showcase/apps/web/.env.test.example create mode 100644 examples/inter-app-api-showcase/apps/web/.gitignore create mode 100644 examples/inter-app-api-showcase/apps/web/.prettierignore create mode 100644 examples/inter-app-api-showcase/apps/web/.prettierrc.json create mode 100644 examples/inter-app-api-showcase/apps/web/.vscode/extensions.json create mode 100644 examples/inter-app-api-showcase/apps/web/.vscode/settings.json create mode 100644 examples/inter-app-api-showcase/apps/web/CLAUDE.md create mode 100644 examples/inter-app-api-showcase/apps/web/app/api/ai.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/api/hello.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/api/product.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/components/counter.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql create mode 100644 examples/inter-app-api-showcase/apps/web/app/db/seed.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/locales/en.json create mode 100644 examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/pages/about.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/pages/index.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/styles/global.css create mode 100644 examples/inter-app-api-showcase/apps/web/eslint.config.js create mode 100644 examples/inter-app-api-showcase/apps/web/package.json create mode 100644 examples/inter-app-api-showcase/apps/web/public/llms.txt create mode 100644 examples/inter-app-api-showcase/apps/web/public/robots.txt create mode 100644 examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts create mode 100644 examples/inter-app-api-showcase/apps/web/test/smoke.test.ts create mode 100644 examples/inter-app-api-showcase/apps/web/tsconfig.json create mode 100644 examples/inter-app-api-showcase/apps/web/wrnexus.config.ts create mode 100644 examples/inter-app-api-showcase/eslint.config.js create mode 100644 examples/inter-app-api-showcase/packages/shared/package.json create mode 100644 examples/inter-app-api-showcase/packages/shared/src/index.ts create mode 100644 examples/inter-app-api-showcase/wrnexus.workspace.ts diff --git a/docs/framework-remediation-plan.md b/docs/framework-remediation-plan.md index 5eee289c..85f153f2 100644 --- a/docs/framework-remediation-plan.md +++ b/docs/framework-remediation-plan.md @@ -802,18 +802,19 @@ compares `git ls-files` against the on-disk listing byte for byte. Ranked by return, not by size. The first item changes the cost of every item below it, which is why it is first. -| # | Item | Why now | -| --- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. | -| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. | -| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. | -| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. | -| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. | -| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. | -| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. | -| 8 | §3.1 Group A: supersede the 5 duplicate scaffolds | 15 of the 22 dead outputs, and 5 fewer components to maintain. Needs a migration entry, not new code. | -| 9 | §3.2 scaffolds that need only their styles | Larger and cheaper than it looks; folds into item 6. | -| 10 | §3.1 Group B: build Chart, TreeView, Confetti, CopyMarkup | Real component work. Chart needs a rendering decision first. | +| # | Item | Why now | +| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | §4.7 neutralise the destructive generator | Safety, not improvement. Running one file in `scripts/` at random can shred the component library. Do this before anyone else touches the repo. | +| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. | +| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. | +| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. | +| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. | +| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. | +| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. | +| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. | +| 8 | §3.1 Group A: supersede the 5 duplicate scaffolds | 15 of the 22 dead outputs, and 5 fewer components to maintain. Needs a migration entry, not new code. | +| 9 | §3.2 scaffolds that need only their styles | Larger and cheaper than it looks; folds into item 6. | +| 10 | §3.1 Group B: build Chart, TreeView, Confetti, CopyMarkup | Real component work. Chart needs a rendering decision first. | §2.2, §2.4 and §2.5 fold into item 1 as diagnostics first, then into item 2 as model work. diff --git a/examples/inter-app-api-showcase/.editorconfig b/examples/inter-app-api-showcase/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/examples/inter-app-api-showcase/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/examples/inter-app-api-showcase/.env.example b/examples/inter-app-api-showcase/.env.example new file mode 100644 index 00000000..a5c8eed9 --- /dev/null +++ b/examples/inter-app-api-showcase/.env.example @@ -0,0 +1,2 @@ +REDIS_URL=redis://localhost:6379 +AUTH_SECRET=replace-with-at-least-32-random-characters diff --git a/examples/inter-app-api-showcase/.gitignore b/examples/inter-app-api-showcase/.gitignore new file mode 100644 index 00000000..82122e42 --- /dev/null +++ b/examples/inter-app-api-showcase/.gitignore @@ -0,0 +1,25 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +coverage/ +.env +.env.* +!.env.example +!.env.*.example +*.log +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +uploads/ +mobile/android/ +mobile/ios/ +mobile/.expo/ +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +*.tsbuildinfo +.eslintcache diff --git a/examples/inter-app-api-showcase/.prettierignore b/examples/inter-app-api-showcase/.prettierignore new file mode 100644 index 00000000..b62d22e1 --- /dev/null +++ b/examples/inter-app-api-showcase/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +*.log +**/CLAUDE.md diff --git a/examples/inter-app-api-showcase/.prettierrc.json b/examples/inter-app-api-showcase/.prettierrc.json new file mode 100644 index 00000000..32474fc7 --- /dev/null +++ b/examples/inter-app-api-showcase/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/examples/inter-app-api-showcase/.vscode/extensions.json b/examples/inter-app-api-showcase/.vscode/extensions.json new file mode 100644 index 00000000..59ff820b --- /dev/null +++ b/examples/inter-app-api-showcase/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/examples/inter-app-api-showcase/.vscode/settings.json b/examples/inter-app-api-showcase/.vscode/settings.json new file mode 100644 index 00000000..90ef9bee --- /dev/null +++ b/examples/inter-app-api-showcase/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, + "prettier.requireConfig": true, + "[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true } +} diff --git a/examples/inter-app-api-showcase/README.md b/examples/inter-app-api-showcase/README.md index d9ceaf5a..8bfe42f9 100644 --- a/examples/inter-app-api-showcase/README.md +++ b/examples/inter-app-api-showcase/README.md @@ -1,19 +1,56 @@ -# Inter-app + external API showcase +# Inter-app API showcase -`GET /api/product-summary?sku=starter` demonstrates one request handler making: +Generated with `wrnexus workspace inter-app-api-showcase`. -1. an RPC request to the `catalog` app (`getProduct`); -2. an external HTTPS request to GitHub's public REST API; and -3. an RPC request to the `audit` app (`recordLookup`). +- `web` exposes `GET /api/product` and calls admin through private RPC. +- `admin` provides the `catalog` service. +- `packages/shared` owns the typed contract imported by both apps. -The handler deliberately forwards `as: ctx` only to WRNexus peer apps. The RPC package turns that into a short-lived subject/tenant token; it is never forwarded to GitHub. Each peer app must implement the same contract from `app/lib/contracts.ts` (normally a shared workspace package) under `app/services/`, and must authorize its own procedures. +Run the verification from the repository root: -Before running this app, configure all three apps with the same private internal-origin map and a distinct, 32+ character RPC secret: - -```sh -WRNEXUS_RPC_SECRET=replace-with-a-private-32-character-minimum-secret -WRNEXUS_APP_NAME=product-summary -WRNEXUS_INTERNAL_ORIGINS={"catalog":"http://127.0.0.1:4101","audit":"http://127.0.0.1:4102"} +```bash +bun test examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts ``` -The peer app processes must remain private; the public gateway blocks the RPC route by design. Run with `bun run --cwd examples/inter-app-api-showcase dev`. +A WrNexus **workspace** — multiple apps, one gateway, interconnected. + +``` +inter-app-api-showcase/ + wrnexus.workspace.ts # apps ↔ domains map (used by `wrnexus gateway`) + apps/ + web/ # a WrNexus app → localhost, web.localhost + admin/ # a WrNexus app → admin.localhost + packages/ + shared/ # @app/shared — shared code + cross-app pubsub bus +``` + +## Run everything (one port, routed by domain) + +```bash +bun install +bun run dev # = wrnexus gateway → http://127.0.0.1:3000 +``` + +Add the hosts to your machine (e.g. /etc/hosts): + +``` +127.0.0.1 web.localhost admin.localhost +``` + +Open `http://localhost:3000` for the web app or +`http://admin.localhost:3000` for the admin app. The ports printed for individual +apps are internal gateway targets, not public workspace URLs. + +## Interconnect + +- **Shared code:** import `@app/shared` in any app. +- **Runtime messaging:** `import { bus } from "@app/shared"` then + `bus.publish("tenant:created", {...})` in one app and + `bus.subscribe("tenant:*", fn)` in another (needs Redis). +- **Databases:** point apps at the same `db`/`databases` in their config. + +## Add another app + +```bash +wrnexus workspace add reports --domain=reports.localhost +``` diff --git a/examples/inter-app-api-showcase/app/api/product-summary.ts b/examples/inter-app-api-showcase/app/api/product-summary.ts deleted file mode 100644 index 6567754a..00000000 --- a/examples/inter-app-api-showcase/app/api/product-summary.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Context } from "@wrnexus/core"; -import { httpTransport, serviceClient } from "@wrnexus/rpc"; -import { auditService, catalogService } from "../lib/contracts.ts"; - -interface GitHubRepository { - full_name?: unknown; - stargazers_count?: unknown; -} - -function requestedSku(ctx: Context): string { - return new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter"; -} - -/** GET /api/product-summary?sku=starter */ -export async function GET(ctx: Context): Promise { - 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, - }); -} diff --git a/examples/inter-app-api-showcase/app/example.test.ts b/examples/inter-app-api-showcase/app/example.test.ts deleted file mode 100644 index 971e7524..00000000 --- a/examples/inter-app-api-showcase/app/example.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -test("product summary composes one external request with two peer-app calls", () => { - const source = readFileSync(join(import.meta.dir, "api", "product-summary.ts"), "utf8"); - expect(source).toContain('serviceClient(catalogService, { app: "catalog", as: ctx, transport })'); - expect(source).toContain('serviceClient(auditService, { app: "audit", as: ctx, transport })'); - expect(source).toContain('fetch("https://api.github.com/repos/octocat/Hello-World"'); - expect(source).toContain("ctx.req.signal"); -}); diff --git a/examples/inter-app-api-showcase/app/lib/contracts.ts b/examples/inter-app-api-showcase/app/lib/contracts.ts deleted file mode 100644 index 9c608188..00000000 --- a/examples/inter-app-api-showcase/app/lib/contracts.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineService, procedure } from "@wrnexus/rpc"; -import { v } from "@wrnexus/validation"; - -/** - * In a real multi-app workspace, put these contracts in a shared package and - * import that package from this app and each peer. They live together here so - * the example is self-contained. - */ -export const catalogService = defineService({ - name: "catalog", - procedures: { - getProduct: procedure - .input(v.object({ sku: v.string() })) - .output<{ sku: string; displayName: string; enabled: boolean }>() - .idempotent() - .build(), - }, -}); - -export const auditService = defineService({ - name: "audit", - procedures: { - recordLookup: procedure - .input( - v.object({ - sku: v.string(), - repository: v.string(), - stars: v.number(), - }), - ) - .output<{ eventId: string }>() - .build(), - }, -}); diff --git a/examples/inter-app-api-showcase/apps/admin/.editorconfig b/examples/inter-app-api-showcase/apps/admin/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/examples/inter-app-api-showcase/apps/admin/.env.example b/examples/inter-app-api-showcase/apps/admin/.env.example new file mode 100644 index 00000000..c214ea2b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.env.example @@ -0,0 +1,8 @@ +# Copy to .env for local development. Never commit real secrets. +WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000 +DATABASE_URL=file:./dev.db +REDIS_URL=redis://localhost:6379 +AUTH_SECRET=replace-with-at-least-32-random-characters +ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key +ANTHROPIC_API_KEY= +OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/examples/inter-app-api-showcase/apps/admin/.env.test.example b/examples/inter-app-api-showcase/apps/admin/.env.test.example new file mode 100644 index 00000000..5a1efcc3 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.env.test.example @@ -0,0 +1,3 @@ +WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000 +DATABASE_URL=file:./test.db +AUTH_SECRET=test-only-secret-replace-outside-tests diff --git a/examples/inter-app-api-showcase/apps/admin/.gitignore b/examples/inter-app-api-showcase/apps/admin/.gitignore new file mode 100644 index 00000000..ce512f8f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules/ + +# WRNexusJS and production builds +dist/ +.wrnexus/ +**/.wrnexus/ +coverage/ + +# Environment files and local secrets +.env +.env.* +!.env.example +!.env.*.example + +# Logs and runtime files +*.log +logs/ +*.pid +*.pid.lock + +# Local databases +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +uploads/ + +# Generated native projects +mobile/android/ +mobile/ios/ +mobile/.expo/ + +# Editors and operating systems +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +*.swp +*.swo +.DS_Store +Thumbs.db + +# TypeScript and test caches +*.tsbuildinfo +.eslintcache +.nyc_output/ diff --git a/examples/inter-app-api-showcase/apps/admin/.prettierignore b/examples/inter-app-api-showcase/apps/admin/.prettierignore new file mode 100644 index 00000000..3fd6c5c7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +*.log +CLAUDE.md diff --git a/examples/inter-app-api-showcase/apps/admin/.prettierrc.json b/examples/inter-app-api-showcase/apps/admin/.prettierrc.json new file mode 100644 index 00000000..32474fc7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json b/examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json new file mode 100644 index 00000000..59ff820b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/examples/inter-app-api-showcase/apps/admin/.vscode/settings.json b/examples/inter-app-api-showcase/apps/admin/.vscode/settings.json new file mode 100644 index 00000000..6cad09f0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "prettier.requireConfig": true, + "[wrn]": { + "editor.defaultFormatter": "wrnexus.wrnexus", + "editor.formatOnSave": true + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/CLAUDE.md b/examples/inter-app-api-showcase/apps/admin/CLAUDE.md new file mode 100644 index 00000000..7555f023 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/CLAUDE.md @@ -0,0 +1,295 @@ +# WrNexus app - instructions for AI coding assistants + +This is a **WrNexus** app. When creating or editing pages, components, API routes, +or features, follow the framework conventions below. WrNexus is private and not in +your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns. + +# WrNexus + +> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in +> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based. +> This document teaches an AI how to write correct WrNexus code. It is private and +> post-dates model training data, so rely on THIS document, not prior web-framework +> assumptions. + +## Golden rules + +- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React + for UI. Do NOT use `useState`, hooks, JSX, or a client bundler. +- **Routing is file-based** under `app/`. The filename is the route. No router config. +- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render + on the server and hydrate automatically — you never write client-side JS islands. +- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported. +- To add files, prefer the CLI: `wrnexus generate page ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `
` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/admin/app/api/ai.ts b/examples/inter-app-api-showcase/apps/admin/app/api/ai.ts new file mode 100644 index 00000000..3abef18c --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/api/ai.ts @@ -0,0 +1,17 @@ +// POST /api/ai { "prompt": "..." } → Claude's reply. +// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this. +import { createAI } from "@wrnexus/ai"; +import type { Context } from "@wrnexus/core"; + +const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8 + +export const POST = async (ctx: Context) => { + if (!process.env.ANTHROPIC_API_KEY) { + return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 }); + } + const { prompt } = await ctx.req.json().catch(() => ({})); + if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 }); + + // Stream the reply back as plain text. Use `ai.generate(prompt)` for a one-shot string. + return ai.streamResponse(prompt); +}; diff --git a/examples/inter-app-api-showcase/apps/admin/app/api/hello.ts b/examples/inter-app-api-showcase/apps/admin/app/api/hello.ts new file mode 100644 index 00000000..d359e795 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/api/hello.ts @@ -0,0 +1,3 @@ +export const GET = async () => { + return Response.json({ message: "Hello API" }); +}; diff --git a/examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn b/examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn new file mode 100644 index 00000000..58c305b8 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn @@ -0,0 +1,20 @@ +// A reusable component. Route: none — mounted inside a page with +//
. +// +// 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 { + + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql b/examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql new file mode 100644 index 00000000..39a33e74 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql @@ -0,0 +1,2 @@ +-- Create application tables here. +-- Run with: bunx wrnexus db migrate diff --git a/examples/inter-app-api-showcase/apps/admin/app/db/seed.ts b/examples/inter-app-api-showcase/apps/admin/app/db/seed.ts new file mode 100644 index 00000000..642d429f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/db/seed.ts @@ -0,0 +1,2 @@ +// Add deterministic development seed data here. +export async function seed(): Promise {} diff --git a/examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn b/examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn new file mode 100644 index 00000000..1aaaf5dd --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn @@ -0,0 +1,22 @@ +// Global document layout. The framework renders this once around the selected +// page layout and merges SEO metadata, styles, and scripts into /. +// 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 { + + + +
+ + + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/locales/en.json b/examples/inter-app-api-showcase/apps/admin/app/locales/en.json new file mode 100644 index 00000000..d1e54ea3 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/locales/en.json @@ -0,0 +1,6 @@ +{ + "common": { + "appName": "admin", + "welcome": "Welcome to admin" + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts b/examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts new file mode 100644 index 00000000..a582fddf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts @@ -0,0 +1,8 @@ +import type { Middleware } from "@wrnexus/core"; + +const logger: Middleware = async (ctx, next) => { + console.log(ctx.req.method, ctx.url.pathname); + return next(); +}; + +export default logger; diff --git a/examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn b/examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn new file mode 100644 index 00000000..267eeb37 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn @@ -0,0 +1,20 @@ +page About { + seo { + title = "About" + description = "Learn how admin is built with WrNexus." + } + + view { +
+
+ ← Home +

WrNexus application

+

About admin

+

+ This page is server-rendered from app/pages/about.wrn. Add state, + events, components, APIs, and data without switching to another UI framework. +

+
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn b/examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn new file mode 100644 index 00000000..ff66ec05 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn @@ -0,0 +1,63 @@ +// Home page (route: /). SSR-first: the view is server-rendered, then components +// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind. +page Home { + seo { + title = "Home" + description = "admin — built with WrNexus, an SSR-first Bun framework." + } + + view { +
+ + +
+
+ + W + admin + + +
+ +
+

SSR-first · Bun-native

+ +

+ Server-rendered.
+ Instantly interactive. +

+ +

+ admin runs on WrNexus — write .wrn components, ship no client boilerplate, and let the server do the work. +

+ + + +
+
+ + live · hydrated on the server +
+
+
+ This button works. You wrote zero client JavaScript. +
+
+ +

+ edit app/pages/index.wrn to make it yours +

+
+ + +
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts b/examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts new file mode 100644 index 00000000..d89a0680 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts @@ -0,0 +1,20 @@ +// ws:///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 }); + }, +}); diff --git a/examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts b/examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts new file mode 100644 index 00000000..bcd3c02e --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts @@ -0,0 +1,6 @@ +import { v } from "@wrnexus/validation"; + +export const contactSchema = v.object({ + email: v.string().email(), + message: v.string().min(10).max(2_000), +}); diff --git a/examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts b/examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts new file mode 100644 index 00000000..4ad7327b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts @@ -0,0 +1,16 @@ +import { implement } from "../../../../../../packages/rpc/src/index.ts"; +import { catalogService } from "../../../../packages/shared/src/index.ts"; + +/** Private service consumed by the workspace's web app. */ +export default implement( + catalogService, + { + getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }), + }, + { + selfApp: "admin", + // Demo policy. Use the admin app's database-backed permission store in production. + checkPermission: (permission, subject) => + permission === "catalog:read" && subject?.subjectId === "demo-user", + }, +); diff --git a/examples/inter-app-api-showcase/apps/admin/app/styles/global.css b/examples/inter-app-api-showcase/apps/admin/app/styles/global.css new file mode 100644 index 00000000..afb7b3cf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/styles/global.css @@ -0,0 +1,19 @@ +/* + * Global stylesheet. Tailwind v4 is compiled by the styles.process hook in + * wrnexus.config.ts and served at /__wrnexus/styles.css on every page. + * + * @source tells Tailwind which files to scan for class names. + */ +@import "tailwindcss"; +@plugin "@iconify/tailwind4"; +@source "../**/*.wrn"; +@source "../**/*.tsx"; + +/* Make Tailwind's `dark:` variant follow the framework's data-theme attribute + * (set on 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); +} diff --git a/examples/inter-app-api-showcase/apps/admin/eslint.config.js b/examples/inter-app-api-showcase/apps/admin/eslint.config.js new file mode 100644 index 00000000..5c86a656 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/eslint.config.js @@ -0,0 +1,44 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); + +export default tseslint.config( + { + ignores: [ + "node_modules/**", + "dist/**", + ".wrnexus/**", + "**/.wrnexus/**", + "mobile/android/**", + "mobile/ios/**", + ], + }, + { + languageOptions: { + parserOptions: { + tsconfigRootDir, + }, + }, + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + rules: { + "no-undef": "off", + "no-console": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, +); diff --git a/examples/inter-app-api-showcase/apps/admin/package.json b/examples/inter-app-api-showcase/apps/admin/package.json new file mode 100644 index 00000000..070fe721 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/package.json @@ -0,0 +1,63 @@ +{ + "name": "admin", + "version": "0.1.0", + "private": true, + "type": "module", + "wrnexus": { + "version": "0.8.6" + }, + "scripts": { + "dev": "wrnexus dev .", + "build": "wrnexus build .", + "start": "bun dist/server.js", + "production": "bun run build && bun run start", + "typecheck": "tsc --noEmit", + "test": "wrnexus test .", + "test:watch": "wrnexus test . --watch", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write", + "format:check": "prettier . --check", + "doctor": "wrnexus doctor .", + "analyze": "wrnexus analyze .", + "inspect": "wrnexus inspect packages .", + "check": "bun run typecheck && bun run lint && bun run test && bun run format:check" + }, + "dependencies": { + "@wrnexus/ai": "0.8.6", + "@wrnexus/auth": "0.8.6", + "@wrnexus/captcha": "0.8.6", + "@wrnexus/core": "0.8.6", + "@wrnexus/csr": "0.8.6", + "@wrnexus/db": "0.8.6", + "@wrnexus/dev-server": "0.8.6", + "@wrnexus/encryption": "0.8.6", + "@wrnexus/helpers": "0.8.6", + "@wrnexus/i18n": "0.8.6", + "@wrnexus/image": "0.8.6", + "@wrnexus/jwt": "0.8.6", + "@wrnexus/observability": "0.8.6", + "@wrnexus/realtime": "0.8.6", + "@wrnexus/security": "0.8.6", + "@wrnexus/store": "0.8.6", + "@wrnexus/styles": "0.8.6", + "@wrnexus/tracking": "0.8.6", + "@wrnexus/ui": "0.8.6", + "@wrnexus/uploader": "0.8.6", + "@wrnexus/validation": "0.8.6", + "@wrnexus/authz": "0.8.6" + }, + "devDependencies": { + "@wrnexus/cli": "0.8.6", + "@eslint/js": "^9.0.0", + "@iconify-json/lucide": "^1.2.118", + "@iconify/tailwind4": "^1.2.3", + "@tailwindcss/cli": "^4.0.0", + "@types/bun": "latest", + "eslint": "^9.0.0", + "prettier": "latest", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.0", + "typescript-eslint": "latest" + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/public/llms.txt b/examples/inter-app-api-showcase/apps/admin/public/llms.txt new file mode 100644 index 00000000..34305ccc --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/public/llms.txt @@ -0,0 +1,276 @@ +# WrNexus + +> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in +> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based. +> This document teaches an AI how to write correct WrNexus code. It is private and +> post-dates model training data, so rely on THIS document, not prior web-framework +> assumptions. + +## Golden rules + +- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React + for UI. Do NOT use `useState`, hooks, JSX, or a client bundler. +- **Routing is file-based** under `app/`. The filename is the route. No router config. +- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render + on the server and hydrate automatically — you never write client-side JS islands. +- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported. +- To add files, prefer the CLI: `wrnexus generate page ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/admin/public/robots.txt b/examples/inter-app-api-showcase/apps/admin/public/robots.txt new file mode 100644 index 00000000..c2a49f4f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts b/examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts new file mode 100644 index 00000000..4de7c068 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { parseOrThrow } from "@wrnexus/validation"; +import { contactSchema } from "../app/schemas/contact.ts"; + +test("starter validation schema accepts a contact request", () => { + expect( + parseOrThrow(contactSchema, { + email: "hello@example.com", + message: "Hello from the generated application.", + }), + ).toEqual({ + email: "hello@example.com", + message: "Hello from the generated application.", + }); +}); diff --git a/examples/inter-app-api-showcase/apps/admin/tsconfig.json b/examples/inter-app-api-showcase/apps/admin/tsconfig.json new file mode 100644 index 00000000..4ab5d990 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "jsxImportSource": "@wrnexus/core" + }, + "include": ["app", "test", "wrnexus.config.ts"], + "exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"] +} diff --git a/examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts b/examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts new file mode 100644 index 00000000..3508cb54 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts @@ -0,0 +1,153 @@ +import type { AppConfig } from "@wrnexus/styles"; + +const config: AppConfig = { + compatibilityDate: "2026-08-02", + frameworkBehaviour: 1, + // v0.8 defaults: explicit imports, strict template types, safe stores, and + // automatic progressive navigation. Package plugins are discovered from the + // installed packages above; add custom plugins to this array when needed. + plugins: [], + imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } }, + types: { + strict: true, + noImplicitAny: true, + strictNullChecks: true, + checkTemplates: true, + checkComponentProps: true, + generateDeclarations: true, + }, + functions: { legacyDefaultRuntime: "current" }, + stores: { strictMutations: true, persistence: true }, + compatibility: { + legacyEmit: false, + legacyEventProps: false, + legacyComponentDiscovery: false, + stringLayouts: false, + }, + experimental: {}, + + performance: { + enforcement: "warn", + analyze: true, + budgets: { + routeJsBytes: 50 * 1024, + routeCssBytes: 25 * 1024, + lcpMs: 2_500, + inpMs: 200, + cls: 0.1, + }, + }, + observability: { + enabled: true, + serviceName: "admin", + serverTiming: true, + sampleRate: 1, + exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none", + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + webVitals: true, + }, + tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] }, + build: { cache: true, sourceMaps: true, report: true, adapter: "bun" }, + navigation: { mode: "auto" }, + devToolbar: { enabled: true, position: "bottom-center", openEditor: true }, + + mobile: { + enabled: true, + appId: "com.example.admin", + appName: "admin", + userAgent: "WrNexusMobile", + backgroundColor: "#0f172a", + // layout: "mobile", // app/layouts/mobile.wrn + // icon: "resources/icon.png", + }, + + // PWA support is enabled automatically. Override any install metadata here. + pwa: { + name: "admin", + shortName: "admin", + display: "standalone", + themeColor: "#6366f1", + backgroundColor: "#0f172a", + }, + + seo: { + title: "admin", + titleTemplate: "%s | admin", + // Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy. + canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN, + description: "An SSR-first WrNexus app.", + robots: "index,follow", + themeColor: "#6366f1", + }, + + styles: { + entry: "app/styles/global.css", + + // Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart) + // and at `wrnexus build`. `@tailwindcss/cli` writes to stdout, so we capture + // and return the final CSS. Delete this hook to drop Tailwind — global.css is + // still bundled and served as-is. + process: async ({ entryPath, appRoot, mode }) => { + const args = ["@tailwindcss/cli", "-i", entryPath!]; + if (mode === "production") args.push("--minify"); + return await Bun.$.cwd(appRoot)`bunx ${args}`.text(); + }, + }, + + // Fonts — optimized preconnect, subsetted weights, font-display, and CSP. + fonts: { + sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', + google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }], + }, + // + // // Or self-host (fastest, no third party) — drop files in public/fonts/: + // // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }], + + theme: { palette: "violet", default: "light" }, + i18n: { default: "en", locales: ["en"] }, + db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" }, + databases: {}, + storage: { + default: "public", + stores: { + public: { + driver: "local", + access: "public", + dir: "uploads/public", + maxBytes: 10_000_000, + accept: ["image/*", "application/pdf"], + }, + private: { + driver: "local", + access: "private", + dir: "uploads/private", + maxBytes: 10_000_000, + }, + }, + }, + realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL }, + port: Number(process.env.PORT ?? 3000), + security: { + cors: { enabled: false }, + }, + profiles: { + development: {}, + test: { + db: { driver: "sqlite", url: "file:./test.db" }, + observability: { exporter: "none", sampleRate: 0 }, + }, + staging: { + seo: { robots: "noindex,nofollow" }, + performance: { enforcement: "error" }, + build: { sourceMaps: true, report: true }, + }, + production: { + seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN }, + performance: { enforcement: "error" }, + build: { sourceMaps: false, report: true }, + devToolbar: false, + }, + }, +}; + +export default config; diff --git a/examples/inter-app-api-showcase/apps/web/.editorconfig b/examples/inter-app-api-showcase/apps/web/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/examples/inter-app-api-showcase/apps/web/.env.example b/examples/inter-app-api-showcase/apps/web/.env.example new file mode 100644 index 00000000..c214ea2b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.env.example @@ -0,0 +1,8 @@ +# Copy to .env for local development. Never commit real secrets. +WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000 +DATABASE_URL=file:./dev.db +REDIS_URL=redis://localhost:6379 +AUTH_SECRET=replace-with-at-least-32-random-characters +ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key +ANTHROPIC_API_KEY= +OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/examples/inter-app-api-showcase/apps/web/.env.test.example b/examples/inter-app-api-showcase/apps/web/.env.test.example new file mode 100644 index 00000000..5a1efcc3 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.env.test.example @@ -0,0 +1,3 @@ +WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000 +DATABASE_URL=file:./test.db +AUTH_SECRET=test-only-secret-replace-outside-tests diff --git a/examples/inter-app-api-showcase/apps/web/.gitignore b/examples/inter-app-api-showcase/apps/web/.gitignore new file mode 100644 index 00000000..ce512f8f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules/ + +# WRNexusJS and production builds +dist/ +.wrnexus/ +**/.wrnexus/ +coverage/ + +# Environment files and local secrets +.env +.env.* +!.env.example +!.env.*.example + +# Logs and runtime files +*.log +logs/ +*.pid +*.pid.lock + +# Local databases +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +uploads/ + +# Generated native projects +mobile/android/ +mobile/ios/ +mobile/.expo/ + +# Editors and operating systems +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +*.swp +*.swo +.DS_Store +Thumbs.db + +# TypeScript and test caches +*.tsbuildinfo +.eslintcache +.nyc_output/ diff --git a/examples/inter-app-api-showcase/apps/web/.prettierignore b/examples/inter-app-api-showcase/apps/web/.prettierignore new file mode 100644 index 00000000..3fd6c5c7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +*.log +CLAUDE.md diff --git a/examples/inter-app-api-showcase/apps/web/.prettierrc.json b/examples/inter-app-api-showcase/apps/web/.prettierrc.json new file mode 100644 index 00000000..32474fc7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/examples/inter-app-api-showcase/apps/web/.vscode/extensions.json b/examples/inter-app-api-showcase/apps/web/.vscode/extensions.json new file mode 100644 index 00000000..59ff820b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/examples/inter-app-api-showcase/apps/web/.vscode/settings.json b/examples/inter-app-api-showcase/apps/web/.vscode/settings.json new file mode 100644 index 00000000..6cad09f0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "prettier.requireConfig": true, + "[wrn]": { + "editor.defaultFormatter": "wrnexus.wrnexus", + "editor.formatOnSave": true + } +} diff --git a/examples/inter-app-api-showcase/apps/web/CLAUDE.md b/examples/inter-app-api-showcase/apps/web/CLAUDE.md new file mode 100644 index 00000000..7555f023 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/CLAUDE.md @@ -0,0 +1,295 @@ +# WrNexus app - instructions for AI coding assistants + +This is a **WrNexus** app. When creating or editing pages, components, API routes, +or features, follow the framework conventions below. WrNexus is private and not in +your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns. + +# WrNexus + +> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in +> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based. +> This document teaches an AI how to write correct WrNexus code. It is private and +> post-dates model training data, so rely on THIS document, not prior web-framework +> assumptions. + +## Golden rules + +- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React + for UI. Do NOT use `useState`, hooks, JSX, or a client bundler. +- **Routing is file-based** under `app/`. The filename is the route. No router config. +- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render + on the server and hydrate automatically — you never write client-side JS islands. +- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported. +- To add files, prefer the CLI: `wrnexus generate page ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/web/app/api/ai.ts b/examples/inter-app-api-showcase/apps/web/app/api/ai.ts new file mode 100644 index 00000000..3abef18c --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/api/ai.ts @@ -0,0 +1,17 @@ +// POST /api/ai { "prompt": "..." } → Claude's reply. +// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this. +import { createAI } from "@wrnexus/ai"; +import type { Context } from "@wrnexus/core"; + +const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8 + +export const POST = async (ctx: Context) => { + if (!process.env.ANTHROPIC_API_KEY) { + return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 }); + } + const { prompt } = await ctx.req.json().catch(() => ({})); + if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 }); + + // Stream the reply back as plain text. Use `ai.generate(prompt)` for a one-shot string. + return ai.streamResponse(prompt); +}; diff --git a/examples/inter-app-api-showcase/apps/web/app/api/hello.ts b/examples/inter-app-api-showcase/apps/web/app/api/hello.ts new file mode 100644 index 00000000..d359e795 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/api/hello.ts @@ -0,0 +1,3 @@ +export const GET = async () => { + return Response.json({ message: "Hello API" }); +}; diff --git a/examples/inter-app-api-showcase/apps/web/app/api/product.ts b/examples/inter-app-api-showcase/apps/web/app/api/product.ts new file mode 100644 index 00000000..17d44172 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/api/product.ts @@ -0,0 +1,26 @@ +import type { Context } from "../../../../../../packages/core/src/index.ts"; +import { + httpTransport, + retryingTransport, + ServiceError, + serviceClient, +} from "../../../../../../packages/rpc/src/index.ts"; +import { catalogService } from "../../../../packages/shared/src/index.ts"; + +/** GET /api/product?sku=starter — reads product data from the admin app. */ +export async function GET(ctx: Context): Promise { + const sku = new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter"; + const catalog = serviceClient(catalogService, { + app: "admin", + as: ctx, + transport: retryingTransport(httpTransport()), + }); + try { + return Response.json({ product: await catalog.getProduct({ sku }) }); + } catch (error) { + if (error instanceof ServiceError && error.code === "RPC_DENIED") { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + return Response.json({ error: "Catalog temporarily unavailable" }, { status: 502 }); + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/components/counter.wrn b/examples/inter-app-api-showcase/apps/web/app/components/counter.wrn new file mode 100644 index 00000000..58c305b8 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/components/counter.wrn @@ -0,0 +1,20 @@ +// A reusable component. Route: none — mounted inside a page with +//
. +// +// 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 { + + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql b/examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql new file mode 100644 index 00000000..39a33e74 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql @@ -0,0 +1,2 @@ +-- Create application tables here. +-- Run with: bunx wrnexus db migrate diff --git a/examples/inter-app-api-showcase/apps/web/app/db/seed.ts b/examples/inter-app-api-showcase/apps/web/app/db/seed.ts new file mode 100644 index 00000000..642d429f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/db/seed.ts @@ -0,0 +1,2 @@ +// Add deterministic development seed data here. +export async function seed(): Promise {} diff --git a/examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn b/examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn new file mode 100644 index 00000000..1aaaf5dd --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn @@ -0,0 +1,22 @@ +// Global document layout. The framework renders this once around the selected +// page layout and merges SEO metadata, styles, and scripts into /. +// 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 { + + + +
+ + + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/locales/en.json b/examples/inter-app-api-showcase/apps/web/app/locales/en.json new file mode 100644 index 00000000..60ce8b04 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/locales/en.json @@ -0,0 +1,6 @@ +{ + "common": { + "appName": "web", + "welcome": "Welcome to web" + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts b/examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts new file mode 100644 index 00000000..a582fddf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts @@ -0,0 +1,8 @@ +import type { Middleware } from "@wrnexus/core"; + +const logger: Middleware = async (ctx, next) => { + console.log(ctx.req.method, ctx.url.pathname); + return next(); +}; + +export default logger; diff --git a/examples/inter-app-api-showcase/apps/web/app/pages/about.wrn b/examples/inter-app-api-showcase/apps/web/app/pages/about.wrn new file mode 100644 index 00000000..ea5496ad --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/pages/about.wrn @@ -0,0 +1,20 @@ +page About { + seo { + title = "About" + description = "Learn how web is built with WrNexus." + } + + view { +
+
+ ← Home +

WrNexus application

+

About web

+

+ This page is server-rendered from app/pages/about.wrn. Add state, + events, components, APIs, and data without switching to another UI framework. +

+
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/pages/index.wrn b/examples/inter-app-api-showcase/apps/web/app/pages/index.wrn new file mode 100644 index 00000000..25c1fc43 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/pages/index.wrn @@ -0,0 +1,63 @@ +// Home page (route: /). SSR-first: the view is server-rendered, then components +// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind. +page Home { + seo { + title = "Home" + description = "web — built with WrNexus, an SSR-first Bun framework." + } + + view { +
+ + +
+
+ + W + web + + +
+ +
+

SSR-first · Bun-native

+ +

+ Server-rendered.
+ Instantly interactive. +

+ +

+ web runs on WrNexus — write .wrn components, ship no client boilerplate, and let the server do the work. +

+ + + +
+
+ + live · hydrated on the server +
+
+
+ This button works. You wrote zero client JavaScript. +
+
+ +

+ edit app/pages/index.wrn to make it yours +

+
+ + +
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts b/examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts new file mode 100644 index 00000000..d89a0680 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts @@ -0,0 +1,20 @@ +// ws:///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 }); + }, +}); diff --git a/examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts b/examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts new file mode 100644 index 00000000..bcd3c02e --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts @@ -0,0 +1,6 @@ +import { v } from "@wrnexus/validation"; + +export const contactSchema = v.object({ + email: v.string().email(), + message: v.string().min(10).max(2_000), +}); diff --git a/examples/inter-app-api-showcase/apps/web/app/styles/global.css b/examples/inter-app-api-showcase/apps/web/app/styles/global.css new file mode 100644 index 00000000..afb7b3cf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/styles/global.css @@ -0,0 +1,19 @@ +/* + * Global stylesheet. Tailwind v4 is compiled by the styles.process hook in + * wrnexus.config.ts and served at /__wrnexus/styles.css on every page. + * + * @source tells Tailwind which files to scan for class names. + */ +@import "tailwindcss"; +@plugin "@iconify/tailwind4"; +@source "../**/*.wrn"; +@source "../**/*.tsx"; + +/* Make Tailwind's `dark:` variant follow the framework's data-theme attribute + * (set on 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); +} diff --git a/examples/inter-app-api-showcase/apps/web/eslint.config.js b/examples/inter-app-api-showcase/apps/web/eslint.config.js new file mode 100644 index 00000000..5c86a656 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/eslint.config.js @@ -0,0 +1,44 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); + +export default tseslint.config( + { + ignores: [ + "node_modules/**", + "dist/**", + ".wrnexus/**", + "**/.wrnexus/**", + "mobile/android/**", + "mobile/ios/**", + ], + }, + { + languageOptions: { + parserOptions: { + tsconfigRootDir, + }, + }, + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + rules: { + "no-undef": "off", + "no-console": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, +); diff --git a/examples/inter-app-api-showcase/apps/web/package.json b/examples/inter-app-api-showcase/apps/web/package.json new file mode 100644 index 00000000..c1608733 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/package.json @@ -0,0 +1,63 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "type": "module", + "wrnexus": { + "version": "0.8.6" + }, + "scripts": { + "dev": "wrnexus dev .", + "build": "wrnexus build .", + "start": "bun dist/server.js", + "production": "bun run build && bun run start", + "typecheck": "tsc --noEmit", + "test": "wrnexus test .", + "test:watch": "wrnexus test . --watch", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write", + "format:check": "prettier . --check", + "doctor": "wrnexus doctor .", + "analyze": "wrnexus analyze .", + "inspect": "wrnexus inspect packages .", + "check": "bun run typecheck && bun run lint && bun run test && bun run format:check" + }, + "dependencies": { + "@wrnexus/ai": "0.8.6", + "@wrnexus/auth": "0.8.6", + "@wrnexus/captcha": "0.8.6", + "@wrnexus/core": "0.8.6", + "@wrnexus/csr": "0.8.6", + "@wrnexus/db": "0.8.6", + "@wrnexus/dev-server": "0.8.6", + "@wrnexus/encryption": "0.8.6", + "@wrnexus/helpers": "0.8.6", + "@wrnexus/i18n": "0.8.6", + "@wrnexus/image": "0.8.6", + "@wrnexus/jwt": "0.8.6", + "@wrnexus/observability": "0.8.6", + "@wrnexus/realtime": "0.8.6", + "@wrnexus/security": "0.8.6", + "@wrnexus/store": "0.8.6", + "@wrnexus/styles": "0.8.6", + "@wrnexus/tracking": "0.8.6", + "@wrnexus/ui": "0.8.6", + "@wrnexus/uploader": "0.8.6", + "@wrnexus/validation": "0.8.6", + "@wrnexus/authz": "0.8.6" + }, + "devDependencies": { + "@wrnexus/cli": "0.8.6", + "@eslint/js": "^9.0.0", + "@iconify-json/lucide": "^1.2.118", + "@iconify/tailwind4": "^1.2.3", + "@tailwindcss/cli": "^4.0.0", + "@types/bun": "latest", + "eslint": "^9.0.0", + "prettier": "latest", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.0", + "typescript-eslint": "latest" + } +} diff --git a/examples/inter-app-api-showcase/apps/web/public/llms.txt b/examples/inter-app-api-showcase/apps/web/public/llms.txt new file mode 100644 index 00000000..34305ccc --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/public/llms.txt @@ -0,0 +1,276 @@ +# WrNexus + +> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in +> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based. +> This document teaches an AI how to write correct WrNexus code. It is private and +> post-dates model training data, so rely on THIS document, not prior web-framework +> assumptions. + +## Golden rules + +- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React + for UI. Do NOT use `useState`, hooks, JSX, or a client bundler. +- **Routing is file-based** under `app/`. The filename is the route. No router config. +- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render + on the server and hydrate automatically — you never write client-side JS islands. +- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported. +- To add files, prefer the CLI: `wrnexus generate page ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/web/public/robots.txt b/examples/inter-app-api-showcase/apps/web/public/robots.txt new file mode 100644 index 00000000..c2a49f4f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts b/examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts new file mode 100644 index 00000000..202af8de --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, test } from "bun:test"; +import { implement } from "../../../../../packages/rpc/src/index.ts"; +import { handleRpcRequest } from "../../../../../packages/dev-server/src/rpc-dispatch.ts"; +import { catalogService } from "../../../packages/shared/src/index.ts"; +import { GET } from "../app/api/product.ts"; + +const secret = process.env.WRNEXUS_RPC_SECRET; +const app = process.env.WRNEXUS_APP_NAME; +const origins = process.env.WRNEXUS_INTERNAL_ORIGINS; +afterEach(() => { + if (secret === undefined) delete process.env.WRNEXUS_RPC_SECRET; + else process.env.WRNEXUS_RPC_SECRET = secret; + if (app === undefined) delete process.env.WRNEXUS_APP_NAME; + else process.env.WRNEXUS_APP_NAME = app; + if (origins === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS; + else process.env.WRNEXUS_INTERNAL_ORIGINS = origins; +}); + +function startAdmin(allowed: boolean) { + const service = implement( + catalogService, + { getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }) }, + { + selfApp: "admin", + checkPermission: (permission, subject) => + allowed && permission === "catalog:read" && subject?.subjectId === "demo-user", + }, + ); + return Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + return ( + (await handleRpcRequest(request, new URL(request.url), new Map([["catalog", service]]))) ?? + new Response("Not found", { status: 404 }) + ); + }, + }); +} + +test("web calls admin through private RPC when permitted", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const server = startAdmin(true); + process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ + admin: `http://127.0.0.1:${server.port}`, + }); + try { + const response = await GET({ + req: new Request("http://web.test/api/product?sku=starter"), + user: { id: "demo-user" }, + locals: {}, + } as never); + expect(await response.json()).toEqual({ + product: { sku: "starter", name: "WRNexus Starter", priceCents: 4900 }, + }); + } finally { + server.stop(true); + } +}); + +test("web returns 403 when admin denies catalog:read", async () => { + process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; + process.env.WRNEXUS_APP_NAME = "web"; + const server = startAdmin(false); + process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ + admin: `http://127.0.0.1:${server.port}`, + }); + try { + const response = await GET({ + req: new Request("http://web.test/api/product"), + user: { id: "not-allowed" }, + locals: {}, + } as never); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "Forbidden" }); + } finally { + server.stop(true); + } +}); diff --git a/examples/inter-app-api-showcase/apps/web/test/smoke.test.ts b/examples/inter-app-api-showcase/apps/web/test/smoke.test.ts new file mode 100644 index 00000000..4de7c068 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/test/smoke.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { parseOrThrow } from "@wrnexus/validation"; +import { contactSchema } from "../app/schemas/contact.ts"; + +test("starter validation schema accepts a contact request", () => { + expect( + parseOrThrow(contactSchema, { + email: "hello@example.com", + message: "Hello from the generated application.", + }), + ).toEqual({ + email: "hello@example.com", + message: "Hello from the generated application.", + }); +}); diff --git a/examples/inter-app-api-showcase/apps/web/tsconfig.json b/examples/inter-app-api-showcase/apps/web/tsconfig.json new file mode 100644 index 00000000..4ab5d990 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "jsxImportSource": "@wrnexus/core" + }, + "include": ["app", "test", "wrnexus.config.ts"], + "exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"] +} diff --git a/examples/inter-app-api-showcase/apps/web/wrnexus.config.ts b/examples/inter-app-api-showcase/apps/web/wrnexus.config.ts new file mode 100644 index 00000000..3172c1d1 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/wrnexus.config.ts @@ -0,0 +1,153 @@ +import type { AppConfig } from "@wrnexus/styles"; + +const config: AppConfig = { + compatibilityDate: "2026-08-02", + frameworkBehaviour: 1, + // v0.8 defaults: explicit imports, strict template types, safe stores, and + // automatic progressive navigation. Package plugins are discovered from the + // installed packages above; add custom plugins to this array when needed. + plugins: [], + imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } }, + types: { + strict: true, + noImplicitAny: true, + strictNullChecks: true, + checkTemplates: true, + checkComponentProps: true, + generateDeclarations: true, + }, + functions: { legacyDefaultRuntime: "current" }, + stores: { strictMutations: true, persistence: true }, + compatibility: { + legacyEmit: false, + legacyEventProps: false, + legacyComponentDiscovery: false, + stringLayouts: false, + }, + experimental: {}, + + performance: { + enforcement: "warn", + analyze: true, + budgets: { + routeJsBytes: 50 * 1024, + routeCssBytes: 25 * 1024, + lcpMs: 2_500, + inpMs: 200, + cls: 0.1, + }, + }, + observability: { + enabled: true, + serviceName: "web", + serverTiming: true, + sampleRate: 1, + exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none", + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + webVitals: true, + }, + tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] }, + build: { cache: true, sourceMaps: true, report: true, adapter: "bun" }, + navigation: { mode: "auto" }, + devToolbar: { enabled: true, position: "bottom-center", openEditor: true }, + + mobile: { + enabled: true, + appId: "com.example.web", + appName: "web", + userAgent: "WrNexusMobile", + backgroundColor: "#0f172a", + // layout: "mobile", // app/layouts/mobile.wrn + // icon: "resources/icon.png", + }, + + // PWA support is enabled automatically. Override any install metadata here. + pwa: { + name: "web", + shortName: "web", + display: "standalone", + themeColor: "#6366f1", + backgroundColor: "#0f172a", + }, + + seo: { + title: "web", + titleTemplate: "%s | web", + // Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy. + canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN, + description: "An SSR-first WrNexus app.", + robots: "index,follow", + themeColor: "#6366f1", + }, + + styles: { + entry: "app/styles/global.css", + + // Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart) + // and at `wrnexus build`. `@tailwindcss/cli` writes to stdout, so we capture + // and return the final CSS. Delete this hook to drop Tailwind — global.css is + // still bundled and served as-is. + process: async ({ entryPath, appRoot, mode }) => { + const args = ["@tailwindcss/cli", "-i", entryPath!]; + if (mode === "production") args.push("--minify"); + return await Bun.$.cwd(appRoot)`bunx ${args}`.text(); + }, + }, + + // Fonts — optimized preconnect, subsetted weights, font-display, and CSP. + fonts: { + sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', + google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }], + }, + // + // // Or self-host (fastest, no third party) — drop files in public/fonts/: + // // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }], + + theme: { palette: "violet", default: "light" }, + i18n: { default: "en", locales: ["en"] }, + db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" }, + databases: {}, + storage: { + default: "public", + stores: { + public: { + driver: "local", + access: "public", + dir: "uploads/public", + maxBytes: 10_000_000, + accept: ["image/*", "application/pdf"], + }, + private: { + driver: "local", + access: "private", + dir: "uploads/private", + maxBytes: 10_000_000, + }, + }, + }, + realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL }, + port: Number(process.env.PORT ?? 3000), + security: { + cors: { enabled: false }, + }, + profiles: { + development: {}, + test: { + db: { driver: "sqlite", url: "file:./test.db" }, + observability: { exporter: "none", sampleRate: 0 }, + }, + staging: { + seo: { robots: "noindex,nofollow" }, + performance: { enforcement: "error" }, + build: { sourceMaps: true, report: true }, + }, + production: { + seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN }, + performance: { enforcement: "error" }, + build: { sourceMaps: false, report: true }, + devToolbar: false, + }, + }, +}; + +export default config; diff --git a/examples/inter-app-api-showcase/eslint.config.js b/examples/inter-app-api-showcase/eslint.config.js new file mode 100644 index 00000000..177993d1 --- /dev/null +++ b/examples/inter-app-api-showcase/eslint.config.js @@ -0,0 +1,25 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); + +export default tseslint.config( + { ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] }, + { languageOptions: { parserOptions: { tsconfigRootDir } } }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + rules: { + "no-undef": "off", + "no-console": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + }, + }, +); diff --git a/examples/inter-app-api-showcase/package.json b/examples/inter-app-api-showcase/package.json index 4b29d644..1733ed14 100644 --- a/examples/inter-app-api-showcase/package.json +++ b/examples/inter-app-api-showcase/package.json @@ -1,22 +1,32 @@ { "name": "inter-app-api-showcase", - "version": "0.8.6", "private": true, "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "bun run ../../packages/cli/src/index.ts dev .", - "build": "bun run ../../packages/cli/src/index.ts build .", - "test": "bun test", - "typecheck": "tsc --noEmit -p tsconfig.json", - "check": "bun run typecheck && bun run test && bun run build" - }, - "dependencies": { - "@wrnexus/core": "workspace:*", - "@wrnexus/rpc": "workspace:*", - "@wrnexus/validation": "workspace:*" + "dev": "wrnexus gateway", + "gateway": "wrnexus gateway", + "staging": "wrnexus staging", + "production": "wrnexus production", + "typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck", + "test": "bun run --filter './apps/*' test", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write", + "format:check": "prettier . --check", + "doctor": "bun run --filter './apps/*' doctor", + "check": "bun run typecheck && bun run lint && bun run test && bun run format:check" }, "devDependencies": { - "@types/bun": "^1.3.14", - "typescript": "^5.9.2" + "@wrnexus/cli": "0.8.6", + "@eslint/js": "^9.0.0", + "@types/bun": "latest", + "eslint": "^9.0.0", + "prettier": "latest", + "typescript": "^5.5.0", + "typescript-eslint": "latest" } } diff --git a/examples/inter-app-api-showcase/packages/shared/package.json b/examples/inter-app-api-showcase/packages/shared/package.json new file mode 100644 index 00000000..f27b313c --- /dev/null +++ b/examples/inter-app-api-showcase/packages/shared/package.json @@ -0,0 +1,17 @@ +{ + "name": "@app/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@wrnexus/pubsub": "0.8.6" + } +} diff --git a/examples/inter-app-api-showcase/packages/shared/src/index.ts b/examples/inter-app-api-showcase/packages/shared/src/index.ts new file mode 100644 index 00000000..2c9339a4 --- /dev/null +++ b/examples/inter-app-api-showcase/packages/shared/src/index.ts @@ -0,0 +1,30 @@ +/** + * Shared code for every app in this workspace. Import it anywhere: `@app/shared`. + * The cross-app event bus uses Redis so messages reach every app process/domain. + */ +import { createPubSub } from "../../../../../packages/pubsub/src/index.ts"; +import { redisDriver } from "../../../../../packages/pubsub/src/redis.ts"; +import { defineService, procedure } from "../../../../../packages/rpc/src/index.ts"; +import { v } from "../../../../../packages/validation/src/index.ts"; + +// One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379). +export const bus = createPubSub(redisDriver(process.env.REDIS_URL)); + +// Shared domain types can live here and be imported by every app. +export interface Tenant { + id: string; + name: string; +} + +/** Contract shared by web (caller) and admin (callee). */ +export const catalogService = defineService({ + name: "catalog", + procedures: { + getProduct: procedure + .input(v.object({ sku: v.string() })) + .output<{ sku: string; name: string; priceCents: number }>() + .permission("catalog:read") + .idempotent() + .build(), + }, +}); diff --git a/examples/inter-app-api-showcase/tsconfig.json b/examples/inter-app-api-showcase/tsconfig.json index b077260d..d4437925 100644 --- a/examples/inter-app-api-showcase/tsconfig.json +++ b/examples/inter-app-api-showcase/tsconfig.json @@ -1,5 +1,15 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] }, - "include": ["app"] + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "types": ["bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["wrnexus.workspace.ts", "packages/**/*.ts"], + "exclude": ["node_modules", "dist", "apps"] } diff --git a/examples/inter-app-api-showcase/wrnexus.workspace.ts b/examples/inter-app-api-showcase/wrnexus.workspace.ts new file mode 100644 index 00000000..3a1dd77e --- /dev/null +++ b/examples/inter-app-api-showcase/wrnexus.workspace.ts @@ -0,0 +1,57 @@ +import type { WorkspaceConfig } from "@wrnexus/cli/workspace"; + +// Map each app to the domains it serves. `wrnexus gateway` runs them all behind +// one port and routes by Host header (add these hosts to your /etc/hosts). +const config: WorkspaceConfig = { + defaultEnvironment: "development", + environments: { + development: { + protocol: "http", + rootDomain: "localhost", + port: 3000, + runtime: "development", + hmr: true, + build: false, + migrate: false, + }, + staging: { + protocol: "https", + rootDomain: "staging.example.com", + port: 443, + runtime: "production", + hmr: false, + build: true, + migrate: true, + }, + production: { + protocol: "https", + rootDomain: "example.com", + port: 443, + runtime: "production", + hmr: false, + build: true, + migrate: true, + }, + }, + // Gateway-wide security (all optional): + security: { + trustedHostsOnly: true, // reject requests for unknown domains + rateLimit: { max: 300, windowMs: 60_000 }, // per client IP + headers: true, // baseline security headers at the edge + accessLog: true, // log host → app, method, path, status + }, + apps: [ + { name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] }, + { + name: "admin", + dir: "apps/admin", + domains: ["admin.localhost"], + // Lock the admin app down at the edge (pick one): + auth: { basic: { user: "admin", pass: "change-me" } }, + // auth: { allowIps: ["127.0.0.1", "::1"] }, + // auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO + }, + ], +}; + +export default config;