Add real-time push: open tabs update the instant an admin edit lands
This commit is contained in:
parent
d745331f75
commit
3f4c2ca56d
3 changed files with 115 additions and 14 deletions
|
|
@ -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<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 {
|
||||
cmd: string;
|
||||
desc: string;
|
||||
|
|
@ -136,6 +178,13 @@ async function handle(request: Request, env: Env): Promise<Response> {
|
|||
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<Response> {
|
|||
).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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue