feat(runtime): add portable runtime support

req: push/009 req: push/010 req: push/011
This commit is contained in:
tmk241
2026-08-17 07:59:04 +02:00
parent e4bd6db13a
commit 353174604e
19 changed files with 1018 additions and 347 deletions
+42
View File
@@ -16,6 +16,7 @@
const busyStates = new WeakMap();
const disabledStates = new WeakMap();
const sseSources = new WeakMap();
const webSockets = new WeakMap();
const atomStores = new WeakMap();
const dragKeys = new WeakMap();
let currentOperationId = null;
@@ -941,6 +942,38 @@
}
}
function bindWebSocket(root) {
const url = root.getAttribute("data-hemx-ws");
if (!url || webSockets.has(root) || typeof WebSocket === "undefined") return;
const href = new URL(url, location.href);
if (href.origin !== location.origin) {
const error = new Error("hemx WebSocket URL must be same-origin");
showError(root, error);
emit(root, "hemx:ws-error", url);
return;
}
href.protocol = href.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(href.href);
socket.binaryType = "arraybuffer";
socket.addEventListener("message", (event) => applyWebSocketMessage(root, event));
socket.addEventListener("error", () => {
showError(root, new Error("hemx WebSocket failed"));
emit(root, "hemx:ws-error", url);
});
webSockets.set(root, socket);
}
function applyWebSocketMessage(root, event) {
try {
if (!(event.data instanceof ArrayBuffer)) throw new Error("hemx WebSocket messages must be binary");
applyBatch(event.data, root);
showError(root, null);
} catch (error) {
showError(root, error);
emit(root, "hemx:error", String(error));
}
}
function duration(value) {
if (!value) return 0;
const match = String(value).trim().match(/^(\d+)(ms|s)?$/);
@@ -1135,6 +1168,9 @@
const source = sseSources.get(root);
if (source) source.close();
sseSources.delete(root);
const socket = webSockets.get(root);
if (socket) socket.close();
webSockets.delete(root);
const observers = revealObservers.get(root);
if (observers) observers.forEach((observer) => observer.disconnect());
revealObservers.delete(root);
@@ -1177,6 +1213,12 @@
} catch (error) {
emit(root, "hemx:sse-error", String(error));
}
try {
bindWebSocket(root);
} catch (error) {
showError(root, error);
emit(root, "hemx:ws-error", String(error));
}
});
new MutationObserver((records) => {
records.forEach((record) => {
+17
View File
@@ -398,6 +398,23 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
}
#[test]
fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/009 req: runtime/001 req: failure/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const webSockets = new WeakMap()"));
assert!(source.contains("const url = root.getAttribute(\"data-hemx-ws\")"));
assert!(source.contains("if (href.origin !== location.origin)"));
assert!(source.contains("href.protocol = href.protocol === \"https:\" ? \"wss:\" : \"ws:\""));
assert!(source.contains("socket.binaryType = \"arraybuffer\""));
assert!(source.contains("if (!(event.data instanceof ArrayBuffer)) throw new Error"));
assert!(source.contains("applyBatch(event.data, root)"));
assert!(source.contains("showError(root, error)"));
assert!(source.contains("emit(root, \"hemx:ws-error\", url)"));
assert!(source.contains("if (socket) socket.close()"));
}
#[test]
fn runtime_page_swaps_lowered_slot_ids() {
// req: wire/001