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