Files
WRNexusJS/packages/csr/src/realtime-runtime.ts
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

213 lines
7.8 KiB
TypeScript

/**
* 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 = wrn.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 wrn = (window.wrn = window.wrn || {});
if (wrn.room) return; // already installed
var open = {}; // normalized room+query key -> room connection
function normalizedQuery(query) {
var params = new URLSearchParams(String(query || "").replace(/^\?/, ""));
var pairs = [];
params.forEach(function (value, name) { pairs.push([name, value]); });
pairs.sort(function (a, b) {
return a[0] === b[0] ? String(a[1]).localeCompare(String(b[1])) : String(a[0]).localeCompare(String(b[0]));
});
var normalized = new URLSearchParams();
pairs.forEach(function (pair) { normalized.append(pair[0], pair[1]); });
return normalized.toString();
}
function roomKey(name, query) {
var normalized = normalizedQuery(query);
return String(name || "") + (normalized ? "?" + normalized : "");
}
function openRoom(name, query) {
query = normalizedQuery(query);
var key = roomKey(name, query);
if (open[key]) return open[key];
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,
key: key,
query: query,
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[key]; },
};
open[key] = api;
connect();
return api;
}
wrn.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) {
var name = el.getAttribute("data-room");
var user = el.getAttribute("data-room-user");
var query = el.getAttribute("data-room-query") || (user ? "user=" + encodeURIComponent(user) : "");
var key = roomKey(name, query);
if (el.__wrnRoomBound && el.__wrnRoomKey === key) return;
el.__wrnRoomBound = true;
el.__wrnRoomKey = key;
var room = openRoom(name, query);
el.__wrnRoom = 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.__wrnRoomForm) {
form.__wrnRoomForm = 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;
}
if (el.__wrnRoom) el.__wrnRoom.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++) {
bindContainer(containers[i]);
if (containers[i].__wrnRoomKey) present[containers[i].__wrnRoomKey] = true;
}
// Close rooms whose container has left the page (client-side navigation).
for (var nm in open) if (!present[nm]) open[nm].close();
}
wrn.bindRooms = bindAll;
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bindAll(document); });
else bindAll(document);
window.addEventListener("wrnexus:navigated", function () { bindAll(document); });
})();
`.trim();