Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.6 KiB
UI events stream (api/events.svelte.ts)
This document describes how the SvelteKit frontend consumes the
gateway's SubscribeEvents server-streaming RPC. The single
authenticated session keeps one stream open through the
EventStream singleton declared in src/api/events.svelte.ts; the
root layout starts it once the session reaches authenticated and
stops it on sign-out.
Why a single consumer
The EventStream singleton consolidates what was previously a
separate revocation watcher in lib/revocation-watcher.ts so that:
- there is only one SubscribeEvents connection per session (avoids doubling the gateway hub load);
- both clean end-of-stream on an authenticated session and an
UnauthenticatedConnectError funnel through onesession.signOut("revoked")call site; - per-event-type dispatch (turn-ready toasts, lobby/mail/battle notifications) shares the same verification path.
Lifecycle
SessionStore.status = "authenticated"
↓ (root layout $effect)
EventStream.start({ core, keypair, deviceSessionId, gatewayResponsePublicKey })
↓
loop: open SubscribeEvents → verify each frame → dispatch to handlers
↓
EventStream.stop() (on logout, unmount, or session id change)
start is idempotent for the same session: re-calling while the
stream is running is a no-op. The root layout detects a session id
flip (re-login on the same tab) by storing the bound id and calling
stop() + start() against the fresh credentials.
Frame handling
Every GatewayEvent is verified before dispatch:
core.verifyPayloadHash(payloadBytes, payloadHash)— the SHA-256 digest ofpayloadBytesmust equalpayloadHash. A mismatch is treated as a forgery.core.verifyEvent(gatewayResponsePublicKey, signature, fields)— Ed25519 verification using the canonical signing input defined inui/core/canon/event.go(mirrored bygateway/authn/event.go).- On success the verified projection (
VerifiedEvent) is passed to every handler registered viaeventStream.on(eventType, handler).
Any verification failure throws SignatureError, which falls into
the same retry path as a transport error: the loop classifies it as
transient, tears the stream down, and reconnects with full-jitter
exponential backoff (base 1 s, ceiling 30 s, unbounded retries).
Connection status
EventStream.connectionStatus is a Svelte rune with five values:
idle— stream not running.connecting—subscribeEvents()issued, no frame received yet.connected— first frame verified and dispatched, attempt counter reset to zero.reconnecting— transient failure, backoff in flight.offline—navigator.onLine === falseat the moment of failure.
The header connection-state indicator reads this rune; the rune is wired so a future change can add the indicator dot without touching this module.
Revocation semantics
Two paths lead to session.signOut("revoked"):
- a
ConnectErrorwith codeUnauthenticated: the gateway rejected the stream credentials (revoked device session); - a clean end-of-stream while
session.status === "authenticated": the gateway's documentedsession_invalidationsignal closes the stream once the device session flips to revoked.
Any other error (network drop, gateway 5xx, transient close, signature failure) keeps the session alive and triggers backoff + reconnect.
Adding a new event type
- Register a handler from the consumer module:
const off = eventStream.on("mail.received", (event) => { // parse event.payloadBytes }); onDestroy(off); - If the handler reads scoped data (per-game), register from a
component that owns that scope and pass the gameId via a closure.
The handler should filter events whose payload does not match its
scope (see
lib/game/game-shell.sveltefor thegame.turn.readyfilter). - The payload encoding is owned by the producer side: the
game.turn.readyevent uses JSON{game_id, turn}. Document the schema next to the producer (e.g.backend/README.md§10).
Tests
- Unit (Vitest):
tests/events.test.tsmocks the transport viacreateRouterTransportand covers verified dispatch, type filtering, bad-signature reconnect,Unauthenticatedsign-out, clean end-of-stream sign-out, and connection-status transitions. - E2E (Playwright):
tests/e2e/turn-ready.spec.tsserves a signedgame.turn.readyframe throughpage.route, asserts the toast surfaces, and verifies manual dismiss without advance. The advance roundtrip (toast → click "view now" → fresh report) is covered by Vitest at the store level because it is sensitive to Playwright-side network ordering.