diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 78887f5b..3d29a755 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index 895695b8..21bdce31 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 140a16ac..88a5b48b 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.18", + "version": "0.2.19", "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.18", - "@wrnexus/router": "^0.2.18", - "@wrnexus/csr": "^0.2.18", - "@wrnexus/compiler": "^0.2.18", - "@wrnexus/styles": "^0.2.18", - "@wrnexus/dev-server": "^0.2.18", - "@wrnexus/ui": "^0.2.18", - "@wrnexus/validation": "^0.2.18", - "@wrnexus/i18n": "^0.2.18", - "@wrnexus/db": "^0.2.18" + "@wrnexus/core": "^0.2.19", + "@wrnexus/router": "^0.2.19", + "@wrnexus/csr": "^0.2.19", + "@wrnexus/compiler": "^0.2.19", + "@wrnexus/styles": "^0.2.19", + "@wrnexus/dev-server": "^0.2.19", + "@wrnexus/ui": "^0.2.19", + "@wrnexus/validation": "^0.2.19", + "@wrnexus/i18n": "^0.2.19", + "@wrnexus/db": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index f22ab6f5..b259433c 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/core/package.json b/.publish/core/package.json index c7a20a3a..85e6645a 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 2b5ef12d..3e7ec4a0 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.18" + "@wrnexus/core": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index 505b2809..39d6693d 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/db — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/dev-server/README.md b/.publish/dev-server/README.md index 105e193f..6e45c204 100644 --- a/.publish/dev-server/README.md +++ b/.publish/dev-server/README.md @@ -142,7 +142,7 @@ interface ProdOptions { ### `startGateway(opts)` — multi-app gateway -Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes). +Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, the gateway supervises every child: editing a page, component, API, or middleware restarts only that app and the HMR client reconnects to display the latest page. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes). ```ts interface GatewayOptions { diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index 198608d8..706d7fc1 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,18 +25,18 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.18", - "@wrnexus/router": "^0.2.18", - "@wrnexus/ssr": "^0.2.18", - "@wrnexus/csr": "^0.2.18", - "@wrnexus/compiler": "^0.2.18", - "@wrnexus/styles": "^0.2.18", - "@wrnexus/ui": "^0.2.18", - "@wrnexus/validation": "^0.2.18", - "@wrnexus/i18n": "^0.2.18", - "@wrnexus/db": "^0.2.18", - "@wrnexus/pubsub": "^0.2.18", - "@wrnexus/uploader": "^0.2.18" + "@wrnexus/core": "^0.2.19", + "@wrnexus/router": "^0.2.19", + "@wrnexus/ssr": "^0.2.19", + "@wrnexus/csr": "^0.2.19", + "@wrnexus/compiler": "^0.2.19", + "@wrnexus/styles": "^0.2.19", + "@wrnexus/ui": "^0.2.19", + "@wrnexus/validation": "^0.2.19", + "@wrnexus/i18n": "^0.2.19", + "@wrnexus/db": "^0.2.19", + "@wrnexus/pubsub": "^0.2.19", + "@wrnexus/uploader": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index dbbb5487..011c8d61 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 04f8ef0f..5e674bad 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.18" + "@wrnexus/core": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index b7c77cca..efccad28 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.18" + "@wrnexus/core": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index d25533f4..c639446e 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/jwt — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index fa454978..226d4109 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.18" + "@wrnexus/native": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index b203cf6a..a1c40fac 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index e93ef3a0..b797e882 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index fe75fe97..c095f509 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 732142e5..2f30fc68 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index e9fce405..874523db 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/router/package.json b/.publish/router/package.json index 886d3de8..9871430e 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.18", - "@wrnexus/core": "^0.2.18" + "@wrnexus/compiler": "^0.2.19", + "@wrnexus/core": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 99a48ac3..36357c00 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.18" + "@wrnexus/core": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 61fac9aa..f1b25262 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.18" + "@wrnexus/uploader": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/test/package.json b/.publish/test/package.json index da25dc1d..9540d59b 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index 2901320c..4918e6d0 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 2a6abc59..5326e5df 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.18", + "version": "0.2.19", "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.18" + "@wrnexus/core": "^0.2.19" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 62966d87..1a2f44e0 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.18" + "@wrnexus/core": "^0.2.19" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index cca2e0a0..ff67ddd5 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 0a734c54..ac623fbf 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.0 + +- Added native Format Document and format-on-save support for `.wrn` files. +- Added configurable formatter enablement through `wrnexus.format.enable`. + ## 0.1.0 - Initial Marketplace package for WrNexus `.wrn` language support. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 5dfafe80..031f6357 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -21,6 +21,8 @@ Bun-powered full-stack framework. `entity.other.attribute-name.wrn.directive` and `…wire.event`). - **Diagnostics.** Parse errors from the real `@wrnexus/compiler` are shown inline as you type, anchored to the exact offset. +- **Formatting.** Format Document and format-on-save normalize WRN block, HTML, + CSS, and function indentation without rewriting application expressions. - **Snippets.** `page`, `component`, `view`, `state`, `props`, `seo`, `api`, `ssr`, `client`, `realtime`, `functions`, `style`, plus view helpers `mount`, `for`, `show`, `t`. @@ -29,9 +31,10 @@ Bun-powered full-stack framework. ## Settings -| Setting | Default | Description | -| ---------------------------- | ------- | ----------------------------------- | -| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. | +| Setting | Default | Description | +| ---------------------------- | ------- | ----------------------------------------- | +| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. | +| `wrnexus.format.enable` | `true` | Enable the built-in formatter for `.wrn`. | ## Building the diagnostics engine diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 9ce29756..463fb864 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "wrnexus", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrnexus", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@vscode/vsce": "^3.9.2" diff --git a/editors/vscode/package.json b/editors/vscode/package.json index f7c6905a..5c10b7fd 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -1,8 +1,8 @@ { "name": "wrnexus", "displayName": "WrNexus (.wrn)", - "description": "Syntax highlighting, diagnostics, snippets and completions for WrNexus .wrn files.", - "version": "0.1.0", + "description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.", + "version": "0.2.0", "publisher": "wrnexus", "license": "MIT", "repository": { @@ -25,7 +25,8 @@ "categories": [ "Programming Languages", "Snippets", - "Linters" + "Linters", + "Formatters" ], "keywords": [ "wire", @@ -90,6 +91,11 @@ "type": "boolean", "default": true, "description": "Show parse errors for .wrn files as you type." + }, + "wrnexus.format.enable": { + "type": "boolean", + "default": true, + "description": "Enable the built-in formatter for .wrn files." } } }, diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index f32379e2..dd1e430b 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -40,10 +40,13 @@ var __export = (target, all) => { var exports_src = {}; __export(exports_src, { parse: () => parse, + generateNative: () => generateNative, generate: () => generate, compileWireFile: () => compileWireFile, + compileNativeWireFile: () => compileNativeWireFile, compile: () => compile, ParseError: () => ParseError, + NativeCompileError: () => NativeCompileError, Lexer: () => Lexer, LexError: () => LexError }); @@ -678,7 +681,7 @@ function attrValue(attrs, name) { } function renderAttr(attr) { if (attr.event) - return ` data-on-${attr.name}="${attrEscape(attr.value)}"`; + return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; switch (attr.name) { case "api": case "ssrGet": @@ -690,6 +693,13 @@ function renderAttr(attr) { return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`; } } +function eventAttribute(name) { + if (name.startsWith("browser-")) + return `data-on-wrnexus-browser-${name.slice(8)}`; + if (name.startsWith("mobile-")) + return `data-on-wrnexus-mobile-${name.slice(7)}`; + return `data-on-${name}`; +} function renderAttrs(attrs, csrId) { const rendered = attrs.map(renderAttr).join(""); return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered; @@ -768,7 +778,7 @@ function renderLoopBody(node) { if (node.type === "if") return compileIfExpr(node); const attrs = node.attrs.map((a) => { - const name = a.event ? `data-on-${a.name}` : a.name; + const name = a.event ? eventAttribute(a.name) : a.name; if (a.boolean) return escLit(` ${name}`); return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`); @@ -1176,7 +1186,7 @@ function renderComponentNode(node, ctx) { if (node.type === "each" || node.type === "if") { throw new Error("Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client)."); } - const attrs = node.attrs.map((a) => a.event ? ` data-on-${a.name}="${compileAttrValue(a.value, ctx)}"` : a.boolean ? ` ${a.name}` : ` ${a.name}="${compileAttrValue(a.value, ctx)}"`).join(""); + const attrs = node.attrs.map((a) => a.event ? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"` : a.boolean ? ` ${a.name}` : ` ${a.name}="${compileAttrValue(a.value, ctx)}"`).join(""); const loops = loopVarsOf(node); const childCtx = loops.length > 0 ? { ...ctx, loopVars: new Set([...ctx.loopVars ?? [], ...loops]) } : ctx; if (VOID_ELEMENTS.has(node.tag.toLowerCase())) @@ -1262,7 +1272,211 @@ function __wireAttr(v: any): string { `; } +// ../../packages/compiler/src/native-codegen.ts +class NativeCompileError extends Error { + constructor(message) { + super(message); + this.name = "NativeCompileError"; + } +} +var tagMap = { + div: "View", + main: "View", + section: "View", + article: "View", + nav: "View", + header: "View", + footer: "View", + aside: "View", + form: "View", + ul: "View", + ol: "View", + li: "View", + p: "Text", + span: "Text", + strong: "Text", + em: "Text", + small: "Text", + label: "Text", + h1: "Text", + h2: "Text", + h3: "Text", + h4: "Text", + h5: "Text", + h6: "Text", + button: "Pressable", + a: "Pressable", + input: "TextInput", + textarea: "TextInput", + img: "Image", + view: "View", + text: "Text", + pressable: "Pressable", + textinput: "TextInput", + image: "Image", + scrollview: "ScrollView", + safeareaview: "SafeAreaView", + flatlist: "FlatList", + activityindicator: "ActivityIndicator" +}; +var attrMap = { + class: "style", + className: "style", + src: "source", + alt: "accessibilityLabel", + placeholder: "placeholder", + disabled: "disabled", + value: "value", + href: "__href", + "aria-label": "accessibilityLabel" +}; +function expression(value) { + const exact = /^\{([\s\S]+)\}$/.exec(value.trim()); + return exact?.[1]?.trim() ?? null; +} +function textJsx(value) { + const pieces = []; + let last = 0; + for (const match of value.matchAll(/\{([^{}]+)\}/g)) { + if (match.index > last) + pieces.push(value.slice(last, match.index)); + const expr = match[1].trim(); + pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`); + last = match.index + match[0].length; + } + pieces.push(value.slice(last)); + return pieces.join("").replace(/([<>])/g, (char) => char === "<" ? "<" : ">"); +} +function eventBody(value, states) { + let body = expression(value) ?? value; + for (const state of states) { + const cap = state[0].toUpperCase() + state.slice(1); + body = body.replace(new RegExp(`\\b${state}\\+\\+`, "g"), `set${cap}(value => value + 1)`).replace(new RegExp(`\\b${state}--`, "g"), `set${cap}(value => value - 1)`).replace(new RegExp(`\\b${state}\\s*=\\s*([^;]+)`, "g"), `set${cap}($1)`); + } + return `() => { ${body} }`; +} +function renderAttrs2(attrs, states) { + return attrs.map((attr) => { + if (attr.event) { + if (attr.name.startsWith("browser-")) + return ""; + const eventName = attr.name.startsWith("mobile-") ? attr.name.slice(7) : attr.name; + const event = eventName === "click" || eventName === "press" ? "onPress" : eventName === "input" || eventName === "change" ? "onChangeText" : `on${eventName[0].toUpperCase()}${eventName.slice(1)}`; + return ` ${event}={${eventBody(attr.value, states)}}`; + } + if (attr.name === "data-native-browser" || attr.name.startsWith("data-native-on-browser-")) + return ""; + if (attr.name === "data-native-options" || attr.name === "data-native-only" || attr.name === "data-native-requires" || attr.name === "data-native-unsupported") + return ""; + if (attr.name === "data-native-mobile") { + throw new NativeCompileError(`Declarative native capability "${attr.value}" currently targets browser/Capacitor pages. In Expo output, call the installed Expo package from an @mobile-event handler.`); + } + const name = attrMap[attr.name] ?? attr.name; + if (name === "__href") + return ` onPress={() => router.push(${JSON.stringify(attr.value)})}`; + if (name === "source") { + const expr2 = expression(attr.value); + return ` source={${expr2 ? `{ uri: ${expr2} }` : `{ uri: ${JSON.stringify(attr.value)} }`}}`; + } + if (name === "style" && attr.name !== "style") { + return ` style={[${attr.value.split(/\s+/).filter(Boolean).map((value) => `styles[${JSON.stringify(value)}]`).join(", ")} ]}`; + } + if (name === "style") { + const inlineExpression = expression(attr.value); + if (inlineExpression) + return ` style={${inlineExpression}}`; + throw new NativeCompileError('Inline CSS strings are not portable to native; use class="name" and a page style block'); + } + if (attr.boolean) + return ` ${name}`; + const expr = expression(attr.value); + return expr ? ` ${name}={${expr}}` : ` ${name}=${JSON.stringify(attr.value)}`; + }).join(""); +} +function renderNode2(node, states, key) { + if (node.type === "text") + return textJsx(node.value); + if (node.type === "each") { + const params = node.index ? `${node.item}, ${node.index}` : `${node.item}, __index`; + const body = node.body.map((child, index) => renderNode2(child, states, index === 0 ? node.index ?? "__index" : undefined)).join(""); + const empty = node.empty.map((child) => renderNode2(child, states)).join(""); + return `{(${node.list})?.length ? (${node.list}).map((${params}) => <>${body}) : <>${empty}}`; + } + if (node.type === "if") { + const result = node.branches.reduceRight((fallback, branch) => branch.cond === null ? `<>${branch.body.map((child) => renderNode2(child, states)).join("")}` : `(${branch.cond}) ? <>${branch.body.map((child) => renderNode2(child, states)).join("")} : ${fallback}`, "null"); + return `{${result}}`; + } + const nativeOnly = node.attrs.find((attr) => !attr.event && attr.name === "data-native-only")?.value; + if (nativeOnly === "browser" || nativeOnly === "web") + return ""; + const nativeTag = tagMap[node.tag.toLowerCase()] ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined); + if (!nativeTag) + throw new NativeCompileError(`HTML element <${node.tag}> has no native equivalent`); + const attrs = renderAttrs2(node.attrs, states) + (key ? ` key={${key}}` : ""); + if (nativeTag === "TextInput" || nativeTag === "Image" || nativeTag === "ActivityIndicator") + return `<${nativeTag}${attrs} />`; + const children = node.children.map((child) => { + if (child.type !== "text") + return renderNode2(child, states); + if (!child.value.trim()) + return ""; + const text = textJsx(child.value); + return nativeTag === "Text" ? text : `${text}`; + }).join(""); + return `<${nativeTag}${attrs}>${children}`; +} +function nativeStyles(blocks) { + const entries = []; + for (const block of blocks) { + for (const match of block.matchAll(/\.([A-Za-z_][\w-]*)\s*\{([^}]*)\}/g)) { + const props = []; + for (const declaration of match[2].split(";")) { + const colon = declaration.indexOf(":"); + if (colon < 0) + continue; + const name = declaration.slice(0, colon).trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + let value = declaration.slice(colon + 1).trim(); + if (/^-?\d+(?:\.\d+)?px$/.test(value)) + value = Number(value.slice(0, -2)); + props.push(`${JSON.stringify(name)}: ${typeof value === "number" ? value : JSON.stringify(value)}`); + } + entries.push(`${JSON.stringify(match[1])}: { ${props.join(", ")} }`); + } + } + return `const styles = StyleSheet.create({ ${entries.join(`, +`)} });`; +} +function generateNative(ast) { + if (ast.kind !== "page") + throw new NativeCompileError("Native route compilation currently accepts page files only"); + if (ast.dataApis.length) + throw new NativeCompileError("Data API blocks are not yet portable to native screens; fetch through the generated native backend helper"); + const states = new Set(ast.states.map((state) => state.name)); + const hooks = ast.states.map((state) => { + const cap = state.name[0].toUpperCase() + state.name.slice(1); + return ` const [${state.name}, set${cap}] = useState(${state.expr});`; + }).join(` +`); + const body = ast.view.map((node) => renderNode2(node, states)).join(""); + return `// generated from .wrn for Expo/React Native +import React, { useState } from "react"; +import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; +import { useRouter } from "expo-router"; + +export default function ${ast.name}() { + const router = useRouter(); +${hooks} + return <>${body}; +} + +${nativeStyles(ast.styles)} +`; +} + // ../../packages/compiler/src/index.ts +function compileNativeWireFile(source) { + return generateNative(parse(source)); +} function compileWireFile(source) { const ast = parse(source); return `// compiled from .wrn diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js index 0a3331e7..a85b0af3 100644 --- a/editors/vscode/src/extension.js +++ b/editors/vscode/src/extension.js @@ -2,6 +2,7 @@ "use strict"; const vscode = require("vscode"); +const { formatWrn } = require("./formatter.js"); // The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded // defensively so the rest of the extension (highlighting, snippets, completion) @@ -112,6 +113,17 @@ function activate(context) { // Completions. context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider("wrn", { + provideDocumentFormattingEdits(document, options) { + if (!vscode.workspace.getConfiguration("wrnexus").get("format.enable", true)) return []; + const formatted = formatWrn(document.getText(), options); + if (formatted === document.getText()) return []; + const end = document.positionAt(document.getText().length); + return [ + vscode.TextEdit.replace(new vscode.Range(new vscode.Position(0, 0), end), formatted), + ]; + }, + }), vscode.languages.registerCompletionItemProvider( "wrn", { provideCompletionItems: provideCompletions }, diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js new file mode 100644 index 00000000..a8eba438 --- /dev/null +++ b/editors/vscode/src/formatter.js @@ -0,0 +1,117 @@ +"use strict"; + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +/** Count structural curly braces while ignoring quoted strings and line comments. */ +function curlyDelta(line, state) { + let delta = 0; + let quote = null; + let escaped = false; + + for (let index = 0; index < line.length; index++) { + const char = line[index]; + const next = line[index + 1]; + + if (state.blockComment) { + if (char === "*" && next === "/") { + state.blockComment = false; + index++; + } + continue; + } + if (state.template) { + if (!escaped && char === "`") state.template = false; + escaped = !escaped && char === "\\"; + if (char !== "\\") escaped = false; + continue; + } + if (quote) { + if (!escaped && char === quote) quote = null; + escaped = !escaped && char === "\\"; + if (char !== "\\") escaped = false; + continue; + } + if (char === "/" && next === "*") { + state.blockComment = true; + index++; + } else if (char === "/" && next === "/") { + break; + } else if (char === "`") { + state.template = true; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === "{") { + delta++; + } else if (char === "}") { + delta--; + } + } + return delta; +} + +/** Return the net nesting introduced by HTML-like tags on this line. */ +function htmlDelta(line) { + let delta = 0; + const tags = line.matchAll(/<\s*(\/?)\s*([A-Za-z][\w:-]*)\b[^>]*>/g); + for (const match of tags) { + const full = match[0]; + const closing = match[1] === "/"; + const name = match[2].toLowerCase(); + if (closing) delta--; + else if (!VOID_ELEMENTS.has(name) && !/\/\s*>$/.test(full)) delta++; + } + return delta; +} + +function leadingClosers(line) { + let count = /^\s*}/.test(line) ? 1 : 0; + const tags = line.match(/^\s*((?:<\/\s*[A-Za-z][\w:-]*\s*>\s*)+)/); + if (tags) count += [...tags[1].matchAll(/<\//g)].length; + return count; +} + +/** + * Format WRN source conservatively: normalize structural indentation and + * trailing whitespace without rewriting expressions, HTML, CSS, or JS. + */ +function formatWrn(text, options = {}) { + const tabSize = Math.max(1, Number(options.tabSize) || 2); + const unit = options.insertSpaces === false ? "\t" : " ".repeat(tabSize); + const hadFinalNewline = /\r?\n$/.test(text); + const lines = text.replace(/\r\n/g, "\n").split("\n"); + if (hadFinalNewline) lines.pop(); + + let depth = 0; + const state = { blockComment: false, template: false }; + const formatted = lines.map((original) => { + const line = original.trimEnd(); + if (!line.trim()) return ""; + + // Preserve multiline template-string content because its whitespace can be data. + const wasTemplate = state.template; + const trimmed = line.trimStart(); + const indent = Math.max(0, depth - leadingClosers(trimmed)); + const output = wasTemplate ? line : unit.repeat(indent) + trimmed; + depth = Math.max(0, depth + curlyDelta(trimmed, state) + htmlDelta(trimmed)); + return output; + }); + + return formatted.join("\n") + (hadFinalNewline ? "\n" : ""); +} + +module.exports = { formatWrn }; diff --git a/editors/vscode/test/validate.mjs b/editors/vscode/test/validate.mjs index 1166c41d..01e26e41 100644 --- a/editors/vscode/test/validate.mjs +++ b/editors/vscode/test/validate.mjs @@ -58,5 +58,19 @@ try { bad("compiler bundle loads", e.message); } +// 4. Formatter normalizes nested WRN blocks and is idempotent. +try { + const { formatWrn } = require(join(root, "src/formatter.js")); + const source = `page Home {\nview {\n
\n

Hello

\n
\n}\n}\n`; + const expected = `page Home {\n view {\n
\n

Hello

\n
\n }\n}\n`; + const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2 }); + formatted === expected ? ok("formatter indents WRN blocks") : bad("formatter indents WRN blocks"); + formatWrn(formatted, { insertSpaces: true, tabSize: 2 }) === formatted + ? ok("formatter is idempotent") + : bad("formatter is idempotent"); +} catch (e) { + bad("formatter loads", e.message); +} + console.log(failures === 0 ? "\nAll extension checks passed." : `\n${failures} check(s) failed.`); process.exit(failures === 0 ? 0 : 1); diff --git a/editors/vscode/wrnexus-0.2.0.vsix b/editors/vscode/wrnexus-0.2.0.vsix new file mode 100644 index 00000000..e34cf5d5 Binary files /dev/null and b/editors/vscode/wrnexus-0.2.0.vsix differ diff --git a/packages/ai/package.json b/packages/ai/package.json index 8e4fed92..c3c9fbe7 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", diff --git a/packages/authz/package.json b/packages/authz/package.json index 2e57a6cc..e25ee529 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 709abc86..3c98b707 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/create.ts b/packages/cli/src/create.ts index ba81269e..258aa29a 100644 --- a/packages/cli/src/create.ts +++ b/packages/cli/src/create.ts @@ -51,7 +51,9 @@ mobile/.expo/ # Editors and operating systems .idea/ -.vscode/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json *.swp *.swo .DS_Store @@ -132,7 +134,14 @@ const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); export default tseslint.config( { - ignores: ["node_modules/**", "dist/**", ".wrnexus/**", "**/.wrnexus/**", "mobile/android/**", "mobile/ios/**"], + ignores: [ + "node_modules/**", + "dist/**", + ".wrnexus/**", + "**/.wrnexus/**", + "mobile/android/**", + "mobile/ios/**", + ], }, { languageOptions: { @@ -187,6 +196,23 @@ indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true +`, + ".vscode/settings.json": `{ + "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 + } +} +`, + ".vscode/extensions.json": `{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} `, "wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles"; @@ -274,7 +300,13 @@ Allow: / @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); body { - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; } `, "app/pages/index.wrn": `// Home page (route: /). SSR-first: the view is server-rendered, then components diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index a5662f7e..0ee51c17 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -172,6 +172,87 @@ const MIGRATIONS: Migration[] = [ if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8"); }, }, + { + version: "0.2.19", + id: "vscode-formatting", + description: "Add safe VS Code format-on-save defaults and extension recommendations", + apply(ctx) { + const vscode = join(ctx.appRoot, ".vscode"); + const settings = join(vscode, "settings.json"); + const extensions = join(vscode, "extensions.json"); + const prettierIgnore = join(ctx.appRoot, ".prettierignore"); + const gitignore = join(ctx.appRoot, ".gitignore"); + + const files: Array<[string, string, string]> = [ + [ + settings, + JSON.stringify( + { + "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, + }, + }, + null, + 2, + ) + "\n", + ".vscode/settings.json", + ], + [ + extensions, + JSON.stringify( + { + recommendations: [ + "wrnexus.wrnexus", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + ], + }, + null, + 2, + ) + "\n", + ".vscode/extensions.json", + ], + ]; + + for (const [file, contents, label] of files) { + if (existsSync(file)) continue; + ctx.log(`+ ${label}`); + if (!ctx.dryRun) { + mkdirSync(vscode, { recursive: true }); + writeFileSync(file, contents, "utf8"); + } + } + + const prettier = existsSync(prettierIgnore) ? readFileSync(prettierIgnore, "utf8") : ""; + if (!new Set(prettier.split(/\r?\n/).map((line) => line.trim())).has("CLAUDE.md")) { + ctx.log("+ .prettierignore: CLAUDE.md"); + if (!ctx.dryRun) { + writeFileSync(prettierIgnore, `${prettier.replace(/\s*$/, "")}\nCLAUDE.md\n`, "utf8"); + } + } + + if (existsSync(gitignore)) { + const current = readFileSync(gitignore, "utf8"); + const lines = current.split(/\r?\n/); + const vscodeIgnore = lines.findIndex((line) => line.trim() === ".vscode/"); + const needed = [".vscode/*", "!.vscode/settings.json", "!.vscode/extensions.json"]; + if (vscodeIgnore >= 0 || needed.some((entry) => !lines.includes(entry))) { + ctx.log("~ .gitignore: track shared VS Code configuration"); + if (!ctx.dryRun) { + const filtered = lines.filter((line) => line.trim() !== ".vscode/"); + const have = new Set(filtered.map((line) => line.trim())); + for (const entry of needed) if (!have.has(entry)) filtered.push(entry); + writeFileSync(gitignore, filtered.join("\n").replace(/\n*$/, "\n"), "utf8"); + } + } + } + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ @@ -246,6 +327,9 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri "wrnexus.config.mjs", "tsconfig.json", ".gitignore", + ".prettierignore", + ".vscode/settings.json", + ".vscode/extensions.json", "CLAUDE.md", "public/llms.txt", ]) { diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index 4f3fffe7..bead6d81 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { format } from "prettier"; import { scaffoldApp } from "../src/create.ts"; import { currentCliVersion } from "../src/update-notifier.ts"; @@ -69,6 +70,15 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", () expect(existsSync(join(root, "public", "llms.txt"))).toBe(true); expect(existsSync(join(root, "llms.txt"))).toBe(false); expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md"); + expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toContain( + '"editor.formatOnSave": true', + ); + expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toContain( + '"editor.defaultFormatter": "wrnexus.wrnexus"', + ); + expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toContain( + "wrnexus.wrnexus", + ); expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toContain( 'href="/api/hello"', ); @@ -77,3 +87,34 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", () rmSync(parent, { recursive: true, force: true }); } }); + +test("scaffoldApp emits Prettier-compliant editor and framework files", async () => { + const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-")); + const root = join(parent, "formatted-app"); + + try { + scaffoldApp(root, "formatted-app"); + for (const relative of [ + "app/styles/global.css", + "eslint.config.js", + ".vscode/settings.json", + ".vscode/extensions.json", + ]) { + const file = join(root, relative); + const source = readFileSync(file, "utf8"); + const formatted = await format(source, { + filepath: file, + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + trailingComma: "all", + endOfLine: "lf", + }); + expect(formatted).toBe(source); + } + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts index e9e9bc3b..2274db00 100644 --- a/packages/cli/test/update.test.ts +++ b/packages/cli/test/update.test.ts @@ -61,3 +61,35 @@ test("0.2.18 migration adds helpers to existing apps and is idempotent", () => { rmSync(root, { recursive: true, force: true }); } }); + +test("0.2.19 migration adds VS Code formatting defaults without overwriting settings", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-update-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + mkdirSync(join(root, ".vscode"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "editor-app", wrnexus: { version: "0.2.18" } }), + ); + writeFileSync(join(root, ".gitignore"), ".vscode/\n"); + writeFileSync(join(root, ".prettierignore"), "node_modules/\n"); + writeFileSync(join(root, ".vscode", "settings.json"), '{"editor.tabSize":4}\n'); + + try { + updateApp(root, "0.2.19", false); + updateApp(root, "0.2.19", false); + + expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toBe( + '{"editor.tabSize":4}\n', + ); + expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toContain( + "wrnexus.wrnexus", + ); + expect(readFileSync(join(root, ".prettierignore"), "utf8")).toBe("node_modules/\nCLAUDE.md\n"); + const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); + expect(gitignore).not.toContain(".vscode/\n"); + expect(gitignore).toContain("!.vscode/settings.json"); + expect(gitignore).toContain("!.vscode/extensions.json"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 1eb26f61..f1fff46f 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index 79ca1ee0..b0a0758d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index 444d96d3..3e17a63d 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 541fc3ee..747d1341 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/README.md b/packages/dev-server/README.md index 105e193f..6e45c204 100644 --- a/packages/dev-server/README.md +++ b/packages/dev-server/README.md @@ -142,7 +142,7 @@ interface ProdOptions { ### `startGateway(opts)` — multi-app gateway -Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes). +Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, the gateway supervises every child: editing a page, component, API, or middleware restarts only that app and the HMR client reconnects to display the latest page. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes). ```ts interface GatewayOptions { diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 42017e87..0ed95623 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index a40f4bf1..3bd20258 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -12,6 +12,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { RESTART_EXIT_CODE } from "./restart.ts"; /** Per-app access control, enforced at the gateway before proxying. */ export interface GatewayAuth { @@ -71,7 +72,7 @@ export interface RunningGateway { interface Target extends GatewayApp { port: number; origin: string; - child: ChildProcess; + child?: ChildProcess; } interface WsBridge { @@ -81,6 +82,18 @@ interface WsBridge { queue: (string | ArrayBufferLike | ArrayBufferView)[]; } +/** Decide whether a gateway child should be relaunched after it exits. */ +export function gatewayRestartDelay( + mode: "development" | "production", + code: number | null, + signal: NodeJS.Signals | null, +): number | null { + if (mode !== "development" || signal) return null; + if (code === RESTART_EXIT_CODE) return 0; + if (code && code !== 0) return 1200; + return null; +} + /** Fixed-window rate limiter keyed by client IP. */ function makeRateLimiter(max: number, windowMs: number) { const hits = new Map(); @@ -196,9 +209,9 @@ function applyEdgeHeaders(res: Response): Response { async function waitReady(target: Target, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs; for (;;) { - if (target.child.exitCode !== null) { + if (!target.child || target.child.exitCode !== null) { throw new Error( - `app '${target.name}' exited with code ${target.child.exitCode} during startup`, + `app '${target.name}' exited with code ${target.child?.exitCode ?? "unknown"} during startup`, ); } try { @@ -245,28 +258,52 @@ export async function startGateway(opts: GatewayOptions): Promise { const appPort = app.port ?? port + 1 + i; const dir = resolve(app.dir); - const child = - mode === "production" - ? spawn(process.execPath, [join(dir, "dist", "server.js")], { - stdio: "inherit", - env: { ...process.env, PORT: String(appPort) }, - }) - : spawn( - process.execPath, - [serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"], - { + const target: Target = { + ...app, + port: appPort, + origin: `http://127.0.0.1:${appPort}`, + }; + + const launch = (): void => { + if (stopping) return; + const child = + mode === "production" + ? spawn(process.execPath, [join(dir, "dist", "server.js")], { stdio: "inherit", - }, - ); - return { ...app, port: appPort, origin: `http://127.0.0.1:${appPort}`, child }; + env: { ...process.env, PORT: String(appPort) }, + }) + : spawn( + process.execPath, + [serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"], + { stdio: "inherit" }, + ); + target.child = child; + + child.once("exit", (code, signal) => { + if (stopping || target.child !== child) return; + const delay = gatewayRestartDelay(mode, code, signal); + if (delay === null) return; + if (delay > 0) { + console.error(`[wrnexus] app '${target.name}' exited (code ${code}); retrying in 1.2s…`); + setTimeout(launch, delay); + } else { + launch(); + } + }); + }; + + launch(); + return target; }); const stopChildren = () => { + stopping = true; for (const target of targets) { - if (target.child.exitCode === null) target.child.kill(); + if (target.child?.exitCode === null) target.child.kill(); } }; diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index c1ededc6..00855b2f 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -28,9 +28,9 @@ import { createHandlers, type WsData } from "./runtime.ts"; import { createDevAssetServer } from "./assets.ts"; import { HmrHub } from "./hmr.ts"; import { startWatcher } from "./watch.ts"; +import { RESTART_EXIT_CODE } from "./restart.ts"; -/** Exit code the child uses to ask the dev supervisor for a fresh process. */ -export const RESTART_EXIT_CODE = 97; +export { RESTART_EXIT_CODE } from "./restart.ts"; export interface ServeOptions { appDir: string; diff --git a/packages/dev-server/src/restart.ts b/packages/dev-server/src/restart.ts new file mode 100644 index 00000000..5ac73cdc --- /dev/null +++ b/packages/dev-server/src/restart.ts @@ -0,0 +1,2 @@ +/** Exit code a dev-server child uses to request a clean supervisor restart. */ +export const RESTART_EXIT_CODE = 97; diff --git a/packages/dev-server/test/gateway.test.ts b/packages/dev-server/test/gateway.test.ts index 81983401..d8b09f93 100644 --- a/packages/dev-server/test/gateway.test.ts +++ b/packages/dev-server/test/gateway.test.ts @@ -4,6 +4,7 @@ import { forwardAuthFailure, forwardAuthHeaders, gatewayProxyHeaders, + gatewayRestartDelay, } from "../src/gateway.ts"; test("gateway uses platform-safe hostname defaults", () => { @@ -56,3 +57,11 @@ test("forward auth describes the original gateway request", () => { expect(headers.get("cookie")).toBe("session=abc"); expect(headers.get("authorization")).toBe("Bearer token"); }); + +test("gateway respawns development apps after an HMR restart exit", () => { + expect(gatewayRestartDelay("development", 97, null)).toBe(0); + expect(gatewayRestartDelay("development", 1, null)).toBe(1200); + expect(gatewayRestartDelay("development", 0, null)).toBeNull(); + expect(gatewayRestartDelay("development", 97, "SIGTERM")).toBeNull(); + expect(gatewayRestartDelay("production", 97, null)).toBeNull(); +}); diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 5189e521..061a518d 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index f549cc34..e0695618 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b56bda94..d4334a2f 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 5996535e..85802952 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 9d134e45..422a7ba3 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 01188db0..e26e722e 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index c10437c5..f6be6bbc 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 341def4e..d5f13b71 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 5f793572..3ce85456 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index f9cfa726..fe5bd702 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index c2c4a0f6..7636c0bc 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index a4222f1e..f51678b3 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index e93c06bd..28a655e2 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.18", + "version": "0.2.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index a4d9f814..30ac988c 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 8e02a8b7..675299ac 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9458e237..ad859b63 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 6a6e4d2f..6952029c 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index ba991f34..e4dca774 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "main": "src/index.ts",