Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.
Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.
The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).
Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.
Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.
Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
interval returns nil, heartbeat fires after silence, real Send
resets the timer, Stop / context-cancel halt the goroutine,
Send errors propagate.
- `server_test.go`: integration tests through the full gateway
pipeline — heartbeat fires after the configured silence window,
zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
dispatched to handlers; leading heartbeat still flips
`connectionStatus` to `connected`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ui — Galaxy Cross-Platform Client
ui/ hosts the new cross-platform Galaxy client. A single
TypeScript + Svelte source tree builds to five targets: web,
web-mobile, standalone PC (mac/win/linux), iOS, and Android. A
shared Go module (ui/core) carries envelope cryptography, the
FlatBuffers codec, keypair management, and a thin bridge over
pkg/calc/ for UI-side game math; it is compiled to WASM for the
web targets, gomobile native libraries for mobile, and embedded
directly in Wails on desktop. All network I/O lives on the
TypeScript side via ConnectRPC, so the Go module is a pure compute
boundary on every platform.
The legacy Fyne client under client/ is reference-only.
Nothing in ui/ imports from it.
The full staged implementation plan lives in PLAN.md. The
strategic rationale (why Svelte, why PixiJS, why Go-as-WASM, why
Wails+Capacitor) lives outside the repo at
~/.claude/plans/buzzing-questing-fountain.md. This README is a
quick orientation; deeper per-phase design notes earn their place
under ui/docs/ as they are introduced.
Targets
| Target | Wrapper | Toolchain | Phase |
|---|---|---|---|
| web | browser tab | Vite + WASM | 5+ |
| web-mobile | mobile browser | Vite + WASM | 5+ |
| desktop (mac) | Wails v2 | Go + Wails CLI | 31 |
| desktop (win) | Wails v2 | Go + Wails CLI | 31 |
| desktop (linux) | Wails v2 | Go + Wails CLI | 31 |
| iOS | Capacitor | gomobile + Xcode | 32+ |
| Android | Capacitor | gomobile + Gradle | 32+ |
Layered architecture
- TypeScript + Svelte 5 frontend, shared across all five targets, scaffolded with SvelteKit + Vite.
- PixiJS v8 with dual WebGPU/WebGL backend for the world map renderer.
- Go module
ui/core/as a compute-only library (canonical bytes, Ed25519 sign/verify, FlatBuffers codec, keypair, thin bridge topkg/calc/) compiled to WASM, gomobile, and Wails-embedded native. - TypeScript-side
Coreinterface with three adapters (WasmCore,WailsCore,CapacitorCore) selected at build time. GalaxyClienton top ofCoreperforms all network I/O via ConnectRPC (@connectrpc/connect-web) on every platform.- Per-platform storage: WebCrypto + IndexedDB on web, OS keychain
- SQLite on desktop, iOS Keychain / Android Keystore + SQLite on
mobile, all behind a single
KeyStoreandCacheTypeScript interface.
- SQLite on desktop, iOS Keychain / Android Keystore + SQLite on
mobile, all behind a single
- Mobile-first navigation: one active view occupies the main area at a time; the sidebar holds a single tool (calculator, inspector, or order) with persistent state on switch.
Repository layout
ui/
├── PLAN.md staged implementation plan (Phases 1-36)
├── Makefile wasm / ts-protos / web / mobile / desktop targets
├── README.md this file
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
├── docs/ topic-based design notes
│ ├── auth-flow.md email-code login, session store, revocation
│ ├── i18n.md translation primitive, native-name picker, extensibility
│ ├── order-composer.md order draft model, persistence, history-mode wiring
│ ├── storage.md web KeyStore/Cache, IDB schema, baseline
│ ├── testing.md per-PR / release test tiers
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
├── core/ ui/core Go module (canonical bytes, keypair)
├── wasm/ TinyGo entry point exposing Core to JS
└── frontend/ SvelteKit / Vite source
├── src/api/ GalaxyClient + typed Connect client + auth + session
├── src/lib/ env config, session store, revocation watcher
├── src/platform/core/ Core interface + WasmCore adapter
├── src/platform/store/ KeyStore/Cache interfaces + web adapter
├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings
├── src/routes/ SvelteKit routes (/, /login, /lobby, /lobby/create)
└── static/ core.wasm + wasm_exec.js (committed artefacts)
Linked topic docs:
docs/auth-flow.md— email-code login, session store state machine, revocation watcher.docs/lobby.md— lobby UI sections, application / invite lifecycle, create-game form defaults.docs/i18n.md— translation primitive, native-name language picker, recipe for adding a new locale.docs/storage.md— web KeyStore/Cache, IndexedDB schema, browser baseline.docs/order-composer.md— local order draft store, persistence, history-mode wiring.docs/wasm-toolchain.md— TinyGo build, loading recipe, bundle size budget.docs/testing.md— Tier 1 per-PR + Tier 2 release test tiers.
ui/
├── README.md this file
├── PLAN.md staged implementation plan
├── Makefile cross-target build placeholders
├── pnpm-workspace.yaml pnpm workspace root
├── .gitignore
├── docs/ per-phase topic docs (added per phase)
├── frontend/ TS + Svelte source, shared across targets
├── core/ Go module ui/core (Phase 3+)
├── wasm/ TinyGo entry point for core.wasm (Phase 5)
├── mobile-bridge/ gomobile bindings (Phase 32+)
├── desktop/ Wails project (Phase 31)
├── mobile/ Capacitor project (Phase 32+)
└── web/ static deploy assets (Phase 30+)
Build pipeline
Every cross-target build flows through make at this level. All
named targets are placeholders until the named phase lands; running
make with no arguments prints the current placeholder map.
make web Vite production build Phase 5+
make wasm TinyGo → core.wasm Phase 5
make ts-protos Connect-ES + Protobuf-ES gen Phase 5
make fbs-ts FlatBuffers TS bindings via flatc Phase 8
make gomobile gomobile bind → ios + android Phase 32+
make desktop-mac Wails build for darwin Phase 31
make desktop-win Wails build for windows Phase 31
make desktop-linux Wails build for linux Phase 31
make ios Capacitor + xcodebuild Phase 32+
make android Capacitor + gradle Phase 32+
make all every target above
Local development
For UI work against a real stack, the tools/local-dev/ docker
compose brings up postgres + redis + mailpit + backend + gateway in
one command, and ui/frontend/.env.development is already wired to
talk to it:
make -C tools/local-dev up # build + start, wait for healthy
pnpm -C ui/frontend dev # Vite on the host
# UI: http://localhost:5173
# Mailpit: http://localhost:8025
The stack accepts a fixed dev code (123456) in addition to the
real Mailpit-delivered one. Full runbook in
../tools/local-dev/README.md.
For testing the production-shaped surface — Caddy in front of the
gateway, statically served UI bundle, real https://*.galaxy.lan
hostnames — use the long-lived dev environment at
../tools/dev-deploy/. It is
redeployed by Gitea Actions on every merge into development.
Per-phase docs
Topic docs live under ui/docs/ and are added per phase as they're
needed (testing tiers, WASM toolchain, navigation shell, renderer
internals, sync protocol, auth flow, and so on). The staged plan in
PLAN.md names the topic doc each phase produces.
Cross-references
PLAN.md— staged implementation plan with goals, artifacts, dependencies, acceptance criteria, and targeted tests per phase.../docs/ARCHITECTURE.md— platform architecture and the transport security model (§15) the client envelope contract derives from.../docs/FUNCTIONAL.md— per-domain user stories that drive the UI flows.../docs/TESTING.md— project-wide testing layers; UI-specific test tiers (Vitest, Playwright) live inui/docs/testing.mdfrom Phase 2 onward.