class NoetherRuntime {
constructor(executeFn, mountEl, executeStageFn, getGraphJsonFn) {
this._execute = executeFn;
this._mount = mountEl;
this._executeStage = executeStageFn || null;
this._getGraphJson = getGraphJsonFn || null;
this._atoms = {}; this._events = {}; this._vdom = null; this._rendering = false; this._pendingRender = false;
this._graphNode = null;
if (typeof window !== 'undefined') {
window.addEventListener('popstate', () => {
this._atoms['_route'] = window.location.pathname;
this._scheduleRender();
});
}
}
defineAtom(name, initial) {
this._atoms[name] = initial;
return this;
}
setAtom(name, value) {
const next = typeof value === 'function' ? value(this._atoms[name]) : value;
this._atoms[name] = next;
this._scheduleRender();
}
defineEvent(name, handler) {
this._events[name] = handler;
return this;
}
navigate(path, state) {
if (typeof window !== 'undefined') {
window.history.pushState(state || null, '', path);
}
this._atoms['_route'] = path;
this._scheduleRender();
}
_scheduleRender() {
if (this._rendering) {
this._pendingRender = true;
return;
}
Promise.resolve().then(() => this.render());
}
async render() {
if (this._rendering) {
this._pendingRender = true;
return;
}
this._rendering = true;
this._pendingRender = false;
try {
const input = { ...this._atoms };
if (typeof window !== 'undefined' && !('_route' in input)) {
input['_route'] = window.location.pathname;
}
let output;
if (this._executeStage && this._getGraphJson) {
if (!this._graphNode) {
const graphJson = this._getGraphJson();
const graph = JSON.parse(graphJson);
this._graphNode = graph.root;
}
output = await this._executeGraph(this._graphNode, input);
} else {
const resultJson = this._execute(JSON.stringify(input));
const result = JSON.parse(resultJson);
if (!result.ok) {
this._renderError(result.error || 'Unknown execution error');
return;
}
output = result.output;
}
this._patch(this._mount, output, this._vdom);
this._vdom = output;
} catch (err) {
this._renderError(String(err));
} finally {
this._rendering = false;
if (this._pendingRender) {
this._pendingRender = false;
Promise.resolve().then(() => this.render());
}
}
}
async _executeGraph(node, input) {
const op = node.op;
switch (op) {
case 'Stage':
return this._execLocal(node.id, input);
case 'RemoteStage':
return this._execRemote(node.url, input);
case 'Const':
return node.value;
case 'Sequential': {
let current = input;
for (const stage of node.stages) {
current = await this._executeGraph(stage, current);
}
return current;
}
case 'Parallel': {
const entries = Object.entries(node.branches);
const results = await Promise.all(
entries.map(([name, branch]) => {
const branchInput =
input && typeof input === 'object' && name in input
? input[name]
: input;
return this._executeGraph(branch, branchInput).then(out => [name, out]);
})
);
const merged = {};
for (const [name, out] of results) {
merged[name] = out;
}
return merged;
}
case 'Branch': {
const pred = await this._executeGraph(node.predicate, input);
const branch = pred ? node.if_true : node.if_false;
return this._executeGraph(branch, input);
}
case 'Fanout': {
const sourceOut = await this._executeGraph(node.source, input);
const results = await Promise.all(
node.targets.map(t => this._executeGraph(t, sourceOut))
);
return results;
}
case 'Merge': {
const sourceResults = await Promise.all(
node.sources.map((s, i) => {
const srcInput =
input && typeof input === 'object' && (`source_${i}`) in input
? input[`source_${i}`]
: input;
return this._executeGraph(s, srcInput).then(out => [`source_${i}`, out]);
})
);
const merged = {};
for (const [k, v] of sourceResults) {
merged[k] = v;
}
return this._executeGraph(node.target, merged);
}
case 'Retry': {
const maxAttempts = node.max_attempts || 3;
let lastErr;
for (let i = 0; i < maxAttempts; i++) {
try {
return await this._executeGraph(node.stage, input);
} catch (e) {
lastErr = e;
if (node.delay_ms) {
await new Promise(r => setTimeout(r, node.delay_ms));
}
}
}
throw lastErr || new Error(`Retry exhausted after ${maxAttempts} attempts`);
}
default:
throw new Error(`Unknown CompositionNode op: ${op}`);
}
}
async _execLocal(stageId, input) {
const resultJson = this._executeStage(stageId, JSON.stringify(input));
const result = JSON.parse(resultJson);
if (!result.ok) {
throw new Error(`Stage ${stageId} failed: ${result.error}`);
}
return result.output;
}
async _execRemote(url, input) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
});
if (!resp.ok) {
throw new Error(`RemoteStage call to ${url} returned HTTP ${resp.status}`);
}
const json = await resp.json();
if (!json.data || !('output' in json.data)) {
throw new Error(`RemoteStage response from ${url} missing data.output field`);
}
return json.data.output;
}
_renderError(msg) {
this._mount.innerHTML =
`<div class="nr-error"><strong>Noether runtime error</strong><pre>${_escHtml(msg)}</pre></div>`;
}
_patch(parent, newVnode, oldVnode) {
if (newVnode == null) {
if (oldVnode != null) {
_clearChildren(parent);
}
return;
}
if (oldVnode == null) {
parent.appendChild(this._createEl(newVnode));
return;
}
if (newVnode.$text != null && oldVnode.$text != null) {
const child = parent.firstChild;
if (child && child.nodeType === Node.TEXT_NODE) {
if (child.textContent !== newVnode.$text) {
child.textContent = newVnode.$text;
}
} else {
_clearChildren(parent);
parent.appendChild(document.createTextNode(newVnode.$text));
}
return;
}
const oldTag = oldVnode.$text != null ? '#text' : (oldVnode.tag || '');
const newTag = newVnode.$text != null ? '#text' : (newVnode.tag || '');
if (oldTag !== newTag) {
_clearChildren(parent);
parent.appendChild(this._createEl(newVnode));
return;
}
const el = parent.firstElementChild || parent.firstChild;
if (!el) {
parent.appendChild(this._createEl(newVnode));
return;
}
this._patchProps(el, newVnode.props || {}, oldVnode.props || {});
const newChildren = newVnode.children || [];
const oldChildren = oldVnode.children || [];
this._patchChildren(el, newChildren, oldChildren);
}
_patchChildren(el, newChildren, oldChildren) {
const hasKeys = newChildren.some(c => c && c.props && c.props.key != null);
if (hasKeys) {
this._patchChildrenKeyed(el, newChildren, oldChildren);
} else {
this._patchChildrenIndexed(el, newChildren, oldChildren);
}
}
_patchChildrenKeyed(el, newChildren, oldChildren) {
const oldByKey = new Map();
const domChildren = Array.from(el.childNodes);
for (let i = 0; i < oldChildren.length; i++) {
const oldVnode = oldChildren[i];
const domNode = domChildren[i];
if (!oldVnode || !domNode) continue;
const key = oldVnode.props && oldVnode.props.key;
if (key != null) {
oldByKey.set(key, { domNode, oldVnode });
domNode.__nrKey = key;
}
}
const newDomNodes = [];
for (const newVnode of newChildren) {
if (newVnode == null) continue;
const key = newVnode.props && newVnode.props.key;
if (key != null && oldByKey.has(key)) {
const { domNode, oldVnode } = oldByKey.get(key);
oldByKey.delete(key);
if (newVnode.$text != null) {
if (domNode.nodeType === Node.TEXT_NODE) {
if (domNode.textContent !== newVnode.$text) {
domNode.textContent = newVnode.$text;
}
}
} else {
this._patchProps(domNode, newVnode.props || {}, oldVnode.props || {});
this._patchChildren(domNode, newVnode.children || [], oldVnode.children || []);
}
newDomNodes.push(domNode);
} else {
newDomNodes.push(this._createEl(newVnode));
}
}
for (const { domNode } of oldByKey.values()) {
if (domNode.parentNode === el) {
el.removeChild(domNode);
}
}
for (let i = 0; i < newDomNodes.length; i++) {
const node = newDomNodes[i];
const current = el.childNodes[i];
if (current !== node) {
el.insertBefore(node, current || null);
}
}
while (el.childNodes.length > newDomNodes.length) {
el.removeChild(el.lastChild);
}
}
_patchChildrenIndexed(el, newChildren, oldChildren) {
const maxLen = Math.max(newChildren.length, oldChildren.length);
for (let i = 0; i < maxLen; i++) {
const newChild = newChildren[i];
const oldChild = oldChildren[i];
if (newChild == null && oldChild != null) {
const domChild = _getNthChild(el, i);
if (domChild) el.removeChild(domChild);
continue;
}
if (newChild != null && oldChild == null) {
el.appendChild(this._createEl(newChild));
continue;
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
const txt = String(newChild);
const domChild = _getNthChild(el, i);
if (domChild && domChild.nodeType === Node.TEXT_NODE) {
if (domChild.textContent !== txt) domChild.textContent = txt;
} else if (domChild) {
el.replaceChild(document.createTextNode(txt), domChild);
} else {
el.appendChild(document.createTextNode(txt));
}
continue;
}
const domChild = _getNthChild(el, i);
if (!domChild) {
el.appendChild(this._createEl(newChild));
continue;
}
const oldTag = oldChild.$text != null ? '#text' : (oldChild.tag || '');
const newTag = newChild.$text != null ? '#text' : (newChild.tag || '');
if (oldTag !== newTag) {
el.replaceChild(this._createEl(newChild), domChild);
} else if (newChild.$text != null) {
if (domChild.nodeType === Node.TEXT_NODE) {
if (domChild.textContent !== newChild.$text) {
domChild.textContent = newChild.$text;
}
} else {
el.replaceChild(document.createTextNode(newChild.$text), domChild);
}
} else {
this._patchProps(domChild, newChild.props || {}, oldChild.props || {});
this._patchChildren(domChild, newChild.children || [], oldChild.children || []);
}
}
}
_patchProps(el, newProps, oldProps) {
for (const key of Object.keys(oldProps)) {
if (key.startsWith('$')) continue;
if (!(key in newProps)) {
_removeProp(el, key);
}
}
for (const [key, val] of Object.entries(newProps)) {
if (key.startsWith('$')) continue;
if (typeof val === 'object' && val !== null && val.$event) {
_bindEvent(el, key, val, this);
} else if (oldProps[key] !== val) {
_setProp(el, key, val);
}
}
}
_createEl(vnode) {
if (vnode == null) return document.createTextNode('');
if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));
if (vnode.$text != null) return document.createTextNode(vnode.$text);
const el = document.createElement(vnode.tag || 'div');
for (const [key, val] of Object.entries(vnode.props || {})) {
if (key.startsWith('$')) continue;
if (typeof val === 'object' && val !== null && val.$event) {
_bindEvent(el, key, val, this);
} else {
_setProp(el, key, val);
}
}
for (const child of (vnode.children || [])) {
el.appendChild(this._createEl(child));
}
return el;
}
dispatchEvent(eventSpec, domEvent) {
const name = eventSpec.$event;
if (name === 'set' && eventSpec.$target) {
const attr = eventSpec.$attr || 'value';
const value = domEvent.target ? domEvent.target[attr] : eventSpec.$value;
this.setAtom(eventSpec.$target, value);
return;
}
if (name === 'toggle' && eventSpec.$target) {
this.setAtom(eventSpec.$target, v => !v);
return;
}
if (name === 'set-value' && eventSpec.$target) {
this.setAtom(eventSpec.$target, eventSpec.$value);
return;
}
if (name === 'navigate' && eventSpec.$path) {
this.navigate(eventSpec.$path);
return;
}
const handler = this._events[name];
if (handler) {
const patch = handler({ ...this._atoms }, domEvent, eventSpec);
if (patch && typeof patch === 'object') {
Object.assign(this._atoms, patch);
this._scheduleRender();
}
return;
}
console.warn(`NoetherRuntime: no handler for event "${name}"`);
}
}
function _setProp(el, key, val) {
if (key === 'class') {
el.className = val;
} else if (key === 'style' && typeof val === 'object') {
Object.assign(el.style, val);
} else if (key === 'style' && typeof val === 'string') {
el.style.cssText = val;
} else if (key in el && key !== 'list' && key !== 'form') {
try { el[key] = val; } catch (_) { el.setAttribute(key, val); }
} else {
el.setAttribute(key, val);
}
}
function _removeProp(el, key) {
if (key === 'class') {
el.className = '';
} else if (key in el) {
try { el[key] = null; } catch (_) { el.removeAttribute(key); }
} else {
el.removeAttribute(key);
}
}
function _bindEvent(el, propKey, eventSpec, runtime) {
const domEvent = _propToEvent(propKey);
if (!domEvent) return;
const specKey = `__nr_${domEvent}`;
const specJson = JSON.stringify(eventSpec);
if (el[specKey + '_spec'] !== specJson) {
if (el[specKey]) {
el.removeEventListener(domEvent, el[specKey]);
}
const listener = (e) => {
e.preventDefault && e.preventDefault();
runtime.dispatchEvent(eventSpec, e);
};
el[specKey] = listener;
el[specKey + '_spec'] = specJson;
el.addEventListener(domEvent, listener);
}
}
function _propToEvent(propKey) {
const norm = propKey.toLowerCase();
if (!norm.startsWith('on')) return null;
return norm.slice(2) || null;
}
function _clearChildren(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function _getNthChild(el, n) {
return el.childNodes[n] || null;
}
function _escHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}