// `EventStream` is the single SubscribeEvents consumer for the // authenticated UI session. It opens one server-streaming RPC against // the gateway, verifies every incoming event (payload-hash + // Ed25519 signature) through `ui/core`, dispatches verified events to // type-keyed handlers, and reconnects with full-jitter exponential // backoff on transient failure. // // Phase 24 introduces this module in place of `revocation-watcher.ts`. // The watcher's revocation semantics are absorbed: a clean // end-of-stream while the session is authenticated, or an // `Unauthenticated` ConnectError, both call `session.signOut("revoked")`. // Per-event-type dispatch (turn-ready toasts in this phase; battle and // mail toasts in later phases) is registered through `on(eventType, // handler)`. // // The store exposes `connectionStatus` as a Svelte rune so the // connection-state indicator in the shell header (see PLAN.md // cross-cutting shell) can subscribe without ceremony. The indicator // itself is not part of Phase 24, but the rune is wired here so the // next phase that adds the dot can read it directly. import { create } from "@bufbuild/protobuf"; import { ConnectError } from "@connectrpc/connect"; import type { Core } from "../platform/core/index"; import type { DeviceKeypair } from "../platform/store/index"; import { GatewayEventSchema, SubscribeEventsRequestSchema, type GatewayEvent, } from "../proto/galaxy/gateway/v1/edge_gateway_pb"; import { GATEWAY_BASE_URL } from "../lib/env"; import { session } from "../lib/session-store.svelte"; import { createEdgeGatewayClient, type EdgeGatewayClient } from "./connect"; const PROTOCOL_VERSION = "v1"; const SUBSCRIBE_MESSAGE_TYPE = "gateway.subscribe"; // Connect error code numerical values used by the watcher. The full // enum lives in `@connectrpc/connect` but importing the runtime enum // would pull a large surface into this small module. const CONNECT_CODE_CANCELED = 1; const CONNECT_CODE_UNAUTHENTICATED = 16; const BACKOFF_BASE_MS = 1_000; const BACKOFF_MAX_MS = 30_000; /** * VerifiedEvent is the verified projection of a `GatewayEvent` handed * to user handlers. The signature and payload-hash fields are dropped * because verification has already succeeded; consumers only need the * envelope plus the opaque payload bytes. */ export interface VerifiedEvent { eventType: string; eventId: string; timestampMs: bigint; requestId: string; traceId: string; payloadBytes: Uint8Array; } export type EventHandler = (event: VerifiedEvent) => void; export type ConnectionStatus = | "idle" | "connecting" | "connected" | "reconnecting" | "offline"; /** * EventStreamStartOptions carries the live primitives the stream * consumer cannot resolve by itself. Production code reads `core`, * `keypair`, and `deviceSessionId` from the session store and the * gateway public key from `lib/env`; tests inject a fake * `EdgeGatewayClient` and deterministic `sleep`/`random` to drive * backoff in fake-timer mode. */ export interface EventStreamStartOptions { core: Core; keypair: DeviceKeypair; deviceSessionId: string; gatewayResponsePublicKey: Uint8Array; /** Custom transport client. Defaults to `createEdgeGatewayClient(GATEWAY_BASE_URL)`. */ client?: EdgeGatewayClient; /** Sleep hook for tests; defaults to a real-time `setTimeout`. */ sleep?: (ms: number) => Promise; /** Random source for full-jitter backoff; defaults to `Math.random`. */ random?: () => number; /** Function reporting `navigator.onLine`; defaults to the browser global. */ onlineProbe?: () => boolean; } /** * SignatureError marks a verification failure (payload-hash mismatch * or invalid Ed25519 signature). The stream loop classifies it as a * forgery and reconnects through the same backoff path used for * transient transport errors. */ export class SignatureError extends Error { constructor(message: string) { super(message); this.name = "SignatureError"; } } export class EventStream { connectionStatus: ConnectionStatus = $state("idle"); private handlers = new Map>(); private controller: AbortController | null = null; private running = false; /** * on registers a handler for a specific event type. Returns a * disposer that removes the handler. Multiple handlers per type * are supported so future phases (battle, mail) can subscribe * alongside turn-ready without coordination. */ on(eventType: string, handler: EventHandler): () => void { let bucket = this.handlers.get(eventType); if (bucket === undefined) { bucket = new Set(); this.handlers.set(eventType, bucket); } bucket.add(handler); return () => { const current = this.handlers.get(eventType); if (current === undefined) { return; } current.delete(handler); if (current.size === 0) { this.handlers.delete(eventType); } }; } /** * start opens the stream. Calling start while the stream is * already running is a no-op so the root layout's `$effect`-based * lifecycle stays idempotent across re-renders. */ start(opts: EventStreamStartOptions): void { if (this.running) { return; } this.running = true; this.controller = new AbortController(); void this.run(opts, this.controller.signal); } /** * stop tears down the stream. Used by the root layout on logout * or unmount. Re-calling start after stop opens a fresh stream. */ stop(): void { this.running = false; if (this.controller !== null) { this.controller.abort(); this.controller = null; } this.connectionStatus = "idle"; } /** * resetForTests is used by the Vitest harness to forget all * handlers and force the rune back to `idle` between cases. */ resetForTests(): void { this.stop(); this.handlers.clear(); } private async run( opts: EventStreamStartOptions, signal: AbortSignal, ): Promise { const sleep = opts.sleep ?? defaultSleep; const random = opts.random ?? Math.random; const onlineProbe = opts.onlineProbe ?? defaultOnlineProbe; const client = opts.client ?? createEdgeGatewayClient(GATEWAY_BASE_URL); let attempt = 0; while (!signal.aborted && this.running) { this.connectionStatus = "connecting"; let stream: AsyncIterable; try { stream = await openStream(client, opts, signal); } catch (err) { if (signal.aborted) { return; } if (handleAuthenticationError(err)) { return; } this.connectionStatus = onlineProbe() ? "reconnecting" : "offline"; await sleep(backoffDelay(attempt, random)); attempt += 1; continue; } let firstEventSeen = false; let terminated = false; try { for await (const event of stream) { if (signal.aborted) { return; } this.verifyEvent(event, opts); if (!firstEventSeen) { firstEventSeen = true; this.connectionStatus = "connected"; attempt = 0; } this.dispatch(event); } terminated = true; } catch (err) { if (signal.aborted) { return; } if (handleAuthenticationError(err)) { return; } this.connectionStatus = onlineProbe() ? "reconnecting" : "offline"; await sleep(backoffDelay(attempt, random)); attempt += 1; continue; } if (terminated) { // Clean end-of-stream on an authenticated session is the // gateway's documented session-invalidation signal. if (session.status === "authenticated") { await session.signOut("revoked"); return; } this.connectionStatus = "idle"; return; } } } private verifyEvent(event: GatewayEvent, opts: EventStreamStartOptions): void { if (!opts.core.verifyPayloadHash(event.payloadBytes, event.payloadHash)) { throw new SignatureError("event payload_hash mismatch"); } const ok = opts.core.verifyEvent( opts.gatewayResponsePublicKey, event.signature, { eventType: event.eventType, eventId: event.eventId, timestampMs: event.timestampMs, requestId: event.requestId, traceId: event.traceId, payloadHash: event.payloadHash, }, ); if (!ok) { throw new SignatureError("event signature verification failed"); } } private dispatch(event: GatewayEvent): void { const bucket = this.handlers.get(event.eventType); if (bucket === undefined || bucket.size === 0) { return; } const projection: VerifiedEvent = { eventType: event.eventType, eventId: event.eventId, timestampMs: event.timestampMs, requestId: event.requestId, traceId: event.traceId, payloadBytes: event.payloadBytes, }; for (const handler of [...bucket]) { try { handler(projection); } catch (err) { console.info("events: handler threw", event.eventType, err); } } } } async function openStream( client: EdgeGatewayClient, opts: EventStreamStartOptions, signal: AbortSignal, ): Promise> { const requestId = newRequestId(); const timestampMs = BigInt(Date.now()); const emptyPayload = new Uint8Array(); const payloadHash = await sha256(emptyPayload); const canonical = opts.core.signRequest({ protocolVersion: PROTOCOL_VERSION, deviceSessionId: opts.deviceSessionId, messageType: SUBSCRIBE_MESSAGE_TYPE, timestampMs, requestId, payloadHash, }); const signature = await opts.keypair.sign(canonical); const request = create(SubscribeEventsRequestSchema, { protocolVersion: PROTOCOL_VERSION, deviceSessionId: opts.deviceSessionId, messageType: SUBSCRIBE_MESSAGE_TYPE, timestampMs, requestId, payloadHash, signature, payloadBytes: emptyPayload, }); return client.subscribeEvents(request, { signal }); } function handleAuthenticationError(err: unknown): boolean { if (!(err instanceof ConnectError)) { return false; } if (err.code === CONNECT_CODE_CANCELED) { return true; } if (err.code === CONNECT_CODE_UNAUTHENTICATED) { void session.signOut("revoked"); return true; } return false; } function backoffDelay(attempt: number, random: () => number): number { const cap = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt); return Math.floor(random() * cap); } function defaultSleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function defaultOnlineProbe(): boolean { if (typeof navigator === "undefined") { return true; } return navigator.onLine !== false; } async function sha256(payload: Uint8Array): Promise { const digest = await crypto.subtle.digest( "SHA-256", payload as BufferSource, ); return new Uint8Array(digest); } function newRequestId(): string { if (typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const buf = new Uint8Array(16); crypto.getRandomValues(buf); let hex = ""; for (let i = 0; i < buf.length; i++) { hex += buf[i]!.toString(16).padStart(2, "0"); } return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } /** * eventStream is the singleton stream consumer the root layout starts * once the session becomes authenticated and stops on logout. Tests * call `resetForTests()` between cases. */ export const eventStream = new EventStream();