213 lines
7.8 KiB
TypeScript
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();
|