fix: stabilize workspace gateway and scaffolding

This commit is contained in:
2026-07-13 12:54:07 +05:30
parent 6dfef7da3c
commit 88e907783a
63 changed files with 380 additions and 206 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+5
View File
@@ -167,6 +167,11 @@ wrnexus workspace acme
wrnexus gateway --port=3000
```
Development gateways bind to `127.0.0.1` by default for reliable access on Windows,
macOS, and Linux. Open the configured app domain on the gateway port (for example
`http://localhost:3000` or `http://admin.localhost:3000`), not the internal child ports
printed while apps start. Pass `--host=0.0.0.0` to accept connections from other devices.
### `wrnexus test`
Runs the app's tests with `bun test`. Defaults to the `test` profile (config + `.env.test`). Pass `--watch` to re-run on change; extra flags pass straight through to `bun test`.
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.2.15",
"@wrnexus/router": "^0.2.15",
"@wrnexus/csr": "^0.2.15",
"@wrnexus/compiler": "^0.2.15",
"@wrnexus/styles": "^0.2.15",
"@wrnexus/dev-server": "^0.2.15",
"@wrnexus/ui": "^0.2.15",
"@wrnexus/validation": "^0.2.15",
"@wrnexus/i18n": "^0.2.15",
"@wrnexus/db": "^0.2.15"
"@wrnexus/core": "^0.2.16",
"@wrnexus/router": "^0.2.16",
"@wrnexus/csr": "^0.2.16",
"@wrnexus/compiler": "^0.2.16",
"@wrnexus/styles": "^0.2.16",
"@wrnexus/dev-server": "^0.2.16",
"@wrnexus/ui": "^0.2.16",
"@wrnexus/validation": "^0.2.16",
"@wrnexus/i18n": "^0.2.16",
"@wrnexus/db": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.15"
"@wrnexus/core": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+5 -2
View File
@@ -147,7 +147,7 @@ Serves several apps behind one port and routes each request to the right app by
```ts
interface GatewayOptions {
port?: number; // default 3000
hostname?: string; // default "localhost"
hostname?: string; // dev: "127.0.0.1"; production: "0.0.0.0"
mode?: "development" | "production";
apps: GatewayApp[];
security?: GatewaySecurity;
@@ -176,7 +176,10 @@ interface GatewaySecurity {
}
```
The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a `RunningGateway` (`{ port, url, stop() }`).
Open the gateway URL (normally `http://127.0.0.1:3000`), not an app's internal
port. The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a
`RunningGateway` (`{ port, url, stop() }`). Use `--host=0.0.0.0` when other devices need
to reach a development gateway.
### `node:http` adapter (from `./adapters/node.ts`)
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,18 +25,18 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.15",
"@wrnexus/router": "^0.2.15",
"@wrnexus/ssr": "^0.2.15",
"@wrnexus/csr": "^0.2.15",
"@wrnexus/compiler": "^0.2.15",
"@wrnexus/styles": "^0.2.15",
"@wrnexus/ui": "^0.2.15",
"@wrnexus/validation": "^0.2.15",
"@wrnexus/i18n": "^0.2.15",
"@wrnexus/db": "^0.2.15",
"@wrnexus/pubsub": "^0.2.15",
"@wrnexus/uploader": "^0.2.15"
"@wrnexus/core": "^0.2.16",
"@wrnexus/router": "^0.2.16",
"@wrnexus/ssr": "^0.2.16",
"@wrnexus/csr": "^0.2.16",
"@wrnexus/compiler": "^0.2.16",
"@wrnexus/styles": "^0.2.16",
"@wrnexus/ui": "^0.2.16",
"@wrnexus/validation": "^0.2.16",
"@wrnexus/i18n": "^0.2.16",
"@wrnexus/db": "^0.2.16",
"@wrnexus/pubsub": "^0.2.16",
"@wrnexus/uploader": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.15"
"@wrnexus/core": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.2.15"
"@wrnexus/native": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.2.15",
"@wrnexus/core": "^0.2.15"
"@wrnexus/compiler": "^0.2.16",
"@wrnexus/core": "^0.2.16"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.15"
"@wrnexus/core": "^0.2.16"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.2.15"
"@wrnexus/uploader": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.2.15"
"@wrnexus/core": "^0.2.16"
},
"files": [
"dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.15"
"@wrnexus/core": "^0.2.16"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+5
View File
@@ -167,6 +167,11 @@ wrnexus workspace acme
wrnexus gateway --port=3000
```
Development gateways bind to `127.0.0.1` by default for reliable access on Windows,
macOS, and Linux. Open the configured app domain on the gateway port (for example
`http://localhost:3000` or `http://admin.localhost:3000`), not the internal child ports
printed while apps start. Pass `--host=0.0.0.0` to accept connections from other devices.
### `wrnexus test`
Runs the app's tests with `bun test`. Defaults to the `test` profile (config + `.env.test`). Pass `--watch` to re-run on change; extra flags pass straight through to `bun test`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+2 -2
View File
@@ -1,10 +1,10 @@
/**
* Framework conventions shipped into scaffolded apps as CLAUDE.md and llms.txt so
* Framework conventions shipped into scaffolded apps as CLAUDE.md and public/llms.txt so
* AI coding tools (Claude Code, Cursor, Copilot) generate correct WrNexus code.
* Generated from the repo-root llms.txt - do not edit by hand.
*/
/** The canonical WrNexus conventions reference (shipped as llms.txt). */
/** The canonical WrNexus conventions reference (served as /llms.txt). */
export const AI_GUIDE = `# WrNexus
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
+35 -8
View File
@@ -8,12 +8,15 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { currentCliVersion } from "./update-notifier.ts";
const frameworkVersion = currentCliVersion();
/** Map of file (relative to app root) -> contents. */
const TEMPLATE: Record<string, string> = {
// AI/agent context: teaches Claude Code / Cursor / Copilot the WrNexus conventions.
"CLAUDE.md": CLAUDE_MD,
"llms.txt": AI_GUIDE,
"public/llms.txt": AI_GUIDE,
".gitignore": `# Dependencies
node_modules/
@@ -64,6 +67,9 @@ Thumbs.db
"version": "0.1.0",
"private": true,
"type": "module",
"wrnexus": {
"version": "${frameworkVersion}"
},
"scripts": {
"dev": "wrnexus dev .",
"build": "wrnexus build .",
@@ -76,14 +82,14 @@ Thumbs.db
"check": "bun run lint && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "^0.2.0",
"@wrnexus/core": "^0.2.0",
"@wrnexus/styles": "^0.2.0",
"@wrnexus/validation": "^0.2.0",
"@wrnexus/db": "^0.2.0"
"@wrnexus/ai": "${frameworkVersion}",
"@wrnexus/core": "${frameworkVersion}",
"@wrnexus/styles": "${frameworkVersion}",
"@wrnexus/validation": "${frameworkVersion}",
"@wrnexus/db": "${frameworkVersion}"
},
"devDependencies": {
"@wrnexus/cli": "^0.2.0",
"@wrnexus/cli": "${frameworkVersion}",
"@eslint/js": "^9.0.0",
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
@@ -306,7 +312,7 @@ page Home {
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
<a href="/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View demo</a>
<a href="/api/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View API</a>
</div>
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
@@ -332,6 +338,27 @@ page Home {
</main>
}
}
`,
"app/pages/about.wrn": `page About {
seo {
title = "About"
description = "Learn how APP_NAME is built with WrNexus."
}
view {
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
<article class="mx-auto max-w-2xl">
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
<h1 class="mt-4 text-4xl font-bold tracking-tight">About APP_NAME</h1>
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
events, components, APIs, and data without switching to another UI framework.
</p>
</article>
</main>
}
}
`,
"app/components/counter.wrn": `// A reusable component. Route: none — mounted inside a page with
// <div data-component="counter" ...props></div>.
+15 -8
View File
@@ -4,7 +4,7 @@
* Upgrade an app (or every app in a workspace) to a WrNexus release:
* 1. Bump every `@wrnexus/*` dependency to the target version.
* 2. `bun install`.
* 3. Refresh framework-owned reference files (llms.txt) and apply any
* 3. Refresh framework-owned reference files (public/llms.txt) and apply any
* versioned, idempotent migrations that newer releases introduce.
* 4. Record the applied version in package.json (`"wrnexus": { version }`).
*
@@ -19,7 +19,7 @@
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { inspectProject } from "./doctor.ts";
@@ -190,11 +190,14 @@ function appVersion(appRoot: string, pkg: Record<string, unknown>): string {
/** Refresh pure framework-owned reference files. Never touches user-edited CLAUDE.md. */
function refreshFrameworkFiles(appRoot: string, dryRun: boolean, log: (m: string) => void): void {
// llms.txt is a generated reference — always safe to overwrite.
const llms = join(appRoot, "llms.txt");
// Public llms.txt is generated and web-visible, so it is safe to refresh.
const llms = join(appRoot, "public", "llms.txt");
if (!existsSync(llms) || readFileSync(llms, "utf8") !== AI_GUIDE) {
log("~ llms.txt refreshed");
if (!dryRun) writeFileSync(llms, AI_GUIDE, "utf8");
log("~ public/llms.txt refreshed");
if (!dryRun) {
mkdirSync(join(appRoot, "public"), { recursive: true });
writeFileSync(llms, AI_GUIDE, "utf8");
}
}
// CLAUDE.md is often user-edited — only create it when absent.
const claude = join(appRoot, "CLAUDE.md");
@@ -223,10 +226,14 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
"tsconfig.json",
".gitignore",
"CLAUDE.md",
"llms.txt",
"public/llms.txt",
]) {
const source = join(appRoot, name);
if (existsSync(source)) cpSync(source, join(backup, name));
if (existsSync(source)) {
const destination = join(backup, name);
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination);
}
}
return backup;
}
+13 -6
View File
@@ -13,8 +13,11 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts";
import { currentCliVersion } from "./update-notifier.ts";
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
const frameworkVersion = currentCliVersion();
export interface WorkspaceApp {
name: string;
dir: string;
@@ -29,7 +32,7 @@ export interface WorkspaceConfig {
security?: GatewaySecurity;
}
const files = (name: string): Record<string, string> => ({
export const workspaceFiles = (name: string): Record<string, string> => ({
"package.json": `{
"name": "${name}",
"private": true,
@@ -40,7 +43,7 @@ const files = (name: string): Record<string, string> => ({
"gateway": "wrnexus gateway"
},
"devDependencies": {
"@wrnexus/cli": "^0.2.0"
"@wrnexus/cli": "${frameworkVersion}"
}
}
`,
@@ -86,7 +89,7 @@ dist/
"main": "src/index.ts",
"exports": { ".": "./src/index.ts" },
"dependencies": {
"@wrnexus/pubsub": "^0.2.0"
"@wrnexus/pubsub": "${frameworkVersion}"
}
}
`,
@@ -124,7 +127,7 @@ ${name}/
\`\`\`bash
bun install
bun run dev # = wrnexus gateway → http://localhost:3000
bun run dev # = wrnexus gateway → http://127.0.0.1:3000
\`\`\`
Add the hosts to your machine (e.g. /etc/hosts):
@@ -133,6 +136,10 @@ 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.
@@ -162,7 +169,7 @@ export function createWorkspace(name: string): void {
process.exit(1);
}
for (const [rel, contents] of Object.entries(files(name))) {
for (const [rel, contents] of Object.entries(workspaceFiles(name))) {
const target = join(root, rel);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents, "utf8");
@@ -198,7 +205,7 @@ export async function runGateway(root: string, args: string[]): Promise<void> {
const portArg = args.find((a) => a.startsWith("--port="));
const hostArg = args.find((a) => a.startsWith("--host="));
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
const hostname = hostArg?.split("=")[1] || "::";
const hostname = hostArg?.split("=")[1];
const mode = args.includes("--prod") ? "production" : "development";
const { startGateway } = await import("@wrnexus/dev-server");
+26
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { scaffoldApp } from "../src/create.ts";
import { currentCliVersion } from "../src/update-notifier.ts";
test("scaffoldApp creates a comprehensive .gitignore", () => {
const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
@@ -49,3 +50,28 @@ test("scaffoldApp includes production build and start scripts", () => {
rmSync(parent, { recursive: true, force: true });
}
});
test("scaffoldApp pins the current framework and exposes llms.txt publicly", () => {
const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
const root = join(parent, "current-app");
try {
scaffoldApp(root, "current-app");
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const version = currentCliVersion();
expect(pkg.wrnexus.version).toBe(version);
for (const dependency of Object.values(pkg.dependencies) as string[]) {
expect(dependency).toBe(version);
}
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
expect(existsSync(join(root, "llms.txt"))).toBe(false);
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toContain(
'href="/api/hello"',
);
expect(existsSync(join(root, "app", "pages", "about.wrn"))).toBe(true);
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
+1
View File
@@ -31,6 +31,7 @@ test("updateApp migrates project files without marking an unverified update comp
const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
expect(gitignore).toContain("!.env.example");
expect(gitignore).toContain("mobile/android/");
expect(readFileSync(join(root, "public", "llms.txt"), "utf8")).toContain("# WrNexus");
} finally {
rmSync(root, { recursive: true, force: true });
}
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test";
import { currentCliVersion } from "../src/update-notifier.ts";
import { workspaceFiles } from "../src/workspace.ts";
test("workspace templates pin the running framework release", () => {
const files = workspaceFiles("acme");
const rootPackage = JSON.parse(files["package.json"]!);
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
const version = currentCliVersion();
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version);
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
expect(files["README.md"]).toContain("internal gateway targets");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+5 -2
View File
@@ -147,7 +147,7 @@ Serves several apps behind one port and routes each request to the right app by
```ts
interface GatewayOptions {
port?: number; // default 3000
hostname?: string; // default "localhost"
hostname?: string; // dev: "127.0.0.1"; production: "0.0.0.0"
mode?: "development" | "production";
apps: GatewayApp[];
security?: GatewaySecurity;
@@ -176,7 +176,10 @@ interface GatewaySecurity {
}
```
The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a `RunningGateway` (`{ port, url, stop() }`).
Open the gateway URL (normally `http://127.0.0.1:3000`), not an app's internal
port. The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a
`RunningGateway` (`{ port, url, stop() }`). Use `--host=0.0.0.0` when other devices need
to reach a development gateway.
### `node:http` adapter (from `./adapters/node.ts`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+153 -97
View File
@@ -170,25 +170,56 @@ function applyEdgeHeaders(res: Response): Response {
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}
async function waitReady(origin: string, timeoutMs = 15000): Promise<void> {
async function waitReady(target: Target, timeoutMs = 15000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (target.child.exitCode !== null) {
throw new Error(
`app '${target.name}' exited with code ${target.child.exitCode} during startup`,
);
}
try {
await fetch(origin + "/__wrnexus/health-probe", { method: "HEAD" });
await fetch(target.origin + "/__wrnexus/health-probe", { method: "HEAD" });
return; // any HTTP response (incl. 404) means the server is up
} catch {
if (Date.now() > deadline) throw new Error(`app at ${origin} did not start in time`);
if (Date.now() > deadline) {
throw new Error(`app '${target.name}' at ${target.origin} did not start in time`);
}
await new Promise((r) => setTimeout(r, 150));
}
}
}
/** Platform-safe defaults: IPv4 loopback in dev, all IPv4 interfaces in production. */
export function defaultGatewayHostname(mode: "development" | "production"): string {
return mode === "production" ? "0.0.0.0" : "127.0.0.1";
}
export function gatewayProxyHeaders(
req: Request,
url: URL,
ip: string,
forwardedHeaders: boolean,
): Headers {
const headers = new Headers(req.headers);
// Bun's internal fetch transparently decompresses response bodies but preserves
// Content-Encoding. Asking child apps for identity encoding prevents clients from
// trying to decompress an already-decoded proxied body.
headers.set("accept-encoding", "identity");
if (forwardedHeaders) {
headers.set("x-forwarded-host", req.headers.get("host") ?? "");
headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
if (ip) headers.set("x-forwarded-for", ip);
}
return headers;
}
/** Boot every app as a child process, then route by Host on one gateway port. */
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
const port = opts.port ?? 3000;
const hostname = opts.hostname ?? "::";
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
const mode = opts.mode ?? "development";
const hostname = opts.hostname ?? defaultGatewayHostname(mode);
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
const targets: Target[] = opts.apps.map((app, i) => {
@@ -207,10 +238,21 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
stdio: "inherit",
},
);
return { ...app, port: appPort, origin: `http://localhost:${appPort}`, child };
return { ...app, port: appPort, origin: `http://127.0.0.1:${appPort}`, child };
});
await Promise.all(targets.map((t) => waitReady(t.origin)));
const stopChildren = () => {
for (const target of targets) {
if (target.child.exitCode === null) target.child.kill();
}
};
try {
await Promise.all(targets.map((target) => waitReady(target)));
} catch (error) {
stopChildren();
throw error;
}
const byHost = new Map<string, Target>();
for (const t of targets) for (const d of t.domains) byHost.set(d.toLowerCase(), t);
@@ -224,107 +266,121 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
: null;
const now = () => Date.now();
const server = Bun.serve<WsBridge>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 50 * 1024 * 1024,
async fetch(req, srv) {
const url = new URL(req.url);
const ip = srv.requestIP(req)?.address ?? "";
const createGatewayServer = () =>
Bun.serve<WsBridge>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 50 * 1024 * 1024,
async fetch(req, srv) {
const url = new URL(req.url);
const ip = srv.requestIP(req)?.address ?? "";
// Health/status endpoint (not proxied).
if (url.pathname === "/__gateway/health") {
return Response.json({
ok: true,
apps: targets.map((t) => ({ name: t.name, domains: t.domains, origin: t.origin })),
});
}
// Health/status endpoint (not proxied).
if (url.pathname === "/__gateway/health") {
return Response.json({
ok: true,
apps: targets.map((t) => ({ name: t.name, domains: t.domains, origin: t.origin })),
});
}
// Edge rate limit (global, by client IP).
if (rateLimit && !rateLimit(ip, now())) {
return new Response("Too Many Requests", { status: 429, headers: { "retry-after": "60" } });
}
// Edge rate limit (global, by client IP).
if (rateLimit && !rateLimit(ip, now())) {
return new Response("Too Many Requests", {
status: 429,
headers: { "retry-after": "60" },
});
}
// Route by Host. Unknown host → 404 when trustedHostsOnly, else first app.
const target =
pick(req.headers.get("host") ?? "") ?? (sec.trustedHostsOnly ? null : targets[0]!);
if (!target) {
return new Response("Unknown host", { status: 404 });
}
// Route by Host. Unknown host → 404 when trustedHostsOnly, else first app.
const target =
pick(req.headers.get("host") ?? "") ?? (sec.trustedHostsOnly ? null : targets[0]!);
if (!target) {
return new Response("Unknown host", { status: 404 });
}
// Per-app access control (basic auth / IP allowlist / forward-auth).
const denied = await checkAuth(target.auth, req, ip);
if (denied) {
// Per-app access control (basic auth / IP allowlist / forward-auth).
const denied = await checkAuth(target.auth, req, ip);
if (denied) {
if (sec.accessLog)
console.log(
`${req.headers.get("host")} ${req.method} ${url.pathname}${denied.status} (${target.name})`,
);
return denied;
}
// WebSocket upgrade → proxy the socket to the app's realtime server.
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ok = srv.upgrade(req, {
data: { origin: target.origin, path: url.pathname + url.search, queue: [] },
});
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = gatewayProxyHeaders(req, url, ip, forwardedHeaders);
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
try {
res = await fetch(target.origin + url.pathname + url.search, {
method: req.method,
headers,
body,
redirect: "manual",
});
} catch {
res = new Response(`Gateway: app '${target.name}' is unavailable.`, { status: 502 });
}
if (sec.headers) res = applyEdgeHeaders(res);
if (sec.accessLog)
console.log(
` ${req.headers.get("host")} ${req.method} ${url.pathname}${denied.status} (${target.name})`,
` ${req.headers.get("host")} ${req.method} ${url.pathname}${res.status} (${target.name})`,
);
return denied;
}
return res;
},
websocket: {
open(ws) {
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
const backend = new WebSocket(backendUrl);
ws.data.backend = backend;
backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m);
ws.data.queue = [];
});
backend.addEventListener("message", (e) => ws.send(e.data as string | ArrayBufferLike));
backend.addEventListener("close", () => ws.close());
backend.addEventListener("error", () => ws.close());
},
message(ws, message) {
const backend = ws.data.backend;
if (backend && backend.readyState === WebSocket.OPEN) backend.send(message);
else ws.data.queue.push(message);
},
close(ws) {
ws.data.backend?.close();
},
},
});
// WebSocket upgrade → proxy the socket to the app's realtime server.
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ok = srv.upgrade(req, {
data: { origin: target.origin, path: url.pathname + url.search, queue: [] },
});
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = new Headers(req.headers);
if (forwardedHeaders) {
headers.set("x-forwarded-host", req.headers.get("host") ?? "");
headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
if (ip) headers.set("x-forwarded-for", ip);
}
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
try {
res = await fetch(target.origin + url.pathname + url.search, {
method: req.method,
headers,
body,
redirect: "manual",
});
} catch {
res = new Response(`Gateway: app '${target.name}' is unavailable.`, { status: 502 });
}
if (sec.headers) res = applyEdgeHeaders(res);
if (sec.accessLog)
console.log(
` ${req.headers.get("host")} ${req.method} ${url.pathname}${res.status} (${target.name})`,
);
return res;
},
websocket: {
open(ws) {
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
const backend = new WebSocket(backendUrl);
ws.data.backend = backend;
backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m);
ws.data.queue = [];
});
backend.addEventListener("message", (e) => ws.send(e.data as string | ArrayBufferLike));
backend.addEventListener("close", () => ws.close());
backend.addEventListener("error", () => ws.close());
},
message(ws, message) {
const backend = ws.data.backend;
if (backend && backend.readyState === WebSocket.OPEN) backend.send(message);
else ws.data.queue.push(message);
},
close(ws) {
ws.data.backend?.close();
},
},
});
let server: ReturnType<typeof createGatewayServer>;
try {
server = createGatewayServer();
} catch (error) {
stopChildren();
if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") {
throw new Error(
`Gateway cannot listen on ${hostname}:${port} because the port is already in use. ` +
`Stop the existing process or run \`wrnexus gateway --port=<another-port>\`.`,
{ cause: error },
);
}
throw error;
}
const stop = () => {
server.stop();
for (const t of targets) t.child.kill();
stopChildren();
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { defaultGatewayHostname, gatewayProxyHeaders } from "../src/gateway.ts";
test("gateway uses platform-safe hostname defaults", () => {
expect(defaultGatewayHostname("development")).toBe("127.0.0.1");
expect(defaultGatewayHostname("production")).toBe("0.0.0.0");
});
test("gateway disables compression for its internal proxy hop", () => {
const request = new Request("http://localhost:3000/path", {
headers: { host: "web.localhost:3000", "accept-encoding": "gzip, deflate" },
});
const headers = gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true);
expect(headers.get("accept-encoding")).toBe("identity");
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
expect(headers.get("x-forwarded-proto")).toBe("http");
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.15",
"version": "0.2.16",
"private": true,
"type": "module",
"main": "src/index.ts",