first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+31
View File
@@ -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;
}
+129
View File
@@ -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();
+577
View File
@@ -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();
+188
View File
@@ -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();