Add real-time push: open tabs update the instant an admin edit lands

This commit is contained in:
Ally Founder 2026-08-02 15:14:13 -04:00
parent d745331f75
commit 3f4c2ca56d
3 changed files with 115 additions and 14 deletions

View file

@ -2,9 +2,51 @@ export interface Env {
ASSETS: Fetcher; ASSETS: Fetcher;
DB: D1Database; DB: D1Database;
OVERLAY_CACHE: KVNamespace; OVERLAY_CACHE: KVNamespace;
HUB: DurableObjectNamespace;
ADMIN_TOKEN: string; 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<Response> {
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 { interface Command {
cmd: string; cmd: string;
desc: string; desc: string;
@ -136,6 +178,13 @@ async function handle(request: Request, env: Env): Promise<Response> {
return new Response(null, { headers: CORS_HEADERS }); 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 // 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 // (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 // 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<Response> {
).run(); ).run();
await env.OVERLAY_CACHE.delete(CACHE_KEY); await env.OVERLAY_CACHE.delete(CACHE_KEY);
await broadcast(env, { type: "overlay-updated", id });
return json({ ok: true }); return json({ ok: true });
} }
if (request.method === "DELETE") { if (request.method === "DELETE") {
await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run(); await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run();
await env.OVERLAY_CACHE.delete(CACHE_KEY); await env.OVERLAY_CACHE.delete(CACHE_KEY);
await broadcast(env, { type: "overlay-updated", id });
return json({ ok: true }); return json({ ok: true });
} }
} }

View file

@ -15,3 +15,11 @@ database_id = "639fef3c-6583-4128-adbd-b5f5f4761483"
[[kv_namespaces]] [[kv_namespaces]]
binding = "OVERLAY_CACHE" binding = "OVERLAY_CACHE"
id = "e56d183bdcd64f5facade1c3fe04eb08" id = "e56d183bdcd64f5facade1c3fe04eb08"
[[durable_objects.bindings]]
name = "HUB"
class_name = "OverlayHub"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["OverlayHub"]

View file

@ -678,22 +678,64 @@
} }
}); });
/* ---------- Boot ---------- */ /* ---------- Boot + live updates ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json") function reloadPlugins(isInitial) {
return fetch("https://rp.jarviscli.dev/plugins-data.json", { cache: "no-store" })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
state.plugins = data; state.plugins = data;
renderStats(); renderStats();
renderChips(); renderChips();
render(); render();
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (isInitial) {
routeFromLocation(); 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 () { .catch(function () {
if (!isInitial) return;
count.textContent = ""; count.textContent = "";
empty.hidden = false; empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing."; 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();
})(); })();
</script> </script>
</body> </body>