diff --git a/public/index.html b/public/index.html index e59e495..ed7d68a 100644 --- a/public/index.html +++ b/public/index.html @@ -528,15 +528,15 @@

🔍 Live Debug

- polling every - + + + - -
@@ -636,7 +636,8 @@ // ═══════════════════════════════════════════════ let views = []; - let debugPollTimer = null; + let debugSocket = null; + let debugReconnectTimer = null; let debugVisible = false; let debugIntervalMs = 1000; const { origin } = location; @@ -970,9 +971,54 @@ } // ═══════════════════════════════════════════════ - // DEBUG PANEL + // DEBUG PANEL (WebSocket) // ═══════════════════════════════════════════════ + function connectDebugSocket() { + if (debugSocket) return; + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${location.host}/ws?interval=${debugIntervalMs}`; + debugSocket = new WebSocket(wsUrl); + + debugSocket.onopen = () => { + if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; } + }; + + debugSocket.onmessage = (event) => { + try { + lastDebugData = JSON.parse(event.data); + renderDebug(lastDebugData); + } catch { /* ignore */ } + }; + + debugSocket.onclose = () => { + debugSocket = null; + if (debugVisible) { + debugReconnectTimer = setTimeout(connectDebugSocket, 2000); + } + }; + + debugSocket.onerror = () => { + debugSocket?.close(); + }; + } + + function disconnectDebugSocket() { + if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; } + if (debugSocket) { + debugSocket.onclose = null; // prevent auto-reconnect + debugSocket.close(); + debugSocket = null; + } + } + + function setDebugInterval(ms) { + debugIntervalMs = ms; + if (debugSocket && debugSocket.readyState === WebSocket.OPEN) { + debugSocket.send(JSON.stringify({ type: "debug-interval", interval: ms })); + } + } + function toggleDebug() { debugVisible = !debugVisible; const panel = document.getElementById("debug-panel"); @@ -980,39 +1026,15 @@ if (debugVisible) { panel.classList.add("visible"); btn.textContent = "🔍 Hide Debug"; - pollDebug(); - debugPollTimer = setInterval(pollDebug, debugIntervalMs); + connectDebugSocket(); } else { panel.classList.remove("visible"); btn.textContent = "🔍 Debug"; - if (debugPollTimer) { - clearInterval(debugPollTimer); - debugPollTimer = null; - } - } - } - - function onDebugIntervalChange() { - debugIntervalMs = parseInt(document.getElementById("debug-interval").value); - if (debugPollTimer) { - clearInterval(debugPollTimer); - debugPollTimer = setInterval(pollDebug, debugIntervalMs); + disconnectDebugSocket(); } } let lastDebugData = null; - - async function pollDebug() { - if (!debugVisible) return; - try { - const res = await fetch(origin + "/api/debug"); - lastDebugData = await res.json(); - renderDebug(lastDebugData); - } catch { - /* silent */ - } - } - let debugTickInterval = null; function renderDebug(data) { diff --git a/src/server.ts b/src/server.ts index 7875a94..035dd87 100644 --- a/src/server.ts +++ b/src/server.ts @@ -236,6 +236,45 @@ function devicesSnapshot(): DeviceEntry[] { return [...devices.values()].sort((a, b) => b.lastSeen - a.lastSeen); } +// ═══════════════════════════════════════════════════════════════ +// DEBUG DATA HELPER (used by both REST and WebSocket) +// ═══════════════════════════════════════════════════════════════ + +function getDebugData() { + const viewsDebug = viewCacheAll().map(v => { + const idx = getCurrentURLIndex(v); + return { + id: v.id, + name: v.name, + urls: v.urls, + method: v.method, + metaRefreshEnabled: v.metaRefreshEnabled, + cacheTtlSec: v.cacheTtlSec, + viewportWidth: v.viewportWidth, + viewportHeight: v.viewportHeight, + currentUrlIndex: idx, + currentUrl: v.urls[idx]?.url ?? "", + remainingSec: getRemainingSec(v), + totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0), + cacheEntries: cacheStats().filter(c => c.viewId === v.id), + }; + }); + + return { + server: { + uptime: Math.round(process.uptime()), + activeRenders: pagePool?.activeRenders ?? 0, + queueLength: renderQueue.length, + cacheEntries: renderCache.size, + browsers: CONFIG.BROWSER_COUNT, + pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, + maxConcurrentRenders: CONFIG.MAX_CONCURRENT_RENDERS, + }, + views: viewsDebug, + devices: devicesSnapshot(), + }; +} + // ═══════════════════════════════════════════════════════════════ // BROWSER + PAGE POOL // ═══════════════════════════════════════════════════════════════ @@ -390,6 +429,17 @@ class PagePool { let pagePool: PagePool; +// ═══════════════════════════════════════════════════════════════ +// WEBSOCKET DEBUG CLIENTS +// ═══════════════════════════════════════════════════════════════ + +const debugClients = new Map< + any, + { interval: number; timer: ReturnType } +>(); +const DEBUG_DEFAULT_INTERVAL_MS = 1000; +const DEBUG_MIN_INTERVAL_MS = 100; + // ═══════════════════════════════════════════════════════════════ // RENDER CACHE + QUEUE // ═══════════════════════════════════════════════════════════════ @@ -907,38 +957,7 @@ async function handleAPI(req: Request, path: string): Promise { // GET /api/debug if (path === "/api/debug" && method === "GET") { - const viewsDebug = viewCacheAll().map((v) => { - const idx = getCurrentURLIndex(v); - return { - id: v.id, - name: v.name, - urls: v.urls, - method: v.method, - metaRefreshEnabled: v.metaRefreshEnabled, - cacheTtlSec: v.cacheTtlSec, - viewportWidth: v.viewportWidth, - viewportHeight: v.viewportHeight, - currentUrlIndex: idx, - currentUrl: v.urls[idx]?.url ?? "", - remainingSec: getRemainingSec(v), - totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0), - cacheEntries: cacheStats().filter((c) => c.viewId === v.id), - }; - }); - - return json({ - server: { - uptime: Math.round(process.uptime()), - activeRenders: pagePool?.activeRenders ?? 0, - queueLength: renderQueue.length, - cacheEntries: renderCache.size, - browsers: CONFIG.BROWSER_COUNT, - pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, - maxConcurrentRenders: CONFIG.MAX_CONCURRENT_RENDERS, - }, - views: viewsDebug, - devices: devicesSnapshot(), - }); + return json(getDebugData()); } // POST /api/views @@ -1064,13 +1083,69 @@ async function serveStatic(path: string): Promise { // SERVER // ═══════════════════════════════════════════════════════════════ -const server = Bun.serve({ +const server = Bun.serve<{ interval: number }>({ port: CONFIG.PORT, + websocket: { + open(ws) { + const interval = ws.data?.interval ?? DEBUG_DEFAULT_INTERVAL_MS; + const timer = setInterval(() => { + try { + ws.send(JSON.stringify(getDebugData())); + } catch { + clearInterval(timer); + debugClients.delete(ws); + } + }, interval); + debugClients.set(ws, { interval, timer }); + // Send initial payload immediately + try { ws.send(JSON.stringify(getDebugData())); } catch {} + }, + message(ws, msg) { + try { + const data = JSON.parse(msg as string); + if (data.type === "debug-interval" && typeof data.interval === "number") { + const client = debugClients.get(ws); + if (!client) return; + const interval = Math.max(DEBUG_MIN_INTERVAL_MS, data.interval); + clearInterval(client.timer); + client.interval = interval; + client.timer = setInterval(() => { + try { ws.send(JSON.stringify(getDebugData())); } catch { + clearInterval(client.timer); + debugClients.delete(ws); + } + }, interval); + // Push immediately after rate change + try { ws.send(JSON.stringify(getDebugData())); } catch {} + } + } catch { /* ignore malformed messages */ } + }, + close(ws) { + const client = debugClients.get(ws); + if (client) { + clearInterval(client.timer); + debugClients.delete(ws); + } + }, + }, async fetch(req) { const url = new URL(req.url); const path = url.pathname; + // ── WebSocket upgrade for debug panel ── + if (path === "/ws") { + const qInterval = parseInt(url.searchParams.get("interval") ?? ""); + const wsInterval = Math.max( + DEBUG_MIN_INTERVAL_MS, + Number.isFinite(qInterval) ? qInterval : DEBUG_DEFAULT_INTERVAL_MS, + ); + if (server.upgrade(req, { data: { interval: wsInterval } })) { + return; // upgraded — Bun will call websocket.open + } + return new Response("WebSocket upgrade failed", { status: 500 }); + } + // View serving — /v/:id const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); if (viewMatch) { @@ -1089,7 +1164,12 @@ const server = Bun.serve({ }); } - deviceTrack(ip, view.id, view.name, req.headers.get("User-Agent") ?? "unknown"); + deviceTrack( + ip, + view.id, + view.name, + req.headers.get("User-Agent") ?? "unknown", + ); return serveView(view); }