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>
punktfunk web — management console
The browser UI for the punktfunk host's management REST API (crates/punktfunk-host/src/mgmt.rs,
OpenAPI at api/openapi.json). It shows live status, host capabilities, paired
clients, the pairing-PIN flow, and session controls.
Stack: TanStack Start (full SSR) on Bun via Nitro v2 (bun preset) · React
Query through orval codegen from the OpenAPI spec · @unom/ui
— the shared punktfunk/unom design system the marketing site + docs are built on (Tailwind v4,
animated components on the violet brand over dark chrome) ·
Paraglide i18n (en/de). Package manager + runtime: Bun.
The @unom registry mapping lives in .npmrc; the auth token comes from
~/.npmrc (or a CI secret).
Develop
# from web/ — Bun is the toolchain (https://bun.sh)
bun install # runs `prepare` → codegen (orval + paraglide)
bun run dev # http://localhost:47992
# The dev server proxies /api → https://127.0.0.1:47990 (the host's mgmt API; it serves HTTPS
# with the host's self-signed identity cert — the dev proxy uses `secure: false`).
# Point it elsewhere: PUNKTFUNK_MGMT_URL=https://<host>:47990 bun run dev
Start a host with the management API up:
# from the repo root — `serve` brings up the native punktfunk/1 plane + the mgmt API (the console
# only needs the mgmt API; add --gamestream too if you also want the Moonlight surface):
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
cargo run -rp punktfunk-host -- serve
# loopback :47990, no token (a token is mandatory for non-loopback binds).
If the host runs with --mgmt-token, set it under Settings → API token (stored in
localStorage, sent as Authorization: Bearer … by the orval fetcher).
Build & run (Nitro + Bun)
The console runs on bun (Bun.serve is a Bun API — node can't run it): Nitro's bun preset
plus a custom entry (nitro-entry/bun-https.mjs) that calls Bun.serve({ tls }), so it serves
HTTPS (HTTP/1.1 over TLS) with the host's own identity cert (the cert native clients already
pin). One trust anchor across the data plane, the mgmt API, and this console. (No HTTP/2 — Bun.serve
has no h2 server — and no HTTP/3, which a browser won't speak against this self-signed, no-SAN host
cert; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a
LAN console.)
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
PORT=47992 HOST=0.0.0.0 \
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 \
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# See .env.example.
bun run lint # tsc --noEmit
The built Nitro bun server SSR-renders the app and is the only thing exposed on the LAN.
Run it on the same box as the host; it serves the console over HTTPS on :47992 (or $PORT).
Auth (backend-for-frontend)
Single-user, login-gated. Config via env (see .env.example):
- The console requires a login (
PUNKTFUNK_UI_PASSWORD). On success the server sets a sealed session cookie (h3useSession, AES-GCM).server/middleware/auth.tsgates every request — pages redirect to/login,/apireturns 401 — and fails closed (503) ifPUNKTFUNK_UI_PASSWORDis unset, so a misconfigured LAN server admits no one. - The bearer-token admin surface of the management API is loopback-only — the host honors a
bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server
holds
PUNKTFUNK_MGMT_TOKENserver-side and injects it when proxying/api/**→PUNKTFUNK_MGMT_URL(loopback;server/routes/api/[...].ts). The token never reaches the browser; the browser only ever holds the session cookie. (The host also binds the read-only surface — host status + the game library — to the LAN so paired native clients can fetch it directly over mTLS; that path uses client certs, not the token, and never touches this console.)
So: browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API.
Run the host with a matching token: cargo run -rp punktfunk-host -- serve +
PUNKTFUNK_MGMT_TOKEN=… (or --mgmt-token …). vite dev has no gate (localhost-only) and
proxies straight to the loopback mgmt API.
Toolchain notes (load-bearing): TanStack Start's
start-plugin-corepeer-requires Vite ≥ 7 — on Vite 6 the build's prerender/post-build hook silently doesn't run.@vitejs/plugin-reactmust match Vite (v5 ↔ Vite 7, v6 ↔ Vite 8); it's required even for dev (TanStack Start's dev mode needs the React Refresh runtime, else a blank screen). Nitro is the server target — without itvite buildonly emits client+SSR bundles, no deployable server. The Nitrobunpreset makes.output/server/index.mjsBun-runnable.
Codegen
Generated code is not committed (gitignored) — reproduced from sources:
bun run codegen— regenerate the API client (orval) + i18n runtime (paraglide). Runs onbun install(prepare) and beforedev/build(pre*for orval; the Vite plugin compiles paraglide on dev/build).- After a management-API change, regenerate the spec on the Rust side first:
cargo run -p punktfunk-host -- openapi > api/openapi.json, thenbun run api:gen.
Layout
src/
routes/ file-based routes (index=dashboard, host, clients, pairing, settings)
components/
app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher
brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs)
ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton)
query-state.tsx loading/error wrapper (incl. 401 → "set a token")
api/
fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError
gen/ GENERATED react-query hooks + models (orval)
lib/i18n.ts reactive Paraglide locale hook
paraglide/ GENERATED i18n runtime (paraglide)
messages/{en,de}.json translation sources