first commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# @wrnexus/csr
|
||||
|
||||
> The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/csr` holds the three client runtimes that WrNexus serves to the browser. Components are authored as `.wrn` files and rendered on the **server**; this package provides the single, generic runtime that **hydrates** that HTML in the browser — there are no per-component browser bundles. Each runtime is exported as a plain-JS string (no build step, no imports) intended to be served verbatim from a well-known URL:
|
||||
|
||||
- **reactive** at `/__wrnexus/reactive.js` — reactive directives (`data-scope`, `data-text`, `data-for`, …)
|
||||
- **nav** at `/__wrnexus/nav.js` — SPA-style client navigation with graceful fallback
|
||||
- **realtime** at `/__wrnexus/realtime.js` — WebSocket "rooms", declarative or programmatic
|
||||
|
||||
The package itself runs on the server (it just returns strings); the strings it returns run in the browser. A dev/prod server (see `@wrnexus/core`) is responsible for actually serving them.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/csr
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
All exports come from the package root (`@wrnexus/csr`). The runtime source is delivered as strings, so the "API" on the server side is small; the real surface is the browser directives/globals each string installs.
|
||||
|
||||
### Runtime strings
|
||||
|
||||
| Export | Type | Served at | Contents |
|
||||
| ------------------ | -------- | ------------------------ | ------------------------------ |
|
||||
| `REACTIVE_RUNTIME` | `string` | `/__wrnexus/reactive.js` | Reactive directive runtime |
|
||||
| `NAV_RUNTIME` | `string` | `/__wrnexus/nav.js` | Client-side navigation runtime |
|
||||
| `REALTIME_RUNTIME` | `string` | `/__wrnexus/realtime.js` | Realtime rooms runtime |
|
||||
|
||||
### Accessor functions
|
||||
|
||||
Convenience getters that return the same strings.
|
||||
|
||||
```ts
|
||||
getReactiveRuntime(): string // → REACTIVE_RUNTIME
|
||||
getNavRuntime(): string // → NAV_RUNTIME
|
||||
getRealtimeRuntime(): string // → REALTIME_RUNTIME
|
||||
```
|
||||
|
||||
### Browser: reactive directives
|
||||
|
||||
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
|
||||
|
||||
| Directive | Purpose |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
||||
| `data-for="item in list"` (opt. `item, i in list`) | Per-item list rendering template |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
|
||||
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
|
||||
|
||||
Browser globals installed: `window.__wrnexusHydrateScopes(root)` and `window.__wrnexusHydrateCsrFetches(root)` — both idempotent, so re-running after a DOM swap or HMR morph is safe. Both run automatically on `DOMContentLoaded`.
|
||||
|
||||
### Browser: navigation
|
||||
|
||||
Intercepts same-origin `<a>` clicks, fetches the target page, and swaps the `#app` container in place (via `importNode` — not `innerHTML` — so it works under a Trusted-Types CSP), updating history, title, and scroll, then re-hydrates. Cross-origin links, modified clicks, `download`/`data-no-nav`/`rel="external"`/`target` links, non-HTML responses, or a missing `#app` fall back to a full browser navigation.
|
||||
|
||||
- Programmatic navigation: `window.__wrnexusNavigate(url)`
|
||||
- Emits a `wrnexus:navigated` `CustomEvent` (`detail.url`) after each swap
|
||||
- Sends `x-wrnexus-nav: 1` on fetches so the server can return the page fragment
|
||||
- Appends any `/__wrnexus/*` runtime scripts the incoming page needs but the current document lacks
|
||||
|
||||
### Browser: realtime rooms
|
||||
|
||||
Connects to `/realtime/<name>` over WebSocket (`ws`/`wss` chosen from `location.protocol`). Two usage modes.
|
||||
|
||||
Programmatic API via `window.wire`:
|
||||
|
||||
```ts
|
||||
wire.room(name): Room // open (or reuse) a room connection
|
||||
wire.bindRooms(root?) // (re)bind declarative [data-room] containers
|
||||
|
||||
interface Room {
|
||||
name: string;
|
||||
send(obj: object | string): Room; // JSON-stringifies objects; queues until open
|
||||
on(type: string, cb): Room; // filter by msg.type; "*" or a fn = all messages
|
||||
on(cb): Room;
|
||||
close(): Room;
|
||||
}
|
||||
```
|
||||
|
||||
Internal lifecycle messages are emitted to listeners as `{ type }`: `__open`, `__close`, `__error`, and `__raw` (non-JSON frames, with `data`). Reconnect uses exponential backoff capped at 5s; queued sends flush on reconnect.
|
||||
|
||||
Declarative binding (zero JS) on a `data-room="<name>"` container:
|
||||
|
||||
| Attribute | On | Purpose |
|
||||
| ------------------------------------ | --------------- | ------------------------------------------------------------------------ |
|
||||
| `data-room="<name>"` | container | Connect to room `<name>` |
|
||||
| `data-room-user="<id>"` | container | Identify the connection (`?user=<id>`) |
|
||||
| `data-room-log` | element | Where incoming messages are appended |
|
||||
| `<template data-room-item="<type>">` | template | Row template for messages of that `type` (empty = fallback) |
|
||||
| `%field%` | inside template | Placeholder filled from the message field (text/attr only, HTML-escaped) |
|
||||
| `data-room-status` | element | Reflects connection state text (`connected`/`disconnected`/`error`) |
|
||||
| `data-room-status-class` | status element | Base class; a state variant (`is-connected`, …) is appended |
|
||||
| `<form data-room-send>` | form | Submits named fields as a JSON message |
|
||||
| `data-room-reset` | form field | Clears that field after send |
|
||||
|
||||
Rebinds on `wrnexus:navigated` and closes rooms whose container has left the page.
|
||||
|
||||
## Usage
|
||||
|
||||
Server side — serve the runtime strings from your router (example with `Bun.serve`):
|
||||
|
||||
```ts
|
||||
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
|
||||
|
||||
const routes: Record<string, string> = {
|
||||
"/__wrnexus/reactive.js": getReactiveRuntime(),
|
||||
"/__wrnexus/nav.js": getNavRuntime(),
|
||||
"/__wrnexus/realtime.js": getRealtimeRuntime(),
|
||||
};
|
||||
|
||||
Bun.serve({
|
||||
fetch(req) {
|
||||
const body = routes[new URL(req.url).pathname];
|
||||
if (body) {
|
||||
return new Response(body, {
|
||||
headers: { "content-type": "text/javascript; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Browser side — server-rendered HTML that the reactive runtime hydrates:
|
||||
|
||||
```html
|
||||
<div data-scope="count: 0">
|
||||
<button data-on-click="count++">+1</button>
|
||||
<span data-text="count"></span>
|
||||
<p>Total: {{count}}</p>
|
||||
</div>
|
||||
<script src="/__wrnexus/reactive.js"></script>
|
||||
```
|
||||
|
||||
A realtime chat, fully declarative:
|
||||
|
||||
```html
|
||||
<div data-room="lobby" data-room-user="ada">
|
||||
<div data-room-status></div>
|
||||
<ul data-room-log></ul>
|
||||
<template data-room-item="chat"><li>%user%: %text%</li></template>
|
||||
<form data-room-send>
|
||||
<input name="text" data-room-reset />
|
||||
<input type="hidden" name="type" value="chat" />
|
||||
<button>Send</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/__wrnexus/realtime.js"></script>
|
||||
```
|
||||
|
||||
Or drive a room from code:
|
||||
|
||||
```ts
|
||||
const room = wire.room("lobby");
|
||||
room.on("chat", (msg) => console.log(msg.user, msg.text));
|
||||
room.send({ type: "chat", user: "ada", text: "hi" });
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only** on the server (the package integrates with Bun-based WrNexus servers); the emitted strings are plain browser JS with no dependencies.
|
||||
- Browser runtimes are **self-contained** (no imports, no build step) and **idempotent**, so re-hydration after navigation or HMR is safe.
|
||||
- Designed for a **strict CSP**: the reactive expression evaluator avoids `eval`/`new Function` (no `unsafe-eval`), and DOM swaps use `importNode`/attribute writes rather than `innerHTML` (Trusted-Types friendly).
|
||||
- Peer packages: rendered `.wrn` components and the serving layer come from `@wrnexus/core` (the sole dependency); pages are rendered by the WrNexus dev/prod server.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @wrnexus/csr — the browser reactive runtime.
|
||||
*
|
||||
* Components are `.wrn` files rendered on the SERVER (see @wrnexus/dev-server)
|
||||
* and hydrated in the browser by this single, generic runtime — served once at
|
||||
* `/__wrnexus/reactive.js` for any page that contains a `data-scope`. There are
|
||||
* no per-component browser bundles: SSR stays cleanly separated from CSR.
|
||||
*/
|
||||
|
||||
import { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
|
||||
import { NAV_RUNTIME } from "./nav-runtime.ts";
|
||||
import { REALTIME_RUNTIME } from "./realtime-runtime.ts";
|
||||
|
||||
export { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
|
||||
export { NAV_RUNTIME } from "./nav-runtime.ts";
|
||||
export { REALTIME_RUNTIME } from "./realtime-runtime.ts";
|
||||
|
||||
/** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */
|
||||
export function getReactiveRuntime(): string {
|
||||
return REACTIVE_RUNTIME;
|
||||
}
|
||||
|
||||
/** The client-side navigation runtime served at `/__wrnexus/nav.js`. */
|
||||
export function getNavRuntime(): string {
|
||||
return NAV_RUNTIME;
|
||||
}
|
||||
|
||||
/** The realtime client runtime served at `/__wrnexus/realtime.js`. */
|
||||
export function getRealtimeRuntime(): string {
|
||||
return REALTIME_RUNTIME;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Client-side navigation runtime, served at `/__wrnexus/nav.js`.
|
||||
*
|
||||
* Progressive enhancement over normal links: intercepts same-origin `<a>`
|
||||
* clicks, fetches the target page's HTML, swaps the `#app` container in place,
|
||||
* updates history/title/scroll, ensures any framework runtimes the new page
|
||||
* needs are present, and re-hydrates. Anything unexpected (cross-origin,
|
||||
* modified click, non-HTML response, missing `#app`) falls back to a full
|
||||
* browser navigation, so behaviour degrades safely.
|
||||
*
|
||||
* Data "loaders": pages load their data on the server (SSR `api` bindings), so
|
||||
* the fetched HTML already contains fresh data — no separate client loader is
|
||||
* needed. Client-side (`csr`) bindings and reactive scopes re-hydrate after the
|
||||
* swap. Programmatic navigation is exposed as `window.__wrnexusNavigate(url)`.
|
||||
*/
|
||||
|
||||
export const NAV_RUNTIME = String.raw`
|
||||
(function () {
|
||||
if (!window.history || !history.pushState || !window.fetch || !window.DOMParser) return;
|
||||
if (window.__wrnexusNavInstalled) return;
|
||||
window.__wrnexusNavInstalled = true;
|
||||
|
||||
var APP_ID = "app";
|
||||
|
||||
function pathOf(src) { return String(src).split("?")[0]; }
|
||||
|
||||
function isLocalLink(a) {
|
||||
if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-nav")) return false;
|
||||
if (a.target && a.target !== "_self") return false;
|
||||
if (a.origin !== location.origin) return false;
|
||||
var href = a.getAttribute("href");
|
||||
if (!href || href.charAt(0) === "#") return false;
|
||||
var rel = (a.getAttribute("rel") || "").toLowerCase();
|
||||
return rel.indexOf("external") === -1;
|
||||
}
|
||||
|
||||
function loadedScriptPaths() {
|
||||
var set = {};
|
||||
document.querySelectorAll("script[src]").forEach(function (s) {
|
||||
var src = s.getAttribute("src");
|
||||
if (src) set[pathOf(src)] = true;
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
// Append any /__wrnexus/* runtime the incoming page declares but the current
|
||||
// document has not loaded yet. Fresh scripts self-initialise on load.
|
||||
function ensureScripts(doc) {
|
||||
var loaded = loadedScriptPaths();
|
||||
doc.querySelectorAll("script[src]").forEach(function (s) {
|
||||
var src = s.getAttribute("src");
|
||||
if (!src || loaded[pathOf(src)]) return;
|
||||
loaded[pathOf(src)] = true;
|
||||
var el = document.createElement("script");
|
||||
el.src = src;
|
||||
el.async = false; // preserve execution order (e.g. schemas.js before validate.js)
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-hydrate already-loaded runtimes against the swapped-in DOM. Every
|
||||
// entrypoint is idempotent, so this is safe even when a fresh script also
|
||||
// self-initialises the same nodes.
|
||||
function rehydrate() {
|
||||
try { if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); } catch (e) {}
|
||||
try { if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document); } catch (e) {}
|
||||
try { if (window.__wireValidate) window.__wireValidate.init(document); } catch (e) {}
|
||||
}
|
||||
|
||||
function render(html, url, isPop) {
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
var to = doc.getElementById(APP_ID);
|
||||
var from = document.getElementById(APP_ID);
|
||||
if (!to || !from) { location.href = url; return; } // structure mismatch → hard nav
|
||||
if (doc.title) document.title = doc.title;
|
||||
// Swap #app by importing nodes — NOT innerHTML — so it works under a strict
|
||||
// Trusted-Types CSP (require-trusted-types-for 'script').
|
||||
var imported = [];
|
||||
for (var i = 0; i < to.childNodes.length; i++) imported.push(document.importNode(to.childNodes[i], true));
|
||||
from.replaceChildren.apply(from, imported);
|
||||
ensureScripts(doc);
|
||||
rehydrate();
|
||||
if (!isPop) { history.pushState({ wrnexusNav: true }, "", url); window.scrollTo(0, 0); }
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:navigated", { detail: { url: url } }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
var inFlight = null;
|
||||
|
||||
function navigate(url, isPop) {
|
||||
var token = {};
|
||||
inFlight = token;
|
||||
fetch(url, { headers: { "x-wrnexus-nav": "1", accept: "text/html" }, credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (inFlight !== token) return null; // superseded by a newer navigation
|
||||
if (r.redirected && r.url) url = r.url; // follow server redirects (e.g. auth)
|
||||
var ct = r.headers.get("content-type") || "";
|
||||
if (ct.indexOf("text/html") === -1) { location.href = url; return null; }
|
||||
return r.text().then(function (t) {
|
||||
if (inFlight === token) render(t, url, isPop);
|
||||
});
|
||||
})
|
||||
.catch(function () { location.href = url; });
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target && e.target.closest ? e.target.closest("a") : null;
|
||||
if (!isLocalLink(a)) return;
|
||||
if (a.href === location.href) { e.preventDefault(); return; }
|
||||
e.preventDefault();
|
||||
navigate(a.href, false);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
window.addEventListener("popstate", function () {
|
||||
navigate(location.href, true);
|
||||
});
|
||||
|
||||
// Programmatic navigation for forms/actions and app code.
|
||||
window.__wrnexusNavigate = function (url) {
|
||||
navigate(new URL(url, location.href).href, false);
|
||||
};
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Browser reactive runtime (Point 2: reactive directives).
|
||||
*
|
||||
* Served verbatim at `/__wrnexus/reactive.js` for any page that contains a
|
||||
* `data-scope`. It is plain browser JS (no build step) and self-contained: it
|
||||
* inlines a tiny `signal()` so it has no imports to resolve.
|
||||
*
|
||||
* Supported directives (this is exactly what the `.wrn` compiler emits):
|
||||
* data-scope="count: 0, name: 'x'" declare reactive state on a subtree
|
||||
* data-on-<event>="count++" run a statement in scope on an event
|
||||
* data-text="expr" element textContent follows an expression
|
||||
* data-wrnexus-csr="id" target for generated CSR fetch bindings
|
||||
* {{expr}} or {expr} interpolation inside text nodes
|
||||
*
|
||||
* Expressions are evaluated by a tiny parser instead of `eval`/`new Function`,
|
||||
* so production can use a strong CSP without `unsafe-eval`.
|
||||
*/
|
||||
export const REACTIVE_RUNTIME = String.raw`
|
||||
(function () {
|
||||
function signal(initial) {
|
||||
var value = initial;
|
||||
var subs = new Set();
|
||||
return {
|
||||
get: function () { return value; },
|
||||
set: function (v) {
|
||||
if (Object.is(v, value)) return;
|
||||
value = v;
|
||||
subs.forEach(function (f) { f(value); });
|
||||
},
|
||||
subscribe: function (f) { subs.add(f); return function () { subs.delete(f); }; }
|
||||
};
|
||||
}
|
||||
|
||||
function setupScope(el) {
|
||||
if (el.__wrnexusScope) return; // idempotent: safe to call again after an HMR morph
|
||||
el.__wrnexusScope = true;
|
||||
el.__wrnexusHydrated = true; // marks the subtree as client-owned for the HMR morph
|
||||
var decl = el.getAttribute("data-scope") || "";
|
||||
var initial = parseScopeDecl(decl);
|
||||
|
||||
var signals = {};
|
||||
Object.keys(initial).forEach(function (k) { signals[k] = signal(initial[k]); });
|
||||
|
||||
var renderers = [];
|
||||
// Dependency-tracked rendering: while a renderer runs, every signal it reads
|
||||
// subscribes THAT renderer (not a blanket "re-render everything"). A signal
|
||||
// change then re-runs only the renderers that actually read it. The Set in
|
||||
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
|
||||
var currentRenderer = null;
|
||||
function reactive(fn) {
|
||||
function run() {
|
||||
var prev = currentRenderer;
|
||||
currentRenderer = run;
|
||||
try { fn(); } finally { currentRenderer = prev; }
|
||||
}
|
||||
renderers.push(run);
|
||||
return run;
|
||||
}
|
||||
function renderAll() { renderers.forEach(function (f) { f(); }); }
|
||||
|
||||
function readScope(name) {
|
||||
var sig = signals[name];
|
||||
if (!sig) return undefined;
|
||||
if (currentRenderer) sig.subscribe(currentRenderer); // track dependency
|
||||
return sig.get();
|
||||
}
|
||||
function peekScope(name) {
|
||||
return signals[name] ? signals[name].get() : undefined;
|
||||
}
|
||||
|
||||
function evalExpr(expr) {
|
||||
return evaluateExpression(expr, readScope);
|
||||
}
|
||||
function runStmt(stmt) {
|
||||
splitTopLevel(stmt, ";").forEach(function (part) {
|
||||
runStatement(part, function (e) { return evaluateExpression(e, peekScope); }, peekScope, function (name, value) {
|
||||
if (!signals[name]) {
|
||||
signals[name] = signal(value);
|
||||
renderAll(); // new variable: re-run once so readers pick it up + re-track
|
||||
} else {
|
||||
signals[name].set(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A binding belongs to THIS scope only when el is the node's nearest
|
||||
// [data-scope] ancestor. Otherwise a nested scope owns it and we skip it,
|
||||
// so an outer scope never clobbers an inner one's values.
|
||||
function owns(node) {
|
||||
var host = node.nodeType === 1 ? node : node.parentNode;
|
||||
return !!host && host.closest && host.closest("[data-scope]") === el;
|
||||
}
|
||||
|
||||
// --- data-for list rendering -------------------------------------------
|
||||
// Each [data-for="item in list"] element is a per-item template. On any
|
||||
// change to the list (or a dependency an item reads), the list re-renders.
|
||||
function parseFor(value) {
|
||||
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
||||
value || "",
|
||||
);
|
||||
return m ? { item: m[1], index: m[2], list: m[3] } : null;
|
||||
}
|
||||
function fillMustache(str, itemEval) {
|
||||
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
||||
var e = (d || s).trim();
|
||||
try { return String(itemEval(e)); } catch (err) { return ""; }
|
||||
});
|
||||
}
|
||||
function hydrateItem(root, locals) {
|
||||
function localRead(name) {
|
||||
return Object.prototype.hasOwnProperty.call(locals, name) ? locals[name] : peekScope(name);
|
||||
}
|
||||
function itemEval(expr) { return evaluateExpression(expr, localRead); }
|
||||
|
||||
var els = [root];
|
||||
if (root.querySelectorAll) Array.prototype.push.apply(els, root.querySelectorAll("*"));
|
||||
els.forEach(function (n) {
|
||||
if (n.nodeType !== 1) return;
|
||||
Array.prototype.slice.call(n.attributes).forEach(function (attr) {
|
||||
if (attr.name === "data-text") {
|
||||
try { n.textContent = String(itemEval(attr.value)); } catch (e) { /* ignore */ }
|
||||
} else if (attr.name.indexOf("data-on-") === 0) {
|
||||
var evt = attr.name.slice("data-on-".length);
|
||||
var stmt = attr.value;
|
||||
n.addEventListener(evt, function () {
|
||||
try {
|
||||
splitTopLevel(stmt, ";").forEach(function (part) {
|
||||
runStatement(part, itemEval, localRead, function (name, value) {
|
||||
if (Object.prototype.hasOwnProperty.call(locals, name)) locals[name] = value;
|
||||
else if (!signals[name]) { signals[name] = signal(value); renderAll(); }
|
||||
else signals[name].set(value);
|
||||
});
|
||||
});
|
||||
} catch (e) { console.error("[wrnexus] data-for handler error", e); }
|
||||
});
|
||||
} else if (attr.value.indexOf("{") !== -1) {
|
||||
attr.value = fillMustache(attr.value, itemEval);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||||
var tn;
|
||||
while ((tn = walker.nextNode())) {
|
||||
if (tn.nodeValue.indexOf("{") === -1) continue;
|
||||
tn.nodeValue = fillMustache(tn.nodeValue, itemEval);
|
||||
}
|
||||
}
|
||||
|
||||
Array.prototype.slice.call(el.querySelectorAll("[data-for]")).forEach(function (tpl) {
|
||||
if (!tpl.parentNode || !owns(tpl)) return;
|
||||
var spec = parseFor(tpl.getAttribute("data-for"));
|
||||
if (!spec) return;
|
||||
tpl.removeAttribute("data-for");
|
||||
var parent = tpl.parentNode;
|
||||
var marker = document.createComment("wire-for");
|
||||
parent.insertBefore(marker, tpl);
|
||||
parent.removeChild(tpl);
|
||||
var clones = [];
|
||||
reactive(function () {
|
||||
var list = evalExpr(spec.list);
|
||||
if (!list || typeof list.length !== "number") list = [];
|
||||
for (var c = 0; c < clones.length; c++) {
|
||||
if (clones[c].parentNode) clones[c].parentNode.removeChild(clones[c]);
|
||||
}
|
||||
clones = [];
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var clone = tpl.cloneNode(true);
|
||||
var locals = {};
|
||||
locals[spec.item] = list[i];
|
||||
if (spec.index) locals[spec.index] = i;
|
||||
hydrateItem(clone, locals);
|
||||
frag.appendChild(clone);
|
||||
clones.push(clone);
|
||||
}
|
||||
parent.insertBefore(frag, marker.nextSibling);
|
||||
});
|
||||
});
|
||||
|
||||
// data-text bindings
|
||||
el.querySelectorAll("[data-text]").forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
var expr = node.getAttribute("data-text");
|
||||
reactive(function () {
|
||||
try { node.textContent = String(evalExpr(expr)); } catch (e) { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
// data-show="expr" — toggle visibility on truthiness.
|
||||
el.querySelectorAll("[data-show]").forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
var showExpr = node.getAttribute("data-show");
|
||||
reactive(function () {
|
||||
var visible = true;
|
||||
try { visible = !!evalExpr(showExpr); } catch (e) { /* keep visible */ }
|
||||
node.style.display = visible ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// {{expr}} / {expr} interpolation in text nodes
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
||||
var textNode;
|
||||
while ((textNode = walker.nextNode())) {
|
||||
var template = textNode.nodeValue;
|
||||
if (template.indexOf("{") === -1) continue;
|
||||
if (!owns(textNode)) continue;
|
||||
(function (node, tpl) {
|
||||
reactive(function () {
|
||||
node.nodeValue = tpl.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, doubleExpr, singleExpr) {
|
||||
var expr = doubleExpr || singleExpr;
|
||||
try { return String(evalExpr(expr.trim())); } catch (err) { return ""; }
|
||||
});
|
||||
});
|
||||
})(textNode, template);
|
||||
}
|
||||
|
||||
// data-on-<event> handlers, on the scope element and the descendants it owns.
|
||||
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
||||
nodes.forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
||||
if (attr.name.indexOf("data-on-") !== 0) return;
|
||||
var evt = attr.name.slice("data-on-".length);
|
||||
var stmt = attr.value;
|
||||
node.addEventListener(evt, function () {
|
||||
try { runStmt(stmt); } catch (e) {
|
||||
console.error("[wrnexus] handler error in '" + stmt + "'", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function hydrateScopes(root) {
|
||||
(root || document).querySelectorAll("[data-scope]").forEach(setupScope);
|
||||
}
|
||||
|
||||
function parseScopeDecl(decl) {
|
||||
var initial = {};
|
||||
splitTopLevel(decl, ",").forEach(function (part) {
|
||||
var idx = findTopLevel(part, ":");
|
||||
if (idx < 0) return;
|
||||
var name = part.slice(0, idx).trim();
|
||||
var expr = part.slice(idx + 1).trim();
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return;
|
||||
try {
|
||||
initial[name] = evaluateExpression(expr, function () { return undefined; });
|
||||
} catch (e) {
|
||||
console.error("[wrnexus] invalid data-scope value for '" + name + "'", e);
|
||||
}
|
||||
});
|
||||
return initial;
|
||||
}
|
||||
|
||||
function runStatement(stmt, evalExpr, read, write) {
|
||||
stmt = String(stmt || "").trim();
|
||||
if (!stmt) return;
|
||||
|
||||
var inc = stmt.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/);
|
||||
if (inc) {
|
||||
write(inc[1], Number(read(inc[1]) || 0) + (inc[2] === "++" ? 1 : -1));
|
||||
return;
|
||||
}
|
||||
|
||||
var assign = stmt.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+=|-=|\*=|\/=|%=|=)\s*([\s\S]+)$/);
|
||||
if (!assign) {
|
||||
evalExpr(stmt); // bare expression statement (e.g. a method/function call)
|
||||
return;
|
||||
}
|
||||
|
||||
var name = assign[1];
|
||||
var op = assign[2];
|
||||
var next = evalExpr(assign[3]);
|
||||
var current = read(name);
|
||||
if (op === "+=") next = current + next;
|
||||
else if (op === "-=") next = Number(current || 0) - Number(next || 0);
|
||||
else if (op === "*=") next = Number(current || 0) * Number(next || 0);
|
||||
else if (op === "/=") next = Number(current || 0) / Number(next || 0);
|
||||
else if (op === "%=") next = Number(current || 0) % Number(next || 0);
|
||||
write(name, next);
|
||||
}
|
||||
|
||||
// A small, eval-free expression evaluator (so a strict CSP needs no
|
||||
// 'unsafe-eval'). Supports: literals, identifiers, member access (a.b, a[b]),
|
||||
// function/method calls, arrays, objects, arithmetic, comparison, equality,
|
||||
// logical (&& ||), unary (! - +), and the ternary operator.
|
||||
function evaluateExpression(expr, read) {
|
||||
var tokens = tokenizeExpression(String(expr || ""));
|
||||
var index = 0;
|
||||
function peek() { return tokens[index]; }
|
||||
function next() { return tokens[index++]; }
|
||||
function is(v) { return peek() && peek().value === v; }
|
||||
function match(v) { if (is(v)) { index++; return true; } return false; }
|
||||
function expect(v) { if (!match(v)) throw new Error("Expected '" + v + "'"); }
|
||||
|
||||
function parsePrimary() {
|
||||
var t = next();
|
||||
if (!t) throw new Error("Unexpected end of expression");
|
||||
if (t.type === "number" || t.type === "string") return { value: t.value };
|
||||
if (t.type === "ident") {
|
||||
if (t.value === "true") return { value: true };
|
||||
if (t.value === "false") return { value: false };
|
||||
if (t.value === "null") return { value: null };
|
||||
if (t.value === "undefined") return { value: undefined };
|
||||
return { value: read(t.value) };
|
||||
}
|
||||
if (t.value === "(") { var v = parseTernary(); expect(")"); return { value: v }; }
|
||||
if (t.value === "[") {
|
||||
var arr = [];
|
||||
if (!is("]")) { arr.push(parseTernary()); while (match(",")) arr.push(parseTernary()); }
|
||||
expect("]");
|
||||
return { value: arr };
|
||||
}
|
||||
if (t.value === "{") {
|
||||
var obj = {};
|
||||
if (!is("}")) {
|
||||
do {
|
||||
var kt = next();
|
||||
var key = kt.value;
|
||||
expect(":");
|
||||
obj[key] = parseTernary();
|
||||
} while (match(","));
|
||||
}
|
||||
expect("}");
|
||||
return { value: obj };
|
||||
}
|
||||
throw new Error("Unexpected token '" + t.value + "'");
|
||||
}
|
||||
|
||||
function parsePostfix() {
|
||||
var node = parsePrimary();
|
||||
for (;;) {
|
||||
if (match(".")) {
|
||||
var prop = next().value;
|
||||
node = { value: node.value == null ? undefined : node.value[prop], obj: node.value };
|
||||
} else if (match("[")) {
|
||||
var key = parseTernary();
|
||||
expect("]");
|
||||
node = { value: node.value == null ? undefined : node.value[key], obj: node.value };
|
||||
} else if (is("(")) {
|
||||
next();
|
||||
var args = [];
|
||||
if (!is(")")) { args.push(parseTernary()); while (match(",")) args.push(parseTernary()); }
|
||||
expect(")");
|
||||
var fn = node.value;
|
||||
node = { value: typeof fn === "function" ? fn.apply(node.obj, args) : undefined };
|
||||
} else break;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function parseUnary() {
|
||||
if (match("!")) return !parseUnary();
|
||||
if (match("-")) return -parseUnary();
|
||||
if (match("+")) return +parseUnary();
|
||||
return parsePostfix().value;
|
||||
}
|
||||
function parseMul() {
|
||||
var l = parseUnary();
|
||||
while (peek() && (is("*") || is("/") || is("%"))) {
|
||||
var op = next().value, r = parseUnary();
|
||||
l = op === "*" ? l * r : op === "/" ? l / r : l % r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseAdd() {
|
||||
var l = parseMul();
|
||||
while (peek() && (is("+") || is("-"))) {
|
||||
var op = next().value, r = parseMul();
|
||||
l = op === "+" ? l + r : l - r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseCmp() {
|
||||
var l = parseAdd();
|
||||
while (peek() && (is("<") || is(">") || is("<=") || is(">="))) {
|
||||
var op = next().value, r = parseAdd();
|
||||
l = op === "<" ? l < r : op === ">" ? l > r : op === "<=" ? l <= r : l >= r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseEq() {
|
||||
var l = parseCmp();
|
||||
while (peek() && (is("==") || is("!=") || is("===") || is("!=="))) {
|
||||
var op = next().value, r = parseCmp();
|
||||
l = op === "==" ? l == r : op === "!=" ? l != r : op === "===" ? l === r : l !== r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseAnd() {
|
||||
var l = parseEq();
|
||||
while (match("&&")) l = l && parseEq();
|
||||
return l;
|
||||
}
|
||||
function parseOr() {
|
||||
var l = parseAnd();
|
||||
while (match("||")) l = l || parseAnd();
|
||||
return l;
|
||||
}
|
||||
function parseTernary() {
|
||||
var c = parseOr();
|
||||
if (match("?")) { var a = parseTernary(); expect(":"); var b = parseTernary(); return c ? a : b; }
|
||||
return c;
|
||||
}
|
||||
|
||||
var value = parseTernary();
|
||||
if (index < tokens.length) throw new Error("Unexpected token '" + tokens[index].value + "'");
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenizeExpression(input) {
|
||||
var tokens = [];
|
||||
var i = 0;
|
||||
while (i < input.length) {
|
||||
var ch = input[i];
|
||||
if (/\s/.test(ch)) { i++; continue; }
|
||||
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(input[i + 1]))) {
|
||||
var start = i++;
|
||||
while (i < input.length && /[0-9.]/.test(input[i])) i++;
|
||||
tokens.push({ type: "number", value: Number(input.slice(start, i)) });
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
var quote = ch, value = "";
|
||||
i++;
|
||||
while (i < input.length) {
|
||||
ch = input[i++];
|
||||
if (ch === quote) break;
|
||||
if (ch === "\\") {
|
||||
var esc = input[i++];
|
||||
value += esc === "n" ? "\n" : esc === "t" ? "\t" : esc || "";
|
||||
} else value += ch;
|
||||
}
|
||||
tokens.push({ type: "string", value: value });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_$]/.test(ch)) {
|
||||
var s = i++;
|
||||
while (i < input.length && /[A-Za-z0-9_$]/.test(input[i])) i++;
|
||||
tokens.push({ type: "ident", value: input.slice(s, i) });
|
||||
continue;
|
||||
}
|
||||
var three = input.substr(i, 3);
|
||||
if (three === "===" || three === "!==") { tokens.push({ type: "op", value: three }); i += 3; continue; }
|
||||
var two = input.substr(i, 2);
|
||||
if (["==", "!=", "<=", ">=", "&&", "||"].indexOf(two) !== -1) {
|
||||
tokens.push({ type: "op", value: two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("()+-*/%!<>.,?:[]{}".indexOf(ch) !== -1) { tokens.push({ type: "op", value: ch }); i++; continue; }
|
||||
throw new Error("Unexpected character '" + ch + "'");
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function splitTopLevel(input, separator) {
|
||||
var parts = [];
|
||||
var start = 0;
|
||||
var depth = 0;
|
||||
var quote = "";
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var ch = input[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
||||
else if (ch === separator && depth === 0) {
|
||||
parts.push(input.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(input.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function findTopLevel(input, needle) {
|
||||
var depth = 0;
|
||||
var quote = "";
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var ch = input[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
||||
else if (ch === needle && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function safeCsrUrl(id) {
|
||||
try {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) return null;
|
||||
return "/__wrnexus/csr?route=" + encodeURIComponent(location.pathname) + "&id=" + encodeURIComponent(id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupCsrFetch(el) {
|
||||
if (el.__wrnexusCsrFetch) return;
|
||||
el.__wrnexusCsrFetch = true;
|
||||
|
||||
var id = el.getAttribute("data-wrnexus-csr") || "";
|
||||
var url = safeCsrUrl(id);
|
||||
if (!url) {
|
||||
console.error("[wrnexus] blocked unsafe CSR binding '" + id + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
var headers = { "accept": "text/plain" };
|
||||
var storage = localStorageSnapshotHeader();
|
||||
if (storage) headers["x-wrnexus-local-storage"] = storage;
|
||||
|
||||
fetch(url, { headers: headers })
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.text();
|
||||
})
|
||||
.then(function (text) {
|
||||
el.textContent = text;
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("[wrnexus] CSR fetch failed for binding '" + id + "'", err);
|
||||
el.textContent = "Failed to load";
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateCsrFetches(root) {
|
||||
(root || document).querySelectorAll("[data-wrnexus-csr]").forEach(setupCsrFetch);
|
||||
}
|
||||
|
||||
function localStorageSnapshotHeader() {
|
||||
try {
|
||||
if (!("localStorage" in window)) return "";
|
||||
var values = {};
|
||||
for (var i = 0; i < window.localStorage.length; i++) {
|
||||
var key = window.localStorage.key(i);
|
||||
if (!key) continue;
|
||||
var value = window.localStorage.getItem(key);
|
||||
if (typeof value === "string") values[key] = value;
|
||||
}
|
||||
var encoded = encodeURIComponent(JSON.stringify(values));
|
||||
return encoded.length <= 12000 ? encoded : "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
window.__wrnexusHydrateScopes = hydrateScopes;
|
||||
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
hydrateScopes(document);
|
||||
hydrateCsrFetches(document);
|
||||
});
|
||||
} else {
|
||||
hydrateScopes(document);
|
||||
hydrateCsrFetches(document);
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Client realtime runtime, served at `/__wrnexus/realtime.js`.
|
||||
*
|
||||
* Two ways to use it — no hand-written WebSocket code either way:
|
||||
*
|
||||
* 1. Declarative (zero JS). Put `data-room="<name>"` on a container; the runtime
|
||||
* connects, appends incoming messages to `[data-room-log]` using a
|
||||
* `<template data-room-item="<type>">` (fields via `%field%`, HTML-escaped),
|
||||
* reflects connection state on `[data-room-status]`, and sends a
|
||||
* `<form data-room-send>`'s named fields as JSON on submit (fields marked
|
||||
* `data-room-reset` clear after send). Optional `data-room-user` identifies
|
||||
* the connection.
|
||||
*
|
||||
* 2. Programmatic: `const room = wire.room("chat"); room.on("chat", fn);
|
||||
* room.send({ type: "chat", text })`. Handles connect, JSON, reconnect.
|
||||
*
|
||||
* Rebinds on `wrnexus:navigated` (client-side nav) and closes rooms whose
|
||||
* container has left the page.
|
||||
*/
|
||||
|
||||
export const REALTIME_RUNTIME = String.raw`
|
||||
(function () {
|
||||
if (!("WebSocket" in window)) return;
|
||||
var wire = (window.wire = window.wire || {});
|
||||
if (wire.room) return; // already installed
|
||||
var open = {}; // name -> room connection
|
||||
|
||||
function openRoom(name, query) {
|
||||
if (open[name]) return open[name];
|
||||
var ws = null, queue = [], listeners = [], attempts = 0, timer = null, closed = false;
|
||||
|
||||
function url() {
|
||||
var proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
var q = query ? "?" + query : "";
|
||||
return proto + "://" + location.host + "/realtime/" + name + q;
|
||||
}
|
||||
function emit(msg) {
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
var l = listeners[i];
|
||||
if (!l.type || l.type === "*" || l.type === msg.type) {
|
||||
try { l.cb(msg); } catch (e) { console.error("[wrnexus] room '" + name + "' listener error", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
ws = new WebSocket(url());
|
||||
ws.onopen = function () {
|
||||
attempts = 0;
|
||||
for (var i = 0; i < queue.length; i++) ws.send(queue[i]);
|
||||
queue = [];
|
||||
emit({ type: "__open" });
|
||||
};
|
||||
ws.onclose = function () {
|
||||
ws = null;
|
||||
emit({ type: "__close" });
|
||||
if (!closed) {
|
||||
var delay = Math.min(5000, 400 * Math.pow(2, attempts++));
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
ws.onerror = function () { emit({ type: "__error" }); };
|
||||
ws.onmessage = function (e) {
|
||||
var msg;
|
||||
try { msg = JSON.parse(e.data); } catch (_) { msg = { type: "__raw", data: e.data }; }
|
||||
emit(msg);
|
||||
};
|
||||
}
|
||||
|
||||
var api = {
|
||||
name: name,
|
||||
send: function (obj) {
|
||||
var payload = typeof obj === "string" ? obj : JSON.stringify(obj);
|
||||
if (ws && ws.readyState === 1) ws.send(payload);
|
||||
else queue.push(payload);
|
||||
return api;
|
||||
},
|
||||
on: function (type, cb) {
|
||||
if (typeof type === "function") { cb = type; type = "*"; }
|
||||
listeners.push({ type: type, cb: cb });
|
||||
return api;
|
||||
},
|
||||
close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[name]; },
|
||||
};
|
||||
open[name] = api;
|
||||
connect();
|
||||
return api;
|
||||
}
|
||||
wire.room = openRoom;
|
||||
|
||||
// --- Declarative binding ---------------------------------------------------
|
||||
|
||||
// Fill %field% placeholders in a cloned template fragment WITHOUT innerHTML
|
||||
// (setting text/attr values, never parsing HTML) — so it works under a strict
|
||||
// Trusted-Types CSP, and message text can never be interpreted as markup.
|
||||
function subst(str, msg) {
|
||||
return str.replace(/%(\w+)%/g, function (_, k) {
|
||||
return msg[k] == null ? "" : String(msg[k]);
|
||||
});
|
||||
}
|
||||
function fillNode(node, msg) {
|
||||
if (node.nodeType === 3) {
|
||||
if (node.nodeValue.indexOf("%") !== -1) node.nodeValue = subst(node.nodeValue, msg);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType === 1 && node.attributes) {
|
||||
for (var i = 0; i < node.attributes.length; i++) {
|
||||
var a = node.attributes[i];
|
||||
if (a.value.indexOf("%") !== -1) a.value = subst(a.value, msg);
|
||||
}
|
||||
}
|
||||
var kids = node.childNodes;
|
||||
for (var j = 0; j < kids.length; j++) fillNode(kids[j], msg);
|
||||
}
|
||||
|
||||
function bindContainer(el) {
|
||||
if (el.__wireRoomBound) return;
|
||||
el.__wireRoomBound = true;
|
||||
var name = el.getAttribute("data-room");
|
||||
var user = el.getAttribute("data-room-user");
|
||||
var room = openRoom(name, user ? "user=" + encodeURIComponent(user) : "");
|
||||
el.__wireRoom = room;
|
||||
|
||||
var log = el.querySelector("[data-room-log]");
|
||||
var status = el.querySelector("[data-room-status]");
|
||||
var templates = {};
|
||||
var tnodes = el.querySelectorAll("template[data-room-item]");
|
||||
for (var i = 0; i < tnodes.length; i++) {
|
||||
templates[tnodes[i].getAttribute("data-room-item") || ""] = tnodes[i];
|
||||
}
|
||||
|
||||
function setStatus(text, variant) {
|
||||
if (!status) return;
|
||||
status.textContent = text;
|
||||
if (status.hasAttribute("data-room-status-class")) {
|
||||
status.className = status.getAttribute("data-room-status-class") + " " + variant;
|
||||
}
|
||||
}
|
||||
|
||||
room.on("*", function (msg) {
|
||||
if (msg.type === "__open") return setStatus("connected", "is-connected");
|
||||
if (msg.type === "__close") return setStatus("disconnected", "is-disconnected");
|
||||
if (msg.type === "__error") return setStatus("error", "is-error");
|
||||
if (!log) return;
|
||||
var tpl = templates[msg.type];
|
||||
if (tpl == null) tpl = templates[""];
|
||||
if (tpl == null || !tpl.content) return; // no template for this type
|
||||
var frag = tpl.content.cloneNode(true);
|
||||
fillNode(frag, msg);
|
||||
log.appendChild(frag);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
|
||||
var form = el.querySelector("form[data-room-send]");
|
||||
if (form && !form.__wireRoomForm) {
|
||||
form.__wireRoomForm = true;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var data = {};
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
var input = form.elements[i];
|
||||
if (input.name) data[input.name] = input.value;
|
||||
}
|
||||
room.send(data);
|
||||
for (var j = 0; j < form.elements.length; j++) {
|
||||
if (form.elements[j].hasAttribute("data-room-reset")) form.elements[j].value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindAll(root) {
|
||||
var containers = (root || document).querySelectorAll("[data-room]");
|
||||
var present = {};
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
present[containers[i].getAttribute("data-room")] = true;
|
||||
bindContainer(containers[i]);
|
||||
}
|
||||
// Close rooms whose container has left the page (client-side navigation).
|
||||
for (var nm in open) if (!present[nm]) open[nm].close();
|
||||
}
|
||||
|
||||
wire.bindRooms = bindAll;
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bindAll(document); });
|
||||
else bindAll(document);
|
||||
window.addEventListener("wrnexus:navigated", function () { bindAll(document); });
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
||||
|
||||
let win: any;
|
||||
let fetchCalls: { url: string; opts: any }[];
|
||||
let nextHtml: string;
|
||||
|
||||
function install(bodyHtml: string): void {
|
||||
win = new Window({ url: "https://example.test/" });
|
||||
win.document.body.innerHTML = bodyHtml;
|
||||
fetchCalls = [];
|
||||
nextHtml = "";
|
||||
const g = globalThis as any;
|
||||
g.window = win;
|
||||
g.document = win.document;
|
||||
g.history = win.history;
|
||||
g.location = win.location;
|
||||
g.DOMParser = win.DOMParser;
|
||||
g.CustomEvent = win.CustomEvent;
|
||||
g.fetch = win.fetch = (url: string, opts: any) => {
|
||||
fetchCalls.push({ url, opts });
|
||||
return Promise.resolve({
|
||||
redirected: false,
|
||||
url,
|
||||
headers: {
|
||||
get: (k: string) =>
|
||||
k.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null,
|
||||
},
|
||||
text: () => Promise.resolve(nextHtml),
|
||||
});
|
||||
};
|
||||
(0, eval)(NAV_RUNTIME);
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
const g = globalThis as any;
|
||||
for (const k of [
|
||||
"window",
|
||||
"document",
|
||||
"history",
|
||||
"location",
|
||||
"DOMParser",
|
||||
"CustomEvent",
|
||||
"fetch",
|
||||
]) {
|
||||
delete g[k];
|
||||
}
|
||||
});
|
||||
|
||||
test("intercepts an internal link click and swaps #app in place", async () => {
|
||||
install(`<div id="app"><h1>Home</h1><a href="/about" id="lnk">About</a></div>`);
|
||||
nextHtml =
|
||||
`<!doctype html><html><head><title>About</title></head>` +
|
||||
`<body><div id="app"><h1>About page</h1></div></body></html>`;
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(1);
|
||||
expect(fetchCalls[0]!.url).toContain("/about");
|
||||
expect(fetchCalls[0]!.opts.headers["x-wrnexus-nav"]).toBe("1");
|
||||
expect(win.document.getElementById("app").innerHTML).toContain("About page");
|
||||
expect(win.document.title).toBe("About");
|
||||
});
|
||||
|
||||
test("ignores cross-origin links (full navigation)", async () => {
|
||||
install(`<div id="app"><a href="https://other.test/x" id="lnk">x</a></div>`);
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("ignores modified clicks so new-tab still works", async () => {
|
||||
install(`<div id="app"><a href="/about" id="lnk">x</a></div>`);
|
||||
win.document
|
||||
.getElementById("lnk")
|
||||
.dispatchEvent(
|
||||
new win.MouseEvent("click", { bubbles: true, cancelable: true, button: 0, metaKey: true }),
|
||||
);
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("exposes programmatic navigation", async () => {
|
||||
install(`<div id="app"><h1>Home</h1></div>`);
|
||||
nextHtml = `<html><head><title>Dash</title></head><body><div id="app"><h1>Dashboard</h1></div></body></html>`;
|
||||
expect(typeof win.__wrnexusNavigate).toBe("function");
|
||||
win.__wrnexusNavigate("/dashboard");
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(1);
|
||||
expect(win.document.getElementById("app").innerHTML).toContain("Dashboard");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
|
||||
// Fresh DOM per test, with the runtime's globals bound.
|
||||
function mount(html: string): Window {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
||||
// test window may not fire). setupScope is idempotent, so this is safe.
|
||||
const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
|
||||
w.__wrnexusHydrateScopes?.(win.document);
|
||||
return win as unknown as Window;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
});
|
||||
|
||||
test("hydrates {expr} mustaches from data-scope", () => {
|
||||
const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
|
||||
});
|
||||
|
||||
test("@event (data-on-click) mutates a signal and re-renders", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
|
||||
);
|
||||
const btn = win.document.querySelector("button")!;
|
||||
expect(btn.textContent).toBe("0");
|
||||
btn.click();
|
||||
btn.click();
|
||||
expect(btn.textContent).toBe("2");
|
||||
});
|
||||
|
||||
test("nested scopes don't clobber each other (regression)", () => {
|
||||
// An empty outer scope must not touch inner scopes' values.
|
||||
const win = mount(
|
||||
`<div data-scope="">
|
||||
<div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
|
||||
<div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
|
||||
</div>`,
|
||||
);
|
||||
const [a, b] = Array.from(win.document.querySelectorAll("button"));
|
||||
expect(a!.textContent).toBe("A0");
|
||||
expect(b!.textContent).toBe("B10");
|
||||
a!.click();
|
||||
expect(a!.textContent).toBe("A1");
|
||||
expect(b!.textContent).toBe("B10"); // unchanged
|
||||
});
|
||||
|
||||
test("data-text binds an element's textContent to an expression", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
|
||||
);
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("9");
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("15");
|
||||
});
|
||||
|
||||
test("string scope values bind via data-text", () => {
|
||||
const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("hi");
|
||||
});
|
||||
|
||||
test("data-for renders a list of objects and reacts to array changes", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
|
||||
<ul><li data-for="t in todos" data-text="t.text"></li></ul>
|
||||
<button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
|
||||
<button id="clear" data-on-click="todos = []">clear</button>
|
||||
</div>`,
|
||||
);
|
||||
const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
|
||||
expect(items()).toEqual(["a", "b"]);
|
||||
(win.document.getElementById("add") as unknown as HTMLElement).click();
|
||||
expect(items()).toEqual(["a", "b", "c"]);
|
||||
(win.document.getElementById("clear") as unknown as HTMLElement).click();
|
||||
expect(items()).toEqual([]);
|
||||
});
|
||||
|
||||
test("data-for exposes item + index, mustaches and member access", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
|
||||
<ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
|
||||
</div>`,
|
||||
);
|
||||
expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
|
||||
"0:x",
|
||||
"1:y",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expression evaluator: member access, ternary, comparison, calls", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
|
||||
<span id="a" data-text="user.name"></span>
|
||||
<span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
|
||||
<span id="c" data-text="items.length"></span>
|
||||
</div>`,
|
||||
);
|
||||
expect(win.document.getElementById("a")!.textContent).toBe("Ada");
|
||||
expect(win.document.getElementById("b")!.textContent).toBe("senior");
|
||||
expect(win.document.getElementById("c")!.textContent).toBe("3");
|
||||
});
|
||||
|
||||
test("data-show toggles visibility on a reactive expression (tabs pattern)", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="tab: 0">
|
||||
<button data-on-click="tab = 1">go</button>
|
||||
<section id="a" data-show="tab === 0">A</section>
|
||||
<section id="b" data-show="tab === 1">B</section>
|
||||
</div>`,
|
||||
);
|
||||
const disp = (id: string) =>
|
||||
(win.document.getElementById(id) as unknown as HTMLElement).style.display;
|
||||
expect(disp("a")).toBe("");
|
||||
expect(disp("b")).toBe("none");
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(disp("a")).toBe("none");
|
||||
expect(disp("b")).toBe("");
|
||||
});
|
||||
|
||||
test("independent signals in one scope update correctly (dependency tracking)", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="a: 0, b: 100">
|
||||
<span id="ta" data-text="a"></span>
|
||||
<span id="tb" data-text="b"></span>
|
||||
<button id="ba" data-on-click="a++">A</button>
|
||||
<button id="bb" data-on-click="b++">B</button>
|
||||
</div>`,
|
||||
);
|
||||
const ta = () => win.document.getElementById("ta")!.textContent;
|
||||
const tb = () => win.document.getElementById("tb")!.textContent;
|
||||
const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
|
||||
expect([ta(), tb()]).toEqual(["0", "100"]);
|
||||
click("ba");
|
||||
click("ba");
|
||||
expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
|
||||
click("bb");
|
||||
expect([ta(), tb()]).toEqual(["2", "101"]);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
||||
|
||||
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||
let sockets: FakeWS[];
|
||||
class FakeWS {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
sockets.push(this);
|
||||
}
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
fireOpen() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
fireMessage(obj: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(obj) });
|
||||
}
|
||||
}
|
||||
|
||||
function boot(bodyHtml: string) {
|
||||
sockets = [];
|
||||
const win = new Window({ url: "http://localhost/" }) as unknown as Window &
|
||||
Record<string, unknown>;
|
||||
win.document.body.innerHTML = bodyHtml;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.window = win;
|
||||
g.document = win.document;
|
||||
g.location = win.location;
|
||||
g.WebSocket = FakeWS;
|
||||
(0, eval)(REALTIME_RUNTIME);
|
||||
return win as unknown as Window;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
||||
delete (globalThis as Record<string, unknown>)[k];
|
||||
}
|
||||
});
|
||||
|
||||
const CHAT = `
|
||||
<div data-room="chat">
|
||||
<span data-room-status data-room-status-class="badge" class="badge">connecting…</span>
|
||||
<div data-room-log></div>
|
||||
<template data-room-item="message"><div class="msg"><strong>%user%</strong>: %text%</div></template>
|
||||
<template data-room-item="system"><div class="sys">%text%</div></template>
|
||||
<form data-room-send>
|
||||
<input name="user" value="Ada">
|
||||
<input name="text" value="hello" data-room-reset>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
test("[data-room] connects to the right URL and reflects status", () => {
|
||||
const win = boot(CHAT);
|
||||
expect(sockets.length).toBe(1);
|
||||
expect(sockets[0]!.url).toBe("ws://localhost/realtime/chat");
|
||||
sockets[0]!.fireOpen();
|
||||
const status = win.document.querySelector("[data-room-status]")!;
|
||||
expect(status.textContent).toBe("connected");
|
||||
expect(status.className).toContain("is-connected");
|
||||
});
|
||||
|
||||
test("incoming messages render via the typed template, HTML-escaped", () => {
|
||||
const win = boot(CHAT);
|
||||
sockets[0]!.fireOpen();
|
||||
sockets[0]!.fireMessage({ type: "message", user: "<b>Ada</b>", text: "hi & bye" });
|
||||
sockets[0]!.fireMessage({ type: "system", text: "joined" });
|
||||
const log = win.document.querySelector("[data-room-log]")!;
|
||||
expect(log.querySelector(".msg strong")!.textContent).toBe("<b>Ada</b>"); // escaped, not parsed
|
||||
expect(log.querySelector(".msg")!.textContent).toBe("<b>Ada</b>: hi & bye");
|
||||
expect(log.querySelector(".sys")!.textContent).toBe("joined");
|
||||
});
|
||||
|
||||
test("submitting [data-room-send] sends JSON and clears reset fields", () => {
|
||||
const win = boot(CHAT);
|
||||
sockets[0]!.fireOpen();
|
||||
const form = win.document.querySelector("form[data-room-send]")! as unknown as HTMLFormElement;
|
||||
form.dispatchEvent(
|
||||
new (win as unknown as { Event: typeof Event }).Event("submit", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(sockets[0]!.sent.length).toBe(1);
|
||||
expect(JSON.parse(sockets[0]!.sent[0]!)).toEqual({ user: "Ada", text: "hello" });
|
||||
// text field had data-room-reset → cleared; user field kept.
|
||||
const inputs = win.document.querySelectorAll("input");
|
||||
expect((inputs[0] as unknown as HTMLInputElement).value).toBe("Ada");
|
||||
expect((inputs[1] as unknown as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
test("programmatic wire.room() sends and receives", () => {
|
||||
const win = boot(`<div></div>`) as unknown as Window & {
|
||||
wire: {
|
||||
room: (n: string) => {
|
||||
on: (t: string, cb: (m: unknown) => void) => unknown;
|
||||
send: (o: unknown) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const got: unknown[] = [];
|
||||
const room = win.wire.room("lobby");
|
||||
room.on("ping", (m: unknown) => got.push(m));
|
||||
sockets[0]!.fireOpen();
|
||||
room.send({ type: "hello" });
|
||||
expect(JSON.parse(sockets[0]!.sent[0]!)).toEqual({ type: "hello" });
|
||||
sockets[0]!.fireMessage({ type: "ping", n: 1 });
|
||||
expect(got).toEqual([{ type: "ping", n: 1 }]);
|
||||
});
|
||||
Reference in New Issue
Block a user