Files
galaxy-game/ui/docs/events.md
T
Ilia Denisov 5b07bb4e14 ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer
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>
2026-05-11 16:16:31 +02:00

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 Unauthenticated ConnectError funnel through one session.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:

  1. core.verifyPayloadHash(payloadBytes, payloadHash) — the SHA-256 digest of payloadBytes must equal payloadHash. A mismatch is treated as a forgery.
  2. core.verifyEvent(gatewayResponsePublicKey, signature, fields) — Ed25519 verification using the canonical signing input defined in ui/core/canon/event.go (mirrored by gateway/authn/event.go).
  3. On success the verified projection (VerifiedEvent) is passed to every handler registered via eventStream.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.
  • connectingsubscribeEvents() issued, no frame received yet.
  • connected — first frame verified and dispatched, attempt counter reset to zero.
  • reconnecting — transient failure, backoff in flight.
  • offlinenavigator.onLine === false at 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 ConnectError with code Unauthenticated: the gateway rejected the stream credentials (revoked device session);
  • a clean end-of-stream while session.status === "authenticated": the gateway's documented session_invalidation signal 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

  1. Register a handler from the consumer module:
    const off = eventStream.on("mail.received", (event) => {
        // parse event.payloadBytes
    });
    onDestroy(off);
    
  2. 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.svelte for the game.turn.ready filter).
  3. The payload encoding is owned by the producer side: the game.turn.ready event uses JSON {game_id, turn}. Document the schema next to the producer (e.g. backend/README.md §10).

Tests

  • Unit (Vitest): tests/events.test.ts mocks the transport via createRouterTransport and covers verified dispatch, type filtering, bad-signature reconnect, Unauthenticated sign-out, clean end-of-stream sign-out, and connection-status transitions.
  • E2E (Playwright): tests/e2e/turn-ready.spec.ts serves a signed game.turn.ready frame through page.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.