refactor: replace Jinja templates with static frontend pages
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Hoover — api.js
|
||||
*
|
||||
* JSON-friendly fetch wrapper with automatic header management.
|
||||
* Toast notification system with auto-dismiss.
|
||||
* ToastContainer component for rendering queued toasts.
|
||||
*/
|
||||
|
||||
import { h } from './vdom.js';
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
*
|
||||
* Automatically sets Content-Type for object bodies, parses JSON
|
||||
* responses, and normalises the result to { ok, data, error, status }.
|
||||
*
|
||||
* @param {string} url – Target URL
|
||||
* @param {object} [options] – Fetch options (method, body, headers, …)
|
||||
* @returns {Promise<{ok, data, error, status}>}
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const { method = 'GET', body, ...opts } = options;
|
||||
const headers = { 'Accept': 'application/json', ...opts.headers };
|
||||
|
||||
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
|
||||
if (res.status === 401) {
|
||||
window.location.reload();
|
||||
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||
}
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status };
|
||||
}
|
||||
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status };
|
||||
} catch (e) {
|
||||
return { ok: false, data: null, error: e.message || 'Network error', status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** ─── Toast notifications ────────────────────────────────── */
|
||||
|
||||
/** Toast notification queue. Exported for ToastContainer component. */
|
||||
export const _toasts = [];
|
||||
const _toastIds = { next: 1 };
|
||||
|
||||
/**
|
||||
* Show a toast notification. Auto-dismisses after `duration` ms.
|
||||
*
|
||||
* @param {string} message – Toast text
|
||||
* @param {string} [type] – 'info' | 'success' | 'error' | 'warning'
|
||||
* @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite)
|
||||
* @returns {number} id
|
||||
*/
|
||||
export function toast(message, type = 'info', duration = 4000) {
|
||||
const id = _toastIds.next++;
|
||||
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
|
||||
|
||||
if (duration > 0) setTimeout(() => dismissToast(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a toast by id.
|
||||
*/
|
||||
export function dismissToast(id) {
|
||||
const idx = _toasts.findIndex(t => t.id === id);
|
||||
if (idx !== -1) _toasts.splice(idx, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the queued toast notifications.
|
||||
*
|
||||
* @returns {VNode} – Toast container (empty text node when no toasts)
|
||||
*/
|
||||
export function ToastContainer() {
|
||||
if (!_toasts.length) return h('#text', '');
|
||||
|
||||
const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning' };
|
||||
|
||||
return h('div', { class: 'toast-container' },
|
||||
..._toasts.map(t =>
|
||||
h('div', { class: `toast ${clsMap[t.type] || clsMap.info}`, 'on:click': () => dismissToast(t.id) },
|
||||
h('span', null, t.message),
|
||||
h('button', { class: 'toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); } }, '\u00d7'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Hoover — component.js
|
||||
*
|
||||
* Component wrapper: definePage, lifecycle hooks, state caching.
|
||||
*
|
||||
* definePage wraps a page definition into a renderer function compatible
|
||||
* with hoover's render engine. Handles reactive state creation, WS
|
||||
* subscription registration on mount, and cleanup on unmount.
|
||||
*
|
||||
* Usage:
|
||||
* export default definePage({
|
||||
* init() { return { data: null, loading: true, error: null }; },
|
||||
* subscribe: ['*'], // WS topics to subscribe to
|
||||
* async load(state) { ... }, // called on mount
|
||||
* render(state) { return [vnodes],
|
||||
* });
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js';
|
||||
import { h } from './vdom.js';
|
||||
import { _compExpandedCache } from './render.js';
|
||||
|
||||
/**
|
||||
* Registry of mounted components: key → { state, subscriptions, loadAbort, entry }
|
||||
*/
|
||||
const _mounted = new Map();
|
||||
|
||||
/**
|
||||
* External subscribe function from websocket.js.
|
||||
* Set via setSubscribeFn() when the websocket module initializes.
|
||||
*/
|
||||
let _subscribeFn = null;
|
||||
|
||||
export function setSubscribeFn(fn) {
|
||||
_subscribeFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a page component.
|
||||
*
|
||||
* @param {object} def — Page definition
|
||||
* @param {function} def.init — Return initial state object
|
||||
* @param {string[]} [def.subscribe] — WS topics to subscribe to on mount
|
||||
* @param {function} def.load — Async function to load data into state
|
||||
* @param {function} def.render — Render function that returns vnodes
|
||||
* @returns {object} — Component renderer compatible with h('#comp', ...)
|
||||
*/
|
||||
export function definePage(def) {
|
||||
const state = reactive(def.init());
|
||||
|
||||
const renderer = () => {
|
||||
return def.render(state);
|
||||
};
|
||||
|
||||
renderer._pageDef = {
|
||||
state,
|
||||
subscribe: def.subscribe || [],
|
||||
load: def.load || null,
|
||||
onUnmount: def.onUnmount || null,
|
||||
};
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a page component. Called by the render engine when a #comp vnode
|
||||
* enters the tree for the first time.
|
||||
*/
|
||||
export function mountComponent(key, renderer) {
|
||||
// Prevent duplicate mounts when normalization loses #comp tracking
|
||||
if (_mounted.has(key)) return;
|
||||
|
||||
const pd = renderer._pageDef;
|
||||
if (!pd) return;
|
||||
|
||||
const entry = {
|
||||
state: pd.state,
|
||||
subscriptions: [],
|
||||
loadAbort: null,
|
||||
};
|
||||
|
||||
_mounted.set(key, entry);
|
||||
|
||||
// Fire load
|
||||
if (pd.load) {
|
||||
const abortController = new AbortController();
|
||||
entry.loadAbort = abortController;
|
||||
pd.load(pd.state, abortController);
|
||||
}
|
||||
|
||||
// Register WS subscriptions
|
||||
if (_subscribeFn && pd.subscribe.length) {
|
||||
for (const topic of pd.subscribe) {
|
||||
const unsub = _subscribeFn(renderer, topic, pd.load, pd.state);
|
||||
if (unsub) entry.subscriptions.push(unsub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount a page component. Called by the render engine when a #comp vnode
|
||||
* is removed from the tree.
|
||||
*/
|
||||
export function unmountComponent(key, renderer) {
|
||||
const entry = _mounted.get(key);
|
||||
if (!entry) return;
|
||||
|
||||
const pd = renderer._pageDef;
|
||||
|
||||
// Cancel load
|
||||
if (entry.loadAbort) {
|
||||
entry.loadAbort.abort();
|
||||
}
|
||||
|
||||
// Unsubscribe from WS
|
||||
for (const unsub of entry.subscriptions) {
|
||||
try { unsub(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Fire custom onUnmount
|
||||
if (pd.onUnmount) {
|
||||
try { pd.onUnmount(entry.state); } catch (_) {}
|
||||
}
|
||||
|
||||
_compExpandedCache.delete(key);
|
||||
_mounted.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of a mounted component.
|
||||
*/
|
||||
export function getComponentState(key) {
|
||||
const entry = _mounted.get(key);
|
||||
return entry ? entry.state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a component vnode that the render engine will wire up to lifecycle.
|
||||
*/
|
||||
export function hComp(renderer, key) {
|
||||
return h('#comp', { component: renderer, key }, []);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Hoover — components/data.js
|
||||
*
|
||||
* Data display components: Badge, StatusDot, Empty, Card.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.text – Badge text
|
||||
* @param {string} [props.variant] – 'info' | 'success' | 'warning' | 'danger'
|
||||
*/
|
||||
export function Badge(props = {}) {
|
||||
return h('span', { class: `badge badge-${props.variant || 'info'}` }, String(props.text || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator dot.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.status – 'success' | 'up' | 'danger' | 'down' | 'pending'
|
||||
*/
|
||||
export function StatusDot(props = {}) {
|
||||
const v = ['success', 'up'].includes(props.status) ? 'up' :
|
||||
['danger', 'down'].includes(props.status) ? 'down' : 'pending';
|
||||
return h('span', { class: `status-dot status-${v}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state placeholder card.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.text]
|
||||
*/
|
||||
export function Empty(props = {}) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted text-sm' }, props.text || 'No data available'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card wrapper with optional header and body content.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.header]
|
||||
* @param {VNode[]} [props.children]
|
||||
*/
|
||||
export function Card(props = {}) {
|
||||
if (props.header) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' }, props.header),
|
||||
h('div', { class: 'card-body' }, props.children || []),
|
||||
);
|
||||
}
|
||||
return h('div', { class: 'card' }, props.children || []);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Hoover — components/layout.js
|
||||
*
|
||||
* Layout components: PageHeader for page titles with optional subtitles
|
||||
* and action buttons.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title
|
||||
* @param {string} [props.subtitle]
|
||||
* @param {VNode} [props.actions]
|
||||
*/
|
||||
export function PageHeader(props = {}) {
|
||||
return h('div', { class: 'page-header' },
|
||||
h('div', null,
|
||||
h('h1', null, props.title || ''),
|
||||
props.subtitle ? h('div', { class: 'subtitle' }, props.subtitle) : null,
|
||||
),
|
||||
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Hoover — components/modal.js
|
||||
*
|
||||
* Modal overlay system: openModal, closeModal, closeAllModals, formModal.
|
||||
* Renders directly into #modal-root using DOM manipulation (not vdom) to
|
||||
* avoid fighting with the main render cycle.
|
||||
*/
|
||||
|
||||
import { esc } from '../helpers.js';
|
||||
import { att_esc } from '../helpers.js';
|
||||
|
||||
const _modalQueue = [];
|
||||
|
||||
function _renderModals() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
_modalQueue.forEach((m, idx) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'modal-overlay active';
|
||||
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal';
|
||||
content.onclick = (e) => e.stopPropagation();
|
||||
if (m.renderFn) {
|
||||
try { m.renderFn(content, idx); }
|
||||
catch (err) { content.textContent = err.message; }
|
||||
}
|
||||
wrap.appendChild(content);
|
||||
root.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal dialog.
|
||||
*
|
||||
* @param {function} renderFn – (contentEl, idx) => void, renders into contentEl
|
||||
*/
|
||||
export function openModal(renderFn) {
|
||||
_modalQueue.push({ renderFn, id: _modalQueue.length });
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a modal by index. Closes the topmost modal if index is omitted.
|
||||
*
|
||||
* @param {number} [idx]
|
||||
*/
|
||||
export function closeModal(idx) {
|
||||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||||
if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1);
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all open modals.
|
||||
*/
|
||||
export function closeAllModals() {
|
||||
_modalQueue.length = 0;
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a standard modal layout: title, form fields, action buttons.
|
||||
*
|
||||
* @param {HTMLElement} inner – Modal content element to fill
|
||||
* @param {string} title – Modal title
|
||||
* @param {object[]} fields – Form field descriptors
|
||||
* @param {object[]} actions – Action button descriptors
|
||||
*
|
||||
* Field shape:
|
||||
* { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] }
|
||||
*
|
||||
* Action shape:
|
||||
* { label, cls, action, handler }
|
||||
*/
|
||||
export function formModal(inner, title, fields, actions) {
|
||||
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
|
||||
+ fields.map(f => {
|
||||
if (f.tag === 'select')
|
||||
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">'
|
||||
+ (f.options || []).map(o =>
|
||||
typeof o === 'string'
|
||||
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
|
||||
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
|
||||
).join('') + '</select></div>';
|
||||
|
||||
const tag = f.tag || 'input';
|
||||
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
|
||||
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
|
||||
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
|
||||
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
|
||||
+ '></' + tag + '></div>';
|
||||
}).join('') + '</div><div class="modal-actions">'
|
||||
+ actions.map(a =>
|
||||
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
|
||||
).join('') + '</div>';
|
||||
|
||||
actions.forEach(a => {
|
||||
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
|
||||
if (btn) btn.addEventListener('click', a.handler);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Hoover — components/toast.js
|
||||
*
|
||||
* ToastContainer component that renders queued toast notifications.
|
||||
* Uses the toast/dismissToast state from api.js.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { _toasts, dismissToast } from '../api.js';
|
||||
|
||||
/**
|
||||
* Render all pending toast notifications.
|
||||
*
|
||||
* @returns {VNode}
|
||||
*/
|
||||
export function ToastContainer() {
|
||||
if (!_toasts.length) return h('#text', '');
|
||||
|
||||
const clsMap = {
|
||||
info: 'toast-info',
|
||||
success: 'toast-success',
|
||||
error: 'toast-error',
|
||||
warning: 'toast-warning',
|
||||
};
|
||||
|
||||
return h('div', { class: 'toast-container' },
|
||||
..._toasts.map(t =>
|
||||
h('div', {
|
||||
class: `toast ${clsMap[t.type] || clsMap.info}`,
|
||||
'on:click': () => dismissToast(t.id),
|
||||
},
|
||||
h('span', null, t.message),
|
||||
h('button', {
|
||||
class: 'toast-close',
|
||||
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
|
||||
}, '\u00d7'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Hoover — helpers.js
|
||||
*
|
||||
* Shared utilities: text escaping, attribute escaping, DOM value helpers,
|
||||
* zone parsing, form utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape text for safe HTML output.
|
||||
* Appends the string to a temporary div and reads innerHTML,
|
||||
* which safely escapes all HTML special characters.
|
||||
*/
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.append(String(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe use in HTML attributes.
|
||||
*/
|
||||
export function att_esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encode a string.
|
||||
*/
|
||||
export const enc = encodeURIComponent;
|
||||
|
||||
/**
|
||||
* Get the value of a DOM element by ID.
|
||||
*/
|
||||
export function $val(id) {
|
||||
return document.getElementById(id)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse zone data from various API response shapes into a flat string array.
|
||||
*/
|
||||
export function parseZones(data) {
|
||||
let z = data?.active || data?.zones || [];
|
||||
if (typeof z === 'object' && !Array.isArray(z))
|
||||
z = Object.values(z).map(i => i?.name || i);
|
||||
return Array.isArray(z) ? z : [];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Hoover — index.js
|
||||
*
|
||||
* Barrel export of all public Hoover APIs.
|
||||
*/
|
||||
|
||||
/* ── Reactivity ──────────────────────────────────────────────── */
|
||||
export { reactive, requestUpdate } from './reactivity.js';
|
||||
|
||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||
export { h } from './vdom.js';
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────── */
|
||||
export { render } from './render.js';
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
export { definePage, hComp } from './component.js';
|
||||
|
||||
/* ── Router ──────────────────────────────────────────────────── */
|
||||
export { createRouter, Link } from './router.js';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage } from './websocket.js';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast } from './api.js';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
export { esc, att_esc, enc, $val, parseZones } from './helpers.js';
|
||||
|
||||
/* ── UI Components: Layout ───────────────────────────────────── */
|
||||
export { PageHeader } from './components/layout.js';
|
||||
|
||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||
export { Badge, StatusDot, Empty, Card } from './components/data.js';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, formModal } from './components/modal.js';
|
||||
|
||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||
export { ToastContainer } from './components/toast.js';
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Hoover — reactivity.js
|
||||
*
|
||||
* Reactive Proxy state + batched render requests via queueMicrotask.
|
||||
* Multiple property mutations in the same microtask tick produce a single
|
||||
* render cycle across all registered render roots.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global flag to prevent duplicate microtask scheduling.
|
||||
*/
|
||||
let _scheduled = false;
|
||||
|
||||
/**
|
||||
* Callback invoked by render.js to perform the actual batched re-render.
|
||||
* Set via setCommitFn() during render engine initialization.
|
||||
*/
|
||||
let _commitFn = null;
|
||||
|
||||
/**
|
||||
* Register the commit callback that performs batched re-renders.
|
||||
* Called by render.js during initialization.
|
||||
*/
|
||||
export function setCommitFn(fn) {
|
||||
_commitFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a single batched re-render for all active render roots.
|
||||
* Multiple reactive property mutations in the same tick produce one diff pass.
|
||||
*/
|
||||
export function requestUpdate() {
|
||||
if (_scheduled) return;
|
||||
_scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
_scheduled = false;
|
||||
if (_commitFn) _commitFn();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an object in a reactive Proxy.
|
||||
* Any property *assignment* that changes the value automatically triggers
|
||||
* a batched re-render via requestUpdate().
|
||||
*/
|
||||
export function reactive(obj = {}) {
|
||||
return new Proxy(obj, {
|
||||
set(target, key, value, receiver) {
|
||||
const old = target[key];
|
||||
const ok = Reflect.set(target, key, value, receiver);
|
||||
if (ok && !Object.is(old, value)) {
|
||||
requestUpdate();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Hoover — render.js
|
||||
*
|
||||
* Render engine: render(container, fn), container-level diffing,
|
||||
* batched re-render loop integration with reactivity.js.
|
||||
*/
|
||||
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js';
|
||||
import {
|
||||
_vnodeDom, createDom, getDom, patchNode,
|
||||
setMountFn, setUnmountFn,
|
||||
} from './vdom.js';
|
||||
import { mountComponent, unmountComponent } from './component.js';
|
||||
|
||||
/** Container → previous root vnodes */
|
||||
export const _renderSlots = new Map();
|
||||
|
||||
/** Container → render function */
|
||||
export const _renderFns = new Map();
|
||||
|
||||
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
|
||||
export const _compExpandedCache = new Map();
|
||||
|
||||
/**
|
||||
* Set up lifecycle callback hooks from vdom.js.
|
||||
* Called once during render initialization.
|
||||
*/
|
||||
setMountFn((el) => {
|
||||
// Reserved for future DOM-level mount hooks
|
||||
});
|
||||
|
||||
setUnmountFn((el) => {
|
||||
// Called during sweepDom for cleanup
|
||||
});
|
||||
|
||||
/**
|
||||
* Commit callback: re-renders all registered containers in batch.
|
||||
* Set as the callback for reactivity.js's requestUpdate().
|
||||
*/
|
||||
function commitAll() {
|
||||
for (const container of _renderFns.keys()) {
|
||||
commit(container);
|
||||
}
|
||||
}
|
||||
|
||||
setCommitFn(commitAll);
|
||||
|
||||
/**
|
||||
* Mount a render function onto a DOM container.
|
||||
* - First call: create DOM from scratch, append to container
|
||||
* - Subsequent calls: diff against previous VNodes, patch in place
|
||||
*/
|
||||
export function render(container, fn) {
|
||||
_renderFns.set(container, fn);
|
||||
commit(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate render function, diff vs previous, commit to _renderSlots.
|
||||
*/
|
||||
function commit(container) {
|
||||
const fn = _renderFns.get(container);
|
||||
if (!fn) return;
|
||||
|
||||
let result = fn();
|
||||
if (typeof result === 'function') result = result();
|
||||
const prev = _renderSlots.get(container);
|
||||
|
||||
// Normalize: expand #comp vnodes and track lifecycle
|
||||
const vnodes = normalizeVNodesWithLifecycle(result, prev);
|
||||
|
||||
if (!prev) {
|
||||
for (const v of vnodes) {
|
||||
const d = createDom(v);
|
||||
_vnodeDom.set(v, d);
|
||||
container.appendChild(d);
|
||||
}
|
||||
} else {
|
||||
diffContainer(container, prev, vnodes);
|
||||
}
|
||||
|
||||
_renderSlots.set(container, vnodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize render output: filter nulls, expand #comp vnodes,
|
||||
* and manage component lifecycle based on key changes.
|
||||
*/
|
||||
function normalizeVNodesWithLifecycle(result, prevVnodes) {
|
||||
const oldEntries = prevVnodes ? collectCompEntries(prevVnodes, []) : [];
|
||||
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
|
||||
const newEntries = [];
|
||||
|
||||
const normalized = normalizeRecursive(result, oldKeyMap, newEntries);
|
||||
|
||||
for (const entry of oldEntries) {
|
||||
if (!newEntries.some(e => e.key === entry.key)) {
|
||||
unmountComponent(entry.key, entry.renderer);
|
||||
}
|
||||
}
|
||||
for (const entry of newEntries) {
|
||||
if (!oldKeyMap.has(entry.key)) {
|
||||
mountComponent(entry.key, entry.renderer);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively normalize a value to a flat VNode array, expanding
|
||||
* #comp vnodes into their rendered content while tracking lifecycle.
|
||||
*
|
||||
* When prevCh is provided, preserves _vnodeDom entries so that diff
|
||||
* can locate existing DOM after normalization creates new vnode objects.
|
||||
*/
|
||||
function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
|
||||
if (result == null) return [];
|
||||
if (Array.isArray(result)) {
|
||||
const flat = [];
|
||||
let idx = 0;
|
||||
for (const item of result) {
|
||||
flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx]));
|
||||
idx++;
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
const vnode = result;
|
||||
if (typeof vnode !== 'object') return [{ tag: '#text', text: String(vnode) }];
|
||||
if (vnode.tag === '#text') return [vnode];
|
||||
|
||||
if (vnode.tag === '#comp') {
|
||||
const renderer = vnode.props?.component;
|
||||
const key = vnode.props?.key;
|
||||
if (key !== undefined) {
|
||||
const existing = newEntries.find(e => e.key === key);
|
||||
if (!existing) newEntries.push({ key, renderer });
|
||||
}
|
||||
if (renderer && typeof renderer === 'function') {
|
||||
const content = renderer();
|
||||
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null;
|
||||
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded);
|
||||
if (key !== undefined) _compExpandedCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh);
|
||||
}
|
||||
|
||||
const rawChildren = vnode.ch || [];
|
||||
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
|
||||
const children = [];
|
||||
for (let i = 0; i < rawChildren.length; i++) {
|
||||
const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i]);
|
||||
children.push(...normalized);
|
||||
}
|
||||
|
||||
const newVNode = { tag: vnode.tag, props: vnode.props, ch: children };
|
||||
|
||||
// Preserve _vnodeDom entry: if the old vnode at this position had a
|
||||
// DOM association, transfer it to the new normalized vnode so diff
|
||||
// can locate existing DOM without creating duplicates.
|
||||
if (prevCh && _vnodeDom.has(prevCh)) {
|
||||
_vnodeDom.set(newVNode, _vnodeDom.get(prevCh));
|
||||
}
|
||||
|
||||
return [newVNode];
|
||||
}
|
||||
|
||||
/** Collect all #comp entries {key, renderer} from a vnode tree. */
|
||||
function collectCompEntries(vnodes, entries) {
|
||||
for (const v of vnodes || []) {
|
||||
if (!v) continue;
|
||||
if (v.tag === '#comp') {
|
||||
const key = v.props?.key;
|
||||
const renderer = v.props?.component;
|
||||
if (key !== undefined) entries.push({ key, renderer });
|
||||
}
|
||||
if (v.ch) collectCompEntries(v.ch, entries);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two VNode arrays inside a container, patching in place.
|
||||
*
|
||||
* Fix: anchor tracking ensures correct DOM insertion order.
|
||||
* Fix: _vnodeDom updated after every patch.
|
||||
*/
|
||||
function diffContainer(container, prev, vnodes) {
|
||||
const maxLen = Math.max(vnodes.length, prev.length);
|
||||
let lastDom = null;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const oldV = prev[i], newV = vnodes[i];
|
||||
|
||||
if (!newV && oldV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (newV && !oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
container.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldDom = getDom(oldV);
|
||||
if (oldDom && oldV.tag === newV.tag) {
|
||||
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
|
||||
lastDom = getDom(newV);
|
||||
} else {
|
||||
if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
if (oldDom?.parentNode) oldDom.parentNode.replaceChild(nd, oldDom);
|
||||
lastDom = nd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Hoover — router.js
|
||||
*
|
||||
* Hash-based SPA router with reactive state (triggers re-render on
|
||||
* navigation). Link component for client-side navigation.
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js';
|
||||
import { h } from './vdom.js';
|
||||
|
||||
/**
|
||||
* Hash-based router.
|
||||
*
|
||||
* const router = createRouter({
|
||||
* '/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
|
||||
* '/interfaces': () => h('#comp', { component: InterfacesPage, key: '/interfaces' }, []),
|
||||
* '*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
|
||||
* });
|
||||
*
|
||||
* Reactive `router.state.path` updates trigger re-renders automatically.
|
||||
*/
|
||||
export function createRouter(routes) {
|
||||
const initialPath = location.hash.slice(1) || '/dashboard';
|
||||
if (!location.hash) location.hash = initialPath;
|
||||
|
||||
const state = reactive({ path: initialPath });
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
state.path = location.hash.slice(1) || '/dashboard';
|
||||
});
|
||||
|
||||
const component = () => {
|
||||
const handler = routes[state.path] || routes['*'];
|
||||
if (!handler) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted' }, `404 — Not found: ${state.path}`));
|
||||
}
|
||||
try {
|
||||
return handler();
|
||||
} catch (e) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted' }, `Error: ${e.message || String(e)}`));
|
||||
}
|
||||
};
|
||||
|
||||
return { state, navigate: (p) => { location.hash = p; }, component };
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side navigation link component.
|
||||
* Sets `location.hash` without full page navigation.
|
||||
*/
|
||||
export function Link(props) {
|
||||
const { path, class: cls, children, ...rest } = props || {};
|
||||
return h('a', {
|
||||
href: '#' + path,
|
||||
class: cls || '',
|
||||
'on:click': (e) => { e.preventDefault(); location.hash = path; },
|
||||
...rest,
|
||||
}, children || []);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Hoover — vdom.js
|
||||
*
|
||||
* Virtual DOM: h() factory, vnode creation, diffing, patching.
|
||||
* Maintains _vnodeDom WeakMap for vnode ↔ DOM element resolution.
|
||||
*
|
||||
* Critical fixes vs. reactive-dom.js:
|
||||
* - _vnodeDom updated after EVERY vnode→dom assignment
|
||||
* - Keyed diff with proper element reordering
|
||||
* - Unkeyed diff with anchor tracking
|
||||
* - Proper unmountTree for cleanup (fires registered onUnmount hooks)
|
||||
*/
|
||||
|
||||
// Exported so render.js can access it
|
||||
export const _vnodeDom = new WeakMap();
|
||||
|
||||
// Lifecycle hooks registry (component.js populates this)
|
||||
export const _mountFn = { fn: null };
|
||||
export const _unmountFn = { fn: null };
|
||||
|
||||
export function setMountFn(fn) { _mountFn.fn = fn; }
|
||||
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
|
||||
|
||||
/**
|
||||
* Build a VNode. Three forms:
|
||||
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
|
||||
* h(ComponentFn, { prop: 1 }, child1, child2) — component (fn called)
|
||||
* h('#text', 'some text') — text node
|
||||
*/
|
||||
export function h(tag, props, ...children) {
|
||||
if (typeof tag === 'function') {
|
||||
const base = typeof props === 'object' && props !== null ? props : {};
|
||||
if (!base.children && children.length)
|
||||
base.children = flatten(children);
|
||||
return tag(base);
|
||||
}
|
||||
if (tag === '#text')
|
||||
return { tag: '#text', text: String(props) };
|
||||
if (tag === '#comp') {
|
||||
return { tag: '#comp', props: props || {}, ch: flatten(children) };
|
||||
}
|
||||
return { tag, props: props || {}, ch: flatten(children) };
|
||||
}
|
||||
|
||||
/** Flatten nested arrays / primitives → VNode array. */
|
||||
function flatten(arr) {
|
||||
const out = [];
|
||||
for (const c of arr.flat(Infinity)) {
|
||||
if (c == null || typeof c === 'boolean') continue;
|
||||
out.push(
|
||||
typeof c === 'string' || typeof c === 'number'
|
||||
? { tag: '#text', text: String(c) }
|
||||
: c,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the real DOM element for a VNode via _vnodeDom.
|
||||
*/
|
||||
export function getDom(vnode) {
|
||||
return vnode ? _vnodeDom.get(vnode) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a real DOM element (or subtree) from a VNode.
|
||||
* Also registers _vnodeDom mapping for the created element and all descendants.
|
||||
*/
|
||||
export function createDom(vnode) {
|
||||
if (!vnode) return document.createTextNode('');
|
||||
if (vnode.tag === '#text') {
|
||||
const tn = document.createTextNode(vnode.text || '');
|
||||
_vnodeDom.set(vnode, tn);
|
||||
return tn;
|
||||
}
|
||||
const el = document.createElement(vnode.tag);
|
||||
applyProps(el, vnode.props);
|
||||
_vnodeDom.set(vnode, el);
|
||||
for (const c of vnode.ch || []) {
|
||||
el.appendChild(createDom(c));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Apply every prop on an element (initial mount). */
|
||||
export function applyProps(el, props) {
|
||||
for (const [k, v] of Object.entries(props)) setProp(el, k, v);
|
||||
}
|
||||
|
||||
/** Set a single prop (or event) on an element. */
|
||||
export function setProp(el, key, value) {
|
||||
if (key === 'key' || key === 'ref') return;
|
||||
if (key === 'html') { el.innerHTML = String(value); return; }
|
||||
if (key === 'innerHTML') { el.innerHTML = String(value); return; }
|
||||
if (key === 'textContent') { el.textContent = String(value); return; }
|
||||
|
||||
if (key.startsWith('on:')) {
|
||||
const ev = key.slice(3);
|
||||
const map = el._evMap || {};
|
||||
if (map[ev]) el.removeEventListener(ev, map[ev]);
|
||||
if (typeof value === 'function') {
|
||||
el.addEventListener(ev, value);
|
||||
map[ev] = value;
|
||||
} else delete map[ev];
|
||||
el._evMap = map;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'class' && typeof value === 'object' && value !== null) {
|
||||
el.className = Object.keys(value).filter(k => value[k]).join(' ');
|
||||
return;
|
||||
}
|
||||
if (key === 'style' && typeof value === 'object' && value !== null) {
|
||||
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) {
|
||||
el.value = value == null ? '' : String(value); return;
|
||||
}
|
||||
if (key === 'checked' && tag === 'input') { el.checked = !!value; return; }
|
||||
if (key === 'disabled') { el.disabled = !!value; return; }
|
||||
if (key === 'selected' && tag === 'option') { el.selected = !!value; return; }
|
||||
|
||||
if (value == null || value === false || value === undefined)
|
||||
el.removeAttribute(key);
|
||||
else
|
||||
el.setAttribute(key, value === true ? '' : String(value));
|
||||
}
|
||||
|
||||
/** Remove a single prop from an element. */
|
||||
export function unsetProp(el, key) {
|
||||
if (key === 'key' || key === 'ref') return;
|
||||
if (key.startsWith('on:')) {
|
||||
const ev = key.slice(3);
|
||||
const map = el._evMap || {};
|
||||
if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; }
|
||||
el._evMap = map;
|
||||
return;
|
||||
}
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return;
|
||||
if (key === 'checked' && tag === 'input') { el.checked = false; return; }
|
||||
if (key === 'disabled') { el.disabled = false; return; }
|
||||
if (key === 'selected' && tag === 'option') { el.selected = false; return; }
|
||||
el.removeAttribute(key);
|
||||
}
|
||||
|
||||
/** Diff two props objects and patch the element in place. */
|
||||
export function patchProps(el, oldP = {}, newP = {}) {
|
||||
for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) {
|
||||
const hasOld = k in oldP, hasNew = k in newP;
|
||||
if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue;
|
||||
if (hasNew) setProp(el, k, newP[k]);
|
||||
else unsetProp(el, k);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively clean up event listeners and child nodes. */
|
||||
export function sweepDom(el) {
|
||||
if (_unmountFn.fn) _unmountFn.fn(el);
|
||||
for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]);
|
||||
while (el.firstChild) {
|
||||
const child = el.firstChild;
|
||||
if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child);
|
||||
el.removeChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch children of a parent element.
|
||||
* Dispatches to keyed or unkeyed patching based on whether any vnode has a key.
|
||||
*/
|
||||
export function patchChildren(parent, oldCh, newCh) {
|
||||
const hasKeys = (ch) => ch.some(v => v?.props?.key != null);
|
||||
if (hasKeys(newCh) && hasKeys(oldCh))
|
||||
patchKeyed(parent, oldCh, newCh);
|
||||
else
|
||||
patchUnkeyed(parent, oldCh, newCh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unkeyed (index-based) children diff.
|
||||
*
|
||||
* Fix: _vnodeDom updated after EVERY vnode→dom assignment.
|
||||
*/
|
||||
export function patchUnkeyed(parent, oldCh, newCh) {
|
||||
const maxLen = Math.max(oldCh.length, newCh.length);
|
||||
let lastDom = null;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const oldV = oldCh[i], newV = newCh[i];
|
||||
|
||||
if (!newV && oldV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (newV && !oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
continue;
|
||||
}
|
||||
|
||||
patchNode(parent, oldV, newV, null);
|
||||
lastDom = getDom(newV);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed children diff — preserves order, reuses DOM by key.
|
||||
*
|
||||
* Fix: proper element reordering using lastDom anchor tracking.
|
||||
*/
|
||||
export function patchKeyed(parent, oldCh, newCh) {
|
||||
const oldMap = new Map(
|
||||
oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v])
|
||||
);
|
||||
const toRemove = new Set(oldMap.keys());
|
||||
let lastDom = null;
|
||||
|
||||
for (const newV of newCh) {
|
||||
const key = newV.props?.key;
|
||||
toRemove.delete(key);
|
||||
const oldV = oldMap.get(key);
|
||||
|
||||
if (oldV) {
|
||||
patchNode(parent, oldV, newV, null);
|
||||
const d = getDom(newV);
|
||||
if (d) {
|
||||
if (lastDom && d !== lastDom.nextSibling) {
|
||||
parent.insertBefore(d, lastDom.nextSibling || null);
|
||||
}
|
||||
lastDom = d;
|
||||
}
|
||||
} else {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of toRemove) {
|
||||
const oldV = oldMap.get(key);
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch one VNode against another inside parent.
|
||||
*
|
||||
* - no old → create + insert
|
||||
* - no new → sweep + remove
|
||||
* - tag match → patchProps + patchChildren
|
||||
* - tag mismatch → replace
|
||||
*
|
||||
* Fix: _vnodeDom always set to the correct dom after patch.
|
||||
*/
|
||||
export function patchNode(parent, oldV, newV, anchor) {
|
||||
if (!oldV && !newV) return;
|
||||
|
||||
if (!oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, anchor || null);
|
||||
return;
|
||||
}
|
||||
if (!newV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dom = getDom(oldV);
|
||||
if (!dom || !dom.parentNode) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, anchor || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tag changed → full replace
|
||||
if (oldV.tag !== newV.tag) {
|
||||
if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
dom.parentNode.replaceChild(nd, dom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text node — fast path
|
||||
if (oldV.tag === '#text') {
|
||||
if (oldV.text !== newV.text) dom.nodeValue = newV.text;
|
||||
_vnodeDom.set(newV, dom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Element: patch in place
|
||||
patchProps(dom, oldV.props || {}, newV.props || {});
|
||||
patchChildren(dom, oldV.ch || [], newV.ch || []);
|
||||
_vnodeDom.set(newV, dom);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Hoover — websocket.js
|
||||
*
|
||||
* WebSocket connection manager with auto-reconnect, subscribe/unsubscribe
|
||||
* per component per topic, and version-track messages.
|
||||
*
|
||||
* The _wsSubs Map stores entries keyed by renderer function so that
|
||||
* auto-refresh messages from the backend can trigger page reloads.
|
||||
*/
|
||||
|
||||
import { setSubscribeFn } from './component.js';
|
||||
|
||||
const _wsSubs = new Map();
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||
* origin has no port (nginx fronting the WS on a different port).
|
||||
*/
|
||||
function _wsUrl() {
|
||||
if (window.__WS_URL__) return window.__WS_URL__;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return proto + '//' + location.host + '/ws';
|
||||
}
|
||||
|
||||
/** Attempt a WebSocket connection. */
|
||||
function _wsConnect() {
|
||||
if (_wsConn && _wsConn.readyState <= 1) return;
|
||||
|
||||
_wsConn = new WebSocket(_wsUrl());
|
||||
|
||||
_wsConn.onopen = () => {
|
||||
_wsReconnectMs = 0;
|
||||
};
|
||||
|
||||
_wsConn.onclose = () => {
|
||||
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
|
||||
setTimeout(_wsConnect, _wsReconnectMs);
|
||||
};
|
||||
|
||||
_wsConn.onerror = () => {
|
||||
_wsConn.close();
|
||||
};
|
||||
|
||||
_wsConn.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
|
||||
handleMessage(msg);
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an incoming WS message to subscribed components.
|
||||
*
|
||||
* Expected message shapes:
|
||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
||||
* { type: 'notify', topic: 'firewall' }
|
||||
* { type: 'status', topic: 'firewall', … }
|
||||
*
|
||||
* Components subscribed to wildcard ('*') match every topic.
|
||||
*/
|
||||
function handleMessage(msg) {
|
||||
const topics = [];
|
||||
|
||||
if (msg.type === 'versions' || msg.type === 'refresh') {
|
||||
topics.push(...(msg.updated || msg.topics || []));
|
||||
} else if (msg.type === 'notify') {
|
||||
topics.push(msg.topic);
|
||||
} else if (msg.type === 'status') {
|
||||
topics.push(msg.topic || '*');
|
||||
}
|
||||
|
||||
for (const s of _wsSubs.values()) {
|
||||
if (s.unsubscribed) continue;
|
||||
if (s.topic === '*') {
|
||||
s.loadFn(s.state);
|
||||
} else if (topics.some(t => t === s.topic || t === '*')) {
|
||||
s.loadFn(s.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a component to WS topics.
|
||||
*
|
||||
* Called by component.js on mount. Returns an unsubscribe function
|
||||
* called by component.js on unmount.
|
||||
*
|
||||
* @param {function} componentFn – The page renderer function (used as map key)
|
||||
* @param {string} topic – Topic to listen for ('*' = all)
|
||||
* @param {function} loadFn – Function to call when topic updates
|
||||
* @param {object} state – Reactive state passed to loadFn
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
function subscribe(componentFn, topic, loadFn, state) {
|
||||
const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
|
||||
_wsSubs.set(componentFn, entry);
|
||||
|
||||
return () => {
|
||||
entry.unsubscribed = true;
|
||||
_wsSubs.delete(componentFn);
|
||||
};
|
||||
}
|
||||
|
||||
/** Register the subscribe function with component.js and kick off connection. */
|
||||
setSubscribeFn(subscribe);
|
||||
|
||||
/** Start the WebSocket connection. */
|
||||
export function connect() {
|
||||
_wsConnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public subscribe API for direct one-off usage (e.g. from page code).
|
||||
* @param {string|string[]} topics
|
||||
* @param {function} handler
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
export function onMessage(topics, handler) {
|
||||
const tArray = Array.isArray(topics) ? topics : [topics];
|
||||
const fns = [];
|
||||
for (const t of tArray) {
|
||||
const entry = {
|
||||
componentFn: handler, topic: t, loadFn: handler, state: {},
|
||||
unsubscribed: false
|
||||
};
|
||||
_wsSubs.set(handler + ':' + t, entry);
|
||||
fns.push(() => { entry.unsubscribed = true; _wsSubs.delete(handler + ':' + t); });
|
||||
}
|
||||
return () => fns.forEach(f => f());
|
||||
}
|
||||
Reference in New Issue
Block a user