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);
}