**Recent activity.** The console could describe the present — a status snapshot — but never the recent past. A client that connected and left while you were on another page left no trace anywhere you could look, and the host's own log is a developer artifact rather than a narrative. The event stream was already open for cache invalidation, so a feed costs one ring buffer next to it: every frame is recorded, labelled per kind, and rendered newest-first on the dashboard. Deliberately in-memory and bounded to 200. It starts empty on a page load and fills as things happen, which is the honest shape for a live tail — an audit trail would need the host to keep one, and pretending otherwise would be worse than not having it. **Connect a device.** The console knew the host's address and identity all along and never offered either in a form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on a couch. There is a card now with the address and a `punktfunk://connect/<uniqueid>` deep link, both copyable — the link is the shipped client grammar (clients/shared/deeplink-vectors.json), so an installed client opens straight onto this host. No QR: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none. **Installable.** A web manifest and the theme/apple meta tags, so the console can live on a phone's home screen — which is where it is used from as often as from a desk. No service worker on purpose: an offline shell for a console whose every screen is live host state would only ever show stale numbers convincingly. The manifest is reachable without a session (install needs it, and it says nothing the login page doesn't); /api stays gated, verified. Verified in a browser: three host-emitted events appear in the feed with the right labels, the deep link renders and copies as `punktfunk://connect/abc123`, and the manifest serves 200 as application/manifest+json while /api/v1/host still answers 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
// The host's event stream, wired to React Query's cache.
|
|
//
|
|
// The host publishes every lifecycle transition on `GET /api/v1/events` as SSE — client
|
|
// connect/disconnect, session and stream start/end, pairing decisions, display create/release,
|
|
// library/store/plugin changes, update availability, host start/stop. Nothing consumed it: the
|
|
// console learned about all of it by asking again on ten separate timers, so a change was up to
|
|
// 5 s stale, two pages could disagree with each other while you looked at them, and the Library
|
|
// page — which polls not at all — never noticed a newly installed game until a full reload.
|
|
//
|
|
// This subscribes once for the whole app and invalidates exactly the queries an event affects.
|
|
// It does NOT carry data into the cache: the REST snapshots stay the source of truth, and an event
|
|
// only says "this is stale now". That keeps the wire format additive-only (a kind we don't know
|
|
// costs us nothing) and means a missed event degrades to the polling behaviour we already had.
|
|
//
|
|
// Transport notes:
|
|
// - Same-origin, so the sealed session cookie rides along and the BFF injects the mgmt bearer;
|
|
// no auth work here. `EventSource` reconnects on its own and replays with `Last-Event-ID`,
|
|
// which h3's proxy forwards, so a dropped connection resumes from the host's ring.
|
|
// - The host sends a keep-alive comment every 15 s; the Bun entry's idle timeout is set above
|
|
// that (nitro-entry/bun-https.mjs) so we don't sever our own stream.
|
|
// - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything.
|
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useSyncExternalStore } from "react";
|
|
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
|
|
import { getGetDisplayStateQueryKey } from "@/api/gen/display/display";
|
|
import { getGetStatusQueryKey } from "@/api/gen/host/host";
|
|
import { getGetLibraryQueryKey } from "@/api/gen/library/library";
|
|
import { getListNativeClientsQueryKey } from "@/api/gen/native/native";
|
|
import { getGetPairingStatusQueryKey } from "@/api/gen/pairing/pairing";
|
|
import { getGetUpdateStatusQueryKey } from "@/api/gen/update/update";
|
|
import { boostPluginPolling, PLUGINS_KEY } from "@/api/plugins";
|
|
import { storeKeys } from "@/api/store";
|
|
|
|
/** Which query keys a given event kind invalidates. Unknown kinds are ignored on purpose.
|
|
* (The generated key helpers return `readonly` tuples, which is what React Query wants.) */
|
|
function keysFor(kind: string): readonly (readonly unknown[])[] {
|
|
const status = [getGetStatusQueryKey()];
|
|
switch (kind) {
|
|
// Anything that changes what the host is doing right now moves the dashboard's status.
|
|
case "client.connected":
|
|
case "client.disconnected":
|
|
case "session.started":
|
|
case "session.ended":
|
|
case "stream.started":
|
|
case "stream.stopped":
|
|
case "game.running":
|
|
case "game.exited":
|
|
return status;
|
|
// A display appearing or going away changes the live list, and its policy card shows
|
|
// "in effect" values derived from the same state.
|
|
case "display.created":
|
|
case "display.released":
|
|
return [...status, getGetDisplayStateQueryKey()];
|
|
case "pairing.pending":
|
|
case "pairing.denied":
|
|
return [...status, getGetPairingStatusQueryKey()];
|
|
// A completed pairing also adds a device to whichever plane's list is on screen.
|
|
case "pairing.completed":
|
|
return [
|
|
...status,
|
|
getGetPairingStatusQueryKey(),
|
|
getListPairedClientsQueryKey(),
|
|
getListNativeClientsQueryKey(),
|
|
];
|
|
// The base key with no params is a PREFIX of every parameterised library query, and React
|
|
// Query invalidates by prefix — so this catches the Dashboard's and the Library page's alike.
|
|
case "library.changed":
|
|
return [getGetLibraryQueryKey()];
|
|
case "update.available":
|
|
case "update.applied":
|
|
return [getGetUpdateStatusQueryKey()];
|
|
// A plugin install/uninstall moves the nav, the catalog, and the installed list.
|
|
case "plugins.changed":
|
|
case "store.changed":
|
|
return [
|
|
PLUGINS_KEY,
|
|
storeKeys.catalog,
|
|
storeKeys.installed,
|
|
storeKeys.runtime,
|
|
];
|
|
// The host came back: everything we hold predates it.
|
|
case "host.started":
|
|
return [];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark one key's data wrong and refetch it.
|
|
*
|
|
* `refetchType: "all"` rather than the default `"active"`: an event says the HOST changed, so every
|
|
* cached copy is wrong, whether or not a mounted component happens to be observing it right now.
|
|
* The default only refetches queries with a live observer, which silently did nothing for a page
|
|
* that had just been re-rendered — the cache stayed marked-stale-but-unfetched and the screen kept
|
|
* showing the old answer.
|
|
*/
|
|
function invalidate(qc: QueryClient, queryKey: readonly unknown[]): void {
|
|
qc.invalidateQueries({ queryKey, refetchType: "all" });
|
|
}
|
|
|
|
/** Invalidate every query — used on `dropped` (we fell off the ring) and on `host.started`. */
|
|
function resyncAll(qc: QueryClient): void {
|
|
qc.invalidateQueries({ refetchType: "all" });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// The activity log.
|
|
//
|
|
// The same frames that drive invalidation are also, in themselves, the answer to "what has this
|
|
// host been doing?" — a question the console could not answer at all. Nothing else records this:
|
|
// the REST snapshots describe the present, and the host's own log is a developer artifact, not a
|
|
// narrative. So keep a small in-memory ring alongside the cache work.
|
|
//
|
|
// Deliberately NOT persisted and deliberately bounded: it is a live tail for someone watching, not
|
|
// an audit trail, and a page load starts fresh from whatever the ring replays.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/** One thing that happened, as the feed renders it. */
|
|
export interface ActivityEntry {
|
|
/** The host's monotonic sequence number — stable, and a good React key. */
|
|
seq: number;
|
|
/** Unix ms, from the host's clock (never the browser's). */
|
|
ts_ms: number;
|
|
kind: string;
|
|
/** The event payload, shape depending on `kind` (see the EventKind schema). */
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
const ACTIVITY_MAX = 200;
|
|
let activity: ActivityEntry[] = [];
|
|
const activityListeners = new Set<() => void>();
|
|
|
|
function pushActivity(entry: ActivityEntry): void {
|
|
// Guard against a replayed frame after a reconnect (`Last-Event-ID` can re-deliver the cursor).
|
|
if (activity.some((e) => e.seq === entry.seq)) return;
|
|
activity = [entry, ...activity].slice(0, ACTIVITY_MAX);
|
|
for (const l of activityListeners) l();
|
|
}
|
|
|
|
/** The activity tail, newest first. Re-renders as frames arrive. */
|
|
export function useActivity(): ActivityEntry[] {
|
|
return useSyncExternalStore(
|
|
(cb) => {
|
|
activityListeners.add(cb);
|
|
return () => activityListeners.delete(cb);
|
|
},
|
|
() => activity,
|
|
// The server has no stream, so SSR renders an empty feed and hydrates into the live one.
|
|
() => EMPTY_ACTIVITY,
|
|
);
|
|
}
|
|
|
|
const EMPTY_ACTIVITY: ActivityEntry[] = [];
|
|
|
|
/** Every kind we act on. A kind the host adds later simply has no listener — never a mis-handle. */
|
|
const KINDS = [
|
|
"client.connected",
|
|
"client.disconnected",
|
|
"session.started",
|
|
"session.ended",
|
|
"stream.started",
|
|
"stream.stopped",
|
|
"game.running",
|
|
"game.exited",
|
|
"pairing.pending",
|
|
"pairing.completed",
|
|
"pairing.denied",
|
|
"display.created",
|
|
"display.released",
|
|
"library.changed",
|
|
"update.available",
|
|
"update.applied",
|
|
"plugins.changed",
|
|
"store.changed",
|
|
"host.started",
|
|
] as const;
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// The connection is a module-level singleton, refcounted, NOT a per-component resource.
|
|
//
|
|
// It has to be. The subscription is app-lifetime, but the component that asks for it is not:
|
|
// during hydration TanStack Start mounts the app shell and discards it again ~15 ms later
|
|
// (measured), which ran an effect cleanup with no matching re-mount. Tied to that effect, the
|
|
// stream opened, closed, and never came back — the console looked subscribed and received nothing.
|
|
//
|
|
// So: `open()` hands out a reference and only the LAST release closes the socket, after a short
|
|
// grace period, so a remount inside that window re-attaches to the live stream instead of
|
|
// reconnecting. `EventSource` handles reconnection itself and replays with `Last-Event-ID`, which
|
|
// the SSE route forwards.
|
|
// ---------------------------------------------------------------------------------------------
|
|
let source: EventSource | null = null;
|
|
let refs = 0;
|
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
/** The client to invalidate against — one per page load, re-pointed if React hands us a new one. */
|
|
let client: QueryClient | null = null;
|
|
|
|
/** How long the stream survives with no subscribers, so a hydration blip doesn't reconnect. */
|
|
const CLOSE_GRACE_MS = 10_000;
|
|
|
|
function attach(): void {
|
|
if (source) return;
|
|
source = new EventSource("/api/v1/events");
|
|
for (const kind of KINDS) {
|
|
source.addEventListener(kind, (ev) => {
|
|
// Record it first: the feed should show an event even for a kind we invalidate nothing for.
|
|
recordActivity(kind, ev);
|
|
if (!client) return;
|
|
// The installed set changed — but the runner is probably still restarting, so keep
|
|
// checking for a while rather than trusting this one refetch (see boostPluginPolling).
|
|
if (kind === "plugins.changed" || kind === "store.changed")
|
|
boostPluginPolling();
|
|
for (const key of keysFor(kind)) invalidate(client, key);
|
|
// `host.started` names no keys — the host is NEW, so everything we hold predates it.
|
|
if (kind === "host.started") resyncAll(client);
|
|
});
|
|
}
|
|
// We fell off the host's ring — every snapshot we hold may be wrong.
|
|
source.addEventListener("dropped", () => {
|
|
if (client) resyncAll(client);
|
|
});
|
|
}
|
|
|
|
/** Parse one SSE frame into the activity ring. A malformed frame is dropped, never thrown. */
|
|
function recordActivity(kind: string, ev: Event): void {
|
|
const raw = (ev as MessageEvent<string>).data;
|
|
if (typeof raw !== "string") return;
|
|
try {
|
|
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
const seq = typeof data.seq === "number" ? data.seq : Number.NaN;
|
|
const ts = typeof data.ts_ms === "number" ? data.ts_ms : Number.NaN;
|
|
if (!Number.isFinite(seq) || !Number.isFinite(ts)) return;
|
|
pushActivity({ seq, ts_ms: ts, kind, data });
|
|
} catch {
|
|
// A frame we cannot parse is not worth breaking the stream over.
|
|
}
|
|
}
|
|
|
|
function release(): void {
|
|
refs -= 1;
|
|
if (refs > 0) return;
|
|
if (closeTimer) clearTimeout(closeTimer);
|
|
closeTimer = setTimeout(() => {
|
|
closeTimer = null;
|
|
if (refs > 0) return; // someone re-subscribed inside the grace window
|
|
source?.close();
|
|
source = null;
|
|
}, CLOSE_GRACE_MS);
|
|
}
|
|
|
|
/**
|
|
* Subscribe to the host's event stream. Safe to call from more than one component and safe on the
|
|
* server (`EventSource` is browser-only, so this is a no-op during SSR).
|
|
*/
|
|
export function useHostEvents(): void {
|
|
const qc = useQueryClient();
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || typeof EventSource === "undefined")
|
|
return;
|
|
client = qc;
|
|
refs += 1;
|
|
if (closeTimer) {
|
|
clearTimeout(closeTimer);
|
|
closeTimer = null;
|
|
}
|
|
attach();
|
|
return release;
|
|
}, [qc]);
|
|
}
|