diff --git a/api/src/index.ts b/api/src/index.ts index e0aca9b..cdb85d9 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -2,9 +2,51 @@ export interface Env { ASSETS: Fetcher; DB: D1Database; OVERLAY_CACHE: KVNamespace; + HUB: DurableObjectNamespace; ADMIN_TOKEN: string; } +// Single global room every browser tab connects to - broadcasts "something changed, refetch" to +// every open tab the moment an admin write succeeds, instead of tabs only picking it up on their +// next natural fetch. Uses the Hibernatable WebSockets API (acceptWebSocket/getWebSockets) so the +// object doesn't have to stay pinned in memory (and billed) between messages - it wakes on a new +// connection, a client event, or an incoming /broadcast call, and can go back to sleep otherwise. +export class OverlayHub { + constructor(private state: DurableObjectState, private env: Env) {} + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === "/broadcast") { + const body = await request.text(); + for (const ws of this.state.getWebSockets()) { + try { ws.send(body); } catch { /* dead socket, ignore */ } + } + return new Response("ok"); + } + + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("expected websocket", { status: 426 }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + this.state.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage() { /* clients never need to send anything, ignore */ } + + async webSocketClose(ws: WebSocket) { + try { ws.close(); } catch { /* already closing */ } + } +} + +async function broadcast(env: Env, message: unknown) { + const stub = env.HUB.get(env.HUB.idFromName("global")); + await stub.fetch("https://internal/broadcast", { method: "POST", body: JSON.stringify(message) }); +} + interface Command { cmd: string; desc: string; @@ -136,6 +178,13 @@ async function handle(request: Request, env: Env): Promise { return new Response(null, { headers: CORS_HEADERS }); } + // Live-update channel - the site connects here once on load; every successful overlay write + // broadcasts through it so an already-open tab updates without the visitor doing anything. + if (url.pathname === "/ws") { + const stub = env.HUB.get(env.HUB.idFromName("global")); + return stub.fetch(request); + } + // The site's actual data file - computed fresh on every request by merging the git-tracked base // (plugins-base.json, written by generate-site.mjs at build time) with the live overlay, so any // change from the admin plugin is visible immediately, with no rebuild. Draft-only plugins (no @@ -249,12 +298,14 @@ async function handle(request: Request, env: Env): Promise { ).run(); await env.OVERLAY_CACHE.delete(CACHE_KEY); + await broadcast(env, { type: "overlay-updated", id }); return json({ ok: true }); } if (request.method === "DELETE") { await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run(); await env.OVERLAY_CACHE.delete(CACHE_KEY); + await broadcast(env, { type: "overlay-updated", id }); return json({ ok: true }); } } diff --git a/api/wrangler.toml b/api/wrangler.toml index d541aff..e9c1c98 100644 --- a/api/wrangler.toml +++ b/api/wrangler.toml @@ -15,3 +15,11 @@ database_id = "639fef3c-6583-4128-adbd-b5f5f4761483" [[kv_namespaces]] binding = "OVERLAY_CACHE" id = "e56d183bdcd64f5facade1c3fe04eb08" + +[[durable_objects.bindings]] +name = "HUB" +class_name = "OverlayHub" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["OverlayHub"] diff --git a/site/index.html b/site/index.html index 02cba7b..870a954 100644 --- a/site/index.html +++ b/site/index.html @@ -678,22 +678,64 @@ } }); - /* ---------- Boot ---------- */ + /* ---------- Boot + live updates ---------- */ - fetch("https://rp.jarviscli.dev/plugins-data.json") - .then(function (r) { return r.json(); }) - .then(function (data) { - state.plugins = data; - renderStats(); - renderChips(); - render(); - routeFromLocation(); - }) - .catch(function () { - count.textContent = ""; - empty.hidden = false; - empty.textContent = "Couldn't load the plugin list - try refreshing."; + function reloadPlugins(isInitial) { + return fetch("https://rp.jarviscli.dev/plugins-data.json", { cache: "no-store" }) + .then(function (r) { return r.json(); }) + .then(function (data) { + state.plugins = data; + renderStats(); + renderChips(); + render(); + + var match = location.pathname.match(/^\/([\w-]+)\/?$/); + if (isInitial) { + routeFromLocation(); + } else if (match && match[1]) { + // Re-render the open detail page too, in case what changed is the plugin being viewed + // right now (e.g. it just got marked broken). + showDetail(match[1]); + showToast("Updated"); + } else { + showToast("Updated"); + } + }) + .catch(function () { + if (!isInitial) return; + count.textContent = ""; + empty.hidden = false; + empty.textContent = "Couldn't load the plugin list - try refreshing."; + }); + } + + /** + * One shared WebSocket room (api/src/index.ts's OverlayHub Durable Object) that every open tab + * connects to - any successful admin write broadcasts through it, so a change from the admin + * plugin shows up here without needing a manual refresh. Reconnects with backoff if it drops + * (Cloudflare recycling the connection, a network blip, etc) rather than just giving up. + */ + var liveRetryDelay = 2000; + function connectLive() { + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + var ws; + try { + ws = new WebSocket(proto + "//" + location.host + "/ws"); + } catch (e) { + return; + } + + ws.addEventListener("open", function () { liveRetryDelay = 2000; }); + ws.addEventListener("message", function () { reloadPlugins(false); }); + ws.addEventListener("close", function () { + setTimeout(connectLive, liveRetryDelay); + liveRetryDelay = Math.min(liveRetryDelay * 1.5, 30000); }); + ws.addEventListener("error", function () { ws.close(); }); + } + + reloadPlugins(true); + connectLive(); })();