Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4.8 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
Before Phase 24, the watcher in lib/revocation-watcher.ts opened a
parallel stream just to observe session revocation. Phase 24 folds
that watcher into EventStream 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 later) 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 planned in PLAN.md
cross-cutting shell reads this rune; it is not part of Phase 24 but
the rune is wired now so a later phase can add the 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, per-route), register
from a layout that owns that scope and pass the gameId via a
closure. The handler should filter events whose payload does not
match its scope (see
routes/games/[id]/+layout.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.