Files
hemx/hemx-js/runtime/hemx.js
T
slhx agent 736f9f3c7e fix(js): isolate startup binding failures
Keep the public runtime API available and report state, binding, and SSE startup errors independently so one startup failure cannot hide the runtime object or other roots.

req: runtime/002
2026-06-05 20:34:08 +02:00

828 lines
30 KiB
JavaScript

(() => {
const ROOT = "data-hemx-root";
const HID = "data-hid";
const SID = "data-sid";
const runtimeAbiVersion = 1;
const FINGERPRINT = "data-hemx-fp";
const STATE = "data-hemx-st";
const pending = new WeakMap();
const queues = new WeakMap();
const timers = new WeakMap();
const everyTimers = new WeakMap();
const pendingClassStates = new WeakMap();
const indicatorStates = new WeakMap();
const disabledStates = new WeakMap();
const sseSources = new WeakMap();
const atomStores = new WeakMap();
const dragKeys = new WeakMap();
function roots() {
const found = [];
forEachElement(document, (el) => { if (el.hasAttribute(ROOT)) found.push(el); });
return found;
}
function rootOf(node) {
for (let el = node; el && el !== document; el = el.parentElement) {
if (el.hasAttribute && el.hasAttribute(ROOT)) return el;
}
return null;
}
function formOwner(el) {
if (!el || el.tagName === "FORM") return el;
if (el.getAttribute && el.getAttribute("form")) return elementById(rootOf(el) || document, el.getAttribute("form"));
return closestInRoot(el, rootOf(el) || document, (node) => node.tagName === "FORM");
}
function elementById(scope, id) {
if (attrEquals(scope, "id", id)) return scope;
return firstElement(scope, (el) => attrEquals(el, "id", id));
}
function formHandleId(form) {
if (!form) return null;
const holder = form.hasAttribute(HID) ? form : firstElement(form, (el) => el.hasAttribute(HID));
const raw = holder && holder.getAttribute(HID);
return raw && /^\d+$/.test(raw) ? raw : null;
}
function closestInRoot(start, root, predicate) {
for (let node = start; node && node !== root.parentNode; node = node.parentElement) {
if (predicate(node)) return node;
if (node === root) break;
}
return null;
}
function handleId(el) {
const raw = el && el.getAttribute(HID);
return raw && /^\d+$/.test(raw) ? raw : null;
}
function normalizedPolicy(value) {
return value === "latest" || value === "queue" || value === "drop" || value === "parallel" ? value : null;
}
function requestPolicy(el, eventName) {
const policy = normalizedPolicy(el.getAttribute("data-hemx-policy"));
if (policy) return policy;
if (el.hasAttribute("data-hemx-debounce") || eventName === "input") return "latest";
if (el.tagName === "FORM") return "drop";
return "parallel";
}
function showPending(el, on) {
const klass = el.getAttribute("data-hemx-pending-class");
if (klass) togglePendingClass(el, klass, on);
const root = rootOf(el) || document;
forEachElement(root, (i) => { if (i.hasAttribute("data-hemx-indicator")) toggleIndicator(i, on); });
if (el.hasAttribute("data-hemx-disable-while-pending")) {
const controls = [];
if (isDisableControl(el)) controls.push(el);
forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); });
controls.forEach((c) => toggleDisabled(c, on));
}
}
function togglePendingClass(el, klass, on) {
const state = pendingClassStates.get(el);
if (on) {
if (state) state.count += 1;
else pendingClassStates.set(el, { count: 1, className: klass, hadClass: el.classList.contains(klass) });
el.classList.add(klass);
return;
}
if (!state) return;
state.count -= 1;
if (state.count <= 0) {
if (state.hadClass) el.classList.add(state.className);
else el.classList.remove(state.className);
pendingClassStates.delete(el);
}
}
function toggleIndicator(indicator, on) {
const state = indicatorStates.get(indicator);
if (on) {
if (state) state.count += 1;
else indicatorStates.set(indicator, { count: 1, hidden: indicator.hidden });
indicator.hidden = false;
return;
}
if (!state) return;
state.count -= 1;
if (state.count <= 0) {
indicator.hidden = state.hidden;
indicatorStates.delete(indicator);
}
}
function toggleDisabled(control, on) {
const state = disabledStates.get(control);
if (on) {
if (state) state.count += 1;
else disabledStates.set(control, { count: 1, disabled: control.disabled });
control.disabled = true;
return;
}
if (!state) return;
state.count -= 1;
if (state.count <= 0) {
control.disabled = state.disabled;
disabledStates.delete(control);
}
}
function formDataFor(el, eventName, source = el) {
const form = formOwner(el);
const data = form ? new FormData(form) : new FormData();
if (form && source && source !== form && source.name && !source.disabled) data.append(source.name, source.value);
const id = handleId(el) || formHandleId(form);
if (id && !data.has("__h")) data.set("__h", id);
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
if (dragKey && !data.has("work_id")) data.set("work_id", dragKey);
for (const { name, value } of Array.from(el.attributes || [])) {
if (name.startsWith("data-") && !name.startsWith("data-hemx-") && name !== HID && name !== SID) {
data.set(name.slice(5).replace(/-/g, "_"), value);
}
}
return { form, data, multipart: form && String(form.enctype).toLowerCase() === "multipart/form-data" };
}
function urlEncoded(data) {
const encoded = new URLSearchParams();
for (const [name, value] of data.entries()) {
if (typeof File !== "undefined" && value instanceof File) continue;
encoded.append(name, value);
}
return encoded;
}
function requestBody(data, multipart) {
return multipart ? data : urlEncoded(data);
}
function requestUrl(form, data, method) {
const url = new URL((form && form.action) || location.href, location.href);
if (method === "GET") url.search = urlEncoded(data).toString();
return url.href;
}
async function send(el, eventName, source = el) {
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
const { form, data, multipart } = formDataFor(el, eventName, source);
const target = form || el;
const policy = requestPolicy(target, eventName);
const active = pending.get(target);
if (active && policy === "drop") return;
if (active && policy === "latest") {
active.abort.abort();
showPending(target, false);
}
if (active && policy === "queue") {
const base = queues.get(target) || active.done;
let queued;
const next = base.then(() => send(el, eventName, source));
queued = next.catch(() => {}).finally(() => {
if (queues.get(target) === queued) queues.delete(target);
});
queues.set(target, queued);
return;
}
const abort = new AbortController();
let finish;
const done = new Promise((resolve) => { finish = resolve; });
const method = String((form && form.getAttribute("method")) || "POST").toUpperCase();
const body = method === "GET" || method === "HEAD" ? undefined : requestBody(data, multipart);
const headers = { "X-HEMX-Partial": "1", "Accept": "application/hemx, text/html" };
if (body instanceof URLSearchParams) headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
pending.set(target, { abort, done });
showPending(target, true);
try {
const response = await fetch(requestUrl(form, data, method), {
method,
body,
headers,
credentials: "same-origin",
signal: abort.signal,
});
if (pending.get(target)?.abort !== abort && policy === "latest") return;
await applyResponse(response, rootOf(target));
} catch (error) {
if (error.name !== "AbortError") emit(rootOf(target), "hemx:error", String(error));
} finally {
if (pending.get(target)?.abort === abort) {
pending.delete(target);
showPending(target, false);
} else if (policy === "parallel") {
showPending(target, false);
}
finish();
}
}
async function navigate(anchor, mode = "push") {
const href = anchor.href;
const root = rootOf(anchor);
showPending(anchor, true);
try {
await navigateUrl(href, root, mode);
} finally {
showPending(anchor, false);
}
}
async function navigateUrl(href, root, mode = "replace") {
const response = await fetch(href, {
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
credentials: "same-origin",
});
if (!await applyResponse(response, root)) {
if (mode === "none") location.reload();
else location.href = href;
return;
}
if (mode === "push") history.pushState({ hemx: true }, "", href);
else if (mode === "replace") history.replaceState({ hemx: true }, "", href);
}
async function applyResponse(response, root) {
if (response.redirected) {
location.href = response.url;
return false;
}
if (!compatibleFingerprint(response, root)) {
location.reload();
return false;
}
const type = response.headers.get("content-type") || "";
if (type.includes("text/html")) return applyHtml(await response.text(), root, response.headers.get("x-hemx-title"));
if (type.includes("application/hemx")) {
applyBatch(await response.arrayBuffer(), root);
return true;
}
return false;
}
function compatibleFingerprint(response, root) {
const received = response.headers.get("x-hemx-fingerprint");
const expected = root && root.getAttribute(FINGERPRINT);
return !received || !expected || received === expected;
}
function applyBatch(buffer, root) {
const batch = decodeBatch(buffer);
if (batch.abiVersion !== runtimeAbiVersion) {
location.reload();
return;
}
const expected = root && root.getAttribute(FINGERPRINT);
if (expected && String(batch.fingerprint) !== expected) {
location.reload();
return;
}
const scope = root || document;
const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean);
if (missingTarget) {
missing(scope, missingTarget);
return;
}
for (const op of batch.ops) applyOp(scope, op);
}
function canApplyOp(scope, op) {
if (op.kind === "put" && isAtom(op.target)) return null;
if (op.kind === "put" || op.kind === "focus") return targetFor(scope, op.target) ? null : op.target;
if (op.kind === "insert" || op.kind === "prepend") return targetFor(scope, op.target) ? null : op.target;
if (op.kind === "remove") return (op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target)) ? null : op.target;
if (op.kind === "move") return targetFor(scope, op.target) && keyedTarget(scope, op.target.resource.id, op.key) ? null : op.target;
if (op.kind === "navigate" && op.scroll && op.scroll.kind === "element") return targetFor(scope, op.scroll.target) ? null : op.scroll.target;
return null;
}
function applyOp(scope, op) {
if (op.kind === "put") {
if (isAtom(op.target)) {
atomStore(scope).set(String(op.target.resource.id), op.payload.value);
return true;
}
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
else putPayload(target, op.payload);
} else if (op.kind === "insert" || op.kind === "prepend") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id);
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
} else if (op.kind === "remove") {
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
target.remove();
} else if (op.kind === "move") {
const target = targetFor(scope, op.target);
const item = keyedTarget(scope, op.target.resource.id, op.key);
if (!target || !item) return missing(scope, op.target);
const before = op.before && keyedTarget(scope, op.target.resource.id, op.before);
target.insertBefore(item, before || null);
} else if (op.kind === "focus") {
const target = targetFor(scope, op.target);
if (target && target.focus) target.focus();
else return missing(scope, op.target);
} else if (op.kind === "navigate") {
if (op.mode === "redirect") location.href = op.url;
else {
history[op.mode === "replace" ? "replaceState" : "pushState"]({ hemx: true }, "", op.url);
if (op.scroll === "top") scrollTo(0, 0);
else if (op.scroll && op.scroll.kind === "element") {
const target = targetFor(scope, op.scroll.target);
if (target) target.scrollIntoView();
}
if (op.title) document.title = op.title;
}
} else if (op.kind === "emit") {
handleRuntimeEvent(scope, op.name, op.payload);
emit(scope, op.name, op.payload);
}
return true;
}
function targetFor(scope, ref) {
if (isAtom(ref)) return null;
if (ref.scope && ref.scope.kind === "key") return keyedTarget(scope, ref.resource.id, ref.scope.value);
if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value);
return generatedTarget(scope, ref.resource.id);
}
function firstElement(scope, predicate) {
const stack = [];
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
while (stack.length) {
const node = stack.shift();
if (predicate(node)) return node;
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
}
return null;
}
function attrEquals(el, name, value) {
return el && el.getAttribute && el.getAttribute(name) === String(value);
}
function generatedResource(el, id) {
return attrEquals(el, "data-sid", id) || attrEquals(el, "data-slot-id", id);
}
function generatedForm(el, id) {
return attrEquals(el, "data-fid", id) || attrEquals(el, "data-form-id", id);
}
function generatedTarget(scope, id) {
if (scope && generatedResource(scope, id)) return scope;
return firstElement(scope, (el) => generatedResource(el, id));
}
function withinGeneratedResource(el, scope, id) {
for (let node = el; node && node !== scope.parentNode; node = node.parentElement) {
if (generatedResource(node, id)) return true;
if (node === scope) break;
}
return false;
}
function withinGeneratedForm(el, scope, id) {
for (let node = el; node && node !== scope.parentNode; node = node.parentElement) {
if (generatedForm(node, id)) return true;
if (node === scope) break;
}
return false;
}
function isInputControl(el) {
return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
}
function isDisableControl(el) {
return isInputControl(el) || (el && el.tagName === "BUTTON");
}
function isAtom(ref) {
return ref && ref.resource && ref.resource.kind === "atom";
}
function atomStore(root) {
const owner = root && root.nodeType === 1 ? root : document.documentElement;
let store = atomStores.get(owner);
if (!store) {
store = new Map();
atomStores.set(owner, store);
}
return store;
}
function atomValue(root, id) {
return atomStore(rootOf(root) || root || roots()[0]).get(String(id));
}
function keyedTarget(scope, id, key) {
return firstElement(scope, (el) => attrEquals(el, "data-key", key) && withinGeneratedResource(el, scope, id));
}
function fieldTarget(scope, id, field) {
return firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
}
function formErrorTarget(scope, id, field) {
return firstElement(scope, (el) => attrEquals(el, "data-hemx-error-for", field) && withinGeneratedForm(el, scope, id)) ||
firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
}
function putFormError(target, message) {
if (isInputControl(target)) target.setCustomValidity(message);
else target.textContent = message;
}
function putPayload(target, payload) {
if (payload.kind === "html") target.innerHTML = payload.value;
else if (isInputControl(target)) target.value = payload.value;
else target.textContent = payload.value;
}
function replacePayload(target, payload, key, resourceId) {
const nodes = fragmentNodes(payload, key, resourceId);
if (nodes.length) target.replaceWith(...nodes);
else target.innerHTML = "";
}
function fragmentNodes(payload, key, resourceId) {
const template = document.createElement("template");
if (payload.kind === "html") template.innerHTML = payload.value;
else template.textContent = payload.value;
const nodes = Array.from(template.content.childNodes);
const firstElement = nodes.find((node) => node.nodeType === 1);
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId);
return nodes;
}
function handleRuntimeEvent(scope, name, payload) {
if (name === "hemx:form-reset") {
const form = generatedFormTarget(scope, payload);
if (form && form.reset) form.reset();
} else if (name === "hemx:form-error") {
const [id, field, message] = String(payload).split("\u001f");
const target = formErrorTarget(scope, id, field);
if (target) putFormError(target, message || "");
} else if (name === "hemx:form-disable-while-pending") {
const form = generatedFormTarget(scope, payload);
if (form) form.setAttribute("data-hemx-disable-while-pending", "");
}
}
function missing(root, target) {
emit(root, "hemx:missing-target", target);
return false;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
}
function applyHtml(html, root, title) {
const scope = root || document;
const doc = new DOMParser().parseFromString(html, "text/html");
const template = firstElement(doc, (el) => el.tagName === "TEMPLATE" && el.hasAttribute("data-hemx"));
const lowered = replaceLoweredSlots(scope, doc);
const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html));
if (!lowered && !named) {
emit(scope, "hemx:missing-content-slot", null);
return false;
}
replaceSlot(scope, doc, "nav");
const titleEl = firstElement(doc, (el) => el.tagName === "TITLE");
const nextTitle = title || (titleEl && titleEl.textContent);
if (nextTitle) document.title = nextTitle;
return true;
}
function replaceLoweredSlots(scope, doc) {
let changed = false;
forEachElement(doc.body || doc, (source) => {
const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id");
if (!id) return;
const target = generatedTarget(scope, id);
if (target) {
target.innerHTML = source.innerHTML;
changed = true;
}
});
return changed;
}
function forEachElement(scope, visit) {
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) {
visit(node);
forEachElement(node, visit);
}
}
function generatedFormTarget(scope, id) {
if (scope && generatedForm(scope, id)) return scope;
return firstElement(scope, (el) => generatedForm(el, id));
}
function namedSlot(el, name) {
return attrEquals(el, "data-hemx-slot", name) || attrEquals(el, "data-slot", name);
}
function replaceSlot(scope, doc, name, fallback) {
const target = firstElement(scope, (el) => namedSlot(el, name));
if (!target) return false;
const source = firstElement(doc, (el) => namedSlot(el, name));
if (!source && fallback === undefined) return false;
target.innerHTML = source ? source.innerHTML : fallback;
return true;
}
function emit(root, name, detail) {
(root || document).dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
}
function defaultEvent(el) {
if (el.getAttribute("data-hemx-on")) return el.getAttribute("data-hemx-on");
if (el.tagName === "FORM") return "submit";
return "click";
}
function bindRoot(root) {
["click", "submit", "input", "change", "dragstart", "dragover", "drop"].forEach((name) => {
root.addEventListener(name, (event) => {
if (name === "dragstart") {
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key"));
if (item) {
dragKeys.set(root, item.getAttribute("data-key"));
if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-key"));
}
return;
}
if (name === "dragover") {
const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-hemx-on") === "drop");
if (drop) event.preventDefault();
return;
}
const nav = closestInRoot(event.target, root, (el) =>
(el.tagName === "A" && el.hasAttribute("data-hemx-nav")) ||
(el.tagName === "A" && el.hasAttribute("href") && closestInRoot(el.parentElement, root, (node) => node.hasAttribute("data-hemx-boost")))
);
if (name === "click" && nav && sameOriginNav(event, nav)) {
event.preventDefault();
navigate(nav);
return;
}
if (name === "click") {
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID));
if (direct && defaultEvent(direct) === "click") {
event.preventDefault();
schedule(direct, name);
return;
}
}
if (name === "click") {
const submitter = closestInRoot(event.target, root, (el) =>
(el.tagName === "BUTTON" && (!el.hasAttribute("type") || el.getAttribute("type") === "submit")) ||
(el.tagName === "INPUT" && el.getAttribute("type") === "submit")
);
const form = formOwner(submitter);
if (form && root.contains(form) && formHandleId(form)) {
if (form.reportValidity && !form.reportValidity()) return;
event.preventDefault();
schedule(form, "submit", submitter);
return;
}
}
let el = closestInRoot(event.target, root, (node) =>
node.hasAttribute(HID) ||
(node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-hemx-boost")))
);
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
if (!el || defaultEvent(el) !== name) return;
event.preventDefault();
schedule(el, name, event.submitter || el);
});
});
bindPolling(root);
}
function schedule(el, eventName, source = el) {
const debounce = duration(el.getAttribute("data-hemx-debounce"));
const throttle = duration(el.getAttribute("data-hemx-throttle"));
if (debounce) {
clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
} else if (throttle) {
if (timers.get(el)) return;
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
} else {
send(el, eventName, source);
}
}
function bindPolling(root) {
forEachElement(root, (el) => {
if (!el.hasAttribute("data-hemx-every") || everyTimers.has(el)) return;
const ms = duration(el.getAttribute("data-hemx-every"));
if (!ms) return;
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : stopPolling(el), ms));
});
}
function stopPolling(el) {
clearInterval(everyTimers.get(el));
everyTimers.delete(el);
}
function bindSse(root) {
const url = root.getAttribute("data-hemx-sse");
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
const href = new URL(url, location.href);
if (href.origin !== location.origin) {
emit(root, "hemx:sse-error", url);
return;
}
const source = new EventSource(href.href);
source.addEventListener("hemx", (event) => applySseMessage(root, event));
source.addEventListener("message", (event) => applySseMessage(root, event));
source.addEventListener("error", () => emit(root, "hemx:sse-error", url));
sseSources.set(root, source);
}
function applySseMessage(root, event) {
try {
const bytes = base64UrlBytes(event.data);
applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root);
} catch (error) {
emit(root, "hemx:error", String(error));
}
}
function duration(value) {
if (!value) return 0;
const match = String(value).trim().match(/^(\d+)(ms|s)?$/);
if (!match) return 0;
return Number(match[1]) * (match[2] === "s" ? 1000 : 1);
}
function bootstrapState(root) {
const encoded = root.getAttribute(STATE);
if (!encoded) return;
try {
const store = atomStore(root);
for (const atom of decodeAtomState(encoded)) store.set(String(atom.id), atom.bytes);
} catch (error) {
atomStores.delete(root);
emit(root, "hemx:state-error", String(error));
}
}
function decodeAtomState(encoded) {
const bytes = base64UrlBytes(encoded);
const d = postcardDecoder(bytes);
const atoms = d.vec(() => ({ id: d.varint(), bytes: d.bytes() }));
if (!d.done()) throw new Error("trailing hemx state bytes");
return atoms;
}
function base64UrlBytes(encoded) {
const normalized = String(encoded).replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
}
function postcardDecoder(bytes) {
let offset = 0;
const need = (len) => {
const end = offset + len;
if (end > bytes.length) throw new Error("truncated hemx state");
const slice = bytes.subarray(offset, end);
offset = end;
return slice;
};
const varint = () => {
let shift = 0;
let value = 0;
for (;;) {
const byte = need(1)[0];
value |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) return value >>> 0;
shift += 7;
}
};
const bytesField = () => need(varint());
const vec = (read) => Array.from({ length: varint() }, read);
return { varint, bytes: bytesField, vec, done: () => offset === bytes.length };
}
function decoder(buffer) {
const bytes = new Uint8Array(buffer);
let offset = 0;
const need = (len) => {
const end = offset + len;
if (end > bytes.length) throw new Error("truncated hemx batch");
const slice = bytes.subarray(offset, end);
offset = end;
return slice;
};
const u8 = () => need(1)[0];
const u32 = () => {
const b = need(4);
return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0;
};
const u64 = () => {
const lo = BigInt(u32());
const hi = BigInt(u32());
return lo | (hi << 32n);
};
const str = () => new TextDecoder().decode(need(u32()));
const option = (read) => u8() === 0 ? null : read();
const resource = () => ({ kind: ["slot", "atom", "handle", "form"][u8()], id: u32() });
const scope = () => {
const kind = u8();
if (kind === 0) return null;
return { kind: kind === 1 ? "key" : "field", value: str() };
};
const ref = () => ({ resource: resource(), scope: scope() });
const payload = () => ({ kind: u8() === 0 ? "text" : "html", value: str() });
const scroll = () => {
const kind = u8();
if (kind === 0) return "preserve";
if (kind === 1) return "top";
return { kind: "element", target: ref() };
};
const effect = () => {
const kind = u8();
if (kind === 0) return { kind: "put", target: ref(), payload: payload() };
if (kind === 1) return { kind: "insert", target: ref(), key: str(), payload: payload() };
if (kind === 2) return { kind: "prepend", target: ref(), key: str(), payload: payload() };
if (kind === 3) return { kind: "remove", target: ref(), key: option(str) };
if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) };
if (kind === 5) return { kind: "focus", target: ref() };
if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) };
if (kind === 7) return { kind: "emit", name: str(), payload: str() };
throw new Error(`unknown hemx effect ${kind}`);
};
const vec = (read) => Array.from({ length: u32() }, read);
return { u8, u32, u64, vec, effect, done: () => offset === bytes.length };
}
function decodeBatch(buffer) {
const d = decoder(buffer);
if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "HEMX") throw new Error("bad hemx batch magic");
const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) };
if (!d.done()) throw new Error("trailing hemx batch bytes");
return batch;
}
function sameOriginNav(event, anchor) {
return !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey &&
anchor.origin === location.origin && !anchor.download && anchor.target !== "_blank";
}
function start() {
roots().forEach((root) => {
try {
bootstrapState(root);
} catch (error) {
emit(root, "hemx:state-error", String(error));
}
try {
bindRoot(root);
} catch (error) {
emit(root, "hemx:bind-error", String(error));
}
try {
bindSse(root);
} catch (error) {
emit(root, "hemx:sse-error", String(error));
}
});
try {
history.replaceState(history.state || { hemx: true }, "", location.href);
} catch (error) {
const root = roots()[0];
if (root) emit(root, "hemx:history-error", String(error));
}
}
addEventListener("popstate", () => {
const root = roots()[0];
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
});
window.hemx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState });
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
else start();
})();