The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
199 lines
8.0 KiB
TypeScript
199 lines
8.0 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 } 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 { 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"],
|
|
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" });
|
|
}
|
|
|
|
/** 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, () => {
|
|
if (!client) return;
|
|
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);
|
|
});
|
|
}
|
|
|
|
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]);
|
|
}
|