/** Live hashrate for the eom.dev P2Pool node, read from the same-origin summary that * the monero role's stats sidecar publishes. The summary never contains miner IPs or * wallets. Standalone public-data client: no credentials, storage, HTML sinks or logging. */ export const ENDPOINT = '/p2pool/stats.json'; export const MAX_BYTES = 64 * 1024; export const REFRESH_MS = 60 * 1000; const CHAINS = new Set(['main', 'mini', 'nano']); const UNITS = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s']; const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value); const count = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; const epoch = value => Number.isSafeInteger(value) && value > 0 ? value : null; function section(raw, fields) { if (!isRecord(raw)) return null; const out = Object.fromEntries(fields.map(field => [field, count(raw[field])])); out.updatedAt = epoch(raw.updated_at); out.stale = raw.stale === true; return out; } /** Copy only known numeric fields; anything unexpected becomes null. */ export function normalizeStats(data) { if (!isRecord(data) || data.version !== 1) throw new Error('Unsupported stats document'); return { sidechain: typeof data.sidechain === 'string' && CHAINS.has(data.sidechain) ? data.sidechain : null, node: section(data.node, ['hashrate_15m', 'hashrate_1h', 'hashrate_24h', 'miners', 'shares_found']), pool: section(data.pool, ['hashrate', 'miners']), network: section(data.network, ['hashrate']), }; } /** 8123 → "8.12 kH/s"; null → "—". */ export function formatHashrate(value) { if (count(value) === null) return '—'; let scaled = value; let unit = 0; while (scaled >= 1000 && unit < UNITS.length - 1) { scaled /= 1000; unit++; } const digits = scaled >= 100 || unit === 0 ? 0 : scaled >= 10 ? 1 : 2; return `${scaled.toFixed(digits)} ${UNITS[unit]}`; } export function formatCount(value) { return count(value) === null ? '—' : Math.round(value).toLocaleString('en-US'); } const abortError = () => new DOMException('Pool stats request aborted', 'AbortError'); /** Fixed same-origin endpoint. Timer covers headers AND streamed body. */ export async function fetchStats({ fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES } = {}) { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds'); const controller = new AbortController(); let reader; let rejectAbort; const aborted = new Promise((_, reject) => { rejectAbort = reject; }); const cancel = () => { controller.abort(); rejectAbort(abortError()); }; signal?.addEventListener('abort', cancel, { once: true }); const timer = setTimeout(cancel, timeoutMs); const work = async () => { if (signal?.aborted) throw abortError(); const response = await fetchImpl(ENDPOINT, { method: 'GET', signal: controller.signal, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', cache: 'no-store', headers: { Accept: 'application/json' } }); if (controller.signal.aborted) throw abortError(); if (!response.ok || response.redirected) throw new Error('Pool stats request failed'); if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') { throw new Error('Unexpected content type'); } const length = response.headers.get('content-length'); if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large'); if (!response.body?.getReader) throw new Error('Streaming response required'); reader = response.body.getReader(); const decoder = new TextDecoder('utf-8', { fatal: true }); let body = ''; let bytes = 0; while (true) { const { done, value } = await reader.read(); if (controller.signal.aborted) throw abortError(); if (done) break; bytes += value.byteLength; if (bytes > maxBytes) throw new Error('Response too large'); body += decoder.decode(value, { stream: true }); } return normalizeStats(JSON.parse(body + decoder.decode())); }; try { return await Promise.race([work(), aborted]); } finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); controller.abort(); if (reader) void Promise.resolve(reader.cancel()).catch(() => {}); } } const time = seconds => new Date(seconds * 1000).toISOString().slice(11, 16) + ' UTC'; /** Text for each [data-stat] cell. Prefers the 15-minute node average, then 1h, then 24h. */ export function statValues(stats) { const node = stats.node; const hashrate = node ? node.hashrate_15m ?? node.hashrate_1h ?? node.hashrate_24h : null; return { 'node-hashrate': formatHashrate(hashrate), 'node-miners': formatCount(node?.miners), 'node-shares': formatCount(node?.shares_found), 'pool-hashrate': formatHashrate(stats.pool?.hashrate), 'network-hashrate': formatHashrate(stats.network?.hashrate), }; } export function statusText(stats) { const chain = stats.sidechain ? ` · ${stats.sidechain} sidechain` : ''; const node = stats.node; if (!node) return `Live pool stats unavailable right now${chain}.`; if (node.stale) { return `The node last reported stats${node.updatedAt ? ` at ${time(node.updatedAt)}` : ''}; newer figures will appear when it reports again${chain}.`; } return `Live${node.updatedAt ? ` · updated ${time(node.updatedAt)}` : ''}${chain} · refreshes every minute.`; } /** DOM contract: #p2pool-stats > [data-p2pool-status], [data-p2pool-stats] [data-stat="…"]. * Static "—" placeholders stay in the markup until a valid summary arrives. */ export function initP2PoolStats(document = globalThis.document, { fetchImpl = globalThis.fetch, setIntervalImpl = globalThis.setInterval, clearIntervalImpl = globalThis.clearInterval, ...requestBounds } = {}) { const root = document?.querySelector('#p2pool-stats'); const status = root?.querySelector('[data-p2pool-status]'); const cells = new Map([...(root?.querySelectorAll('[data-p2pool-stats] [data-stat]') ?? [])] .map(cell => [cell.getAttribute('data-stat'), cell])); if (!status || !cells.size) return { ready: Promise.resolve(), refresh: async () => {}, stop() {} }; status.setAttribute('role', 'status'); let stopped = false; let generation = 0; let pending; let interval; let loaded = false; const invalidate = () => { generation++; pending?.abort(); pending = undefined; }; const refresh = async () => { if (stopped || document.hidden) return; invalidate(); const current = generation; const controller = new AbortController(); pending = controller; try { const stats = await fetchStats({ ...requestBounds, fetchImpl, signal: controller.signal }); if (stopped || current !== generation) return; for (const [key, value] of Object.entries(statValues(stats))) { const cell = cells.get(key); if (cell) cell.textContent = value; } loaded = true; status.textContent = statusText(stats); } catch { if (!stopped && current === generation) { status.textContent = loaded ? 'Live pool stats unavailable; showing the last figures received.' : 'Live pool stats unavailable right now. You can still join with the configuration below.'; } } finally { if (current === generation) pending = undefined; } }; const pauseTimer = () => { if (interval !== undefined) clearIntervalImpl(interval); interval = undefined; }; const startTimer = () => { if (!stopped && !document.hidden && interval === undefined) interval = setIntervalImpl(() => { if (!pending) void refresh(); }, REFRESH_MS); }; const visibility = () => { if (stopped) return; if (document.hidden) { pauseTimer(); invalidate(); } else { startTimer(); void refresh(); } }; document.addEventListener('visibilitychange', visibility); startTimer(); const ready = refresh(); return { ready, refresh, stop() { if (stopped) return; stopped = true; pauseTimer(); invalidate(); document.removeEventListener('visibilitychange', visibility); } }; } if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initP2PoolStats(), { once: true }); else initP2PoolStats(); }