Network architecture
How traffic flows between browsers and the tournament app: HTTPS termination at a reverse proxy, plain HTTP to the app, real-time updates over Server-Sent Events (SSE), and the client-side resilience that keeps it working on flaky venue Wi-Fi.
Related: Software architecture · Infrastructure architecture
1. Edge topology
The app speaks plain HTTP; a TLS-terminating reverse proxy (Caddy, automatic Let's Encrypt) sits in front and streams SSE through unbuffered.
flowchart LR
subgraph net["Venue / Internet"]
op["Operator tablets"]
vw["Viewer phones"]
end
subgraph host["Host (VM / container host)"]
caddy["Caddy<br/>:80 redirect · :443 HTTPS<br/>auto TLS · streams SSE unbuffered"]
app["bracket-creator mobile-app<br/>:8080 (HTTP, internal only)"]
disk[("tournament-data/<br/>on persistent disk")]
end
op -->|HTTPS 443| caddy
vw -->|HTTPS 443 + SSE| caddy
caddy -->|HTTP reverse_proxy| app
app --> disk
| Port | Where | Purpose |
|---|---|---|
| 443 | Caddy (public) | HTTPS for REST + SSE |
| 80 | Caddy (public) | ACME challenge; redirect to 443 |
| 8080 | app (internal) | plain HTTP; never published directly (Caddy proxies it) |
| 22 | host (optional) | SSH (restrict to your IP) |
Proxy must stream, not buffer. SSE is a long-lived response; the Caddyfiles deliberately avoid
flush_interval/ response-buffering directives, which would break the real-time event stream. (In production HTTPS comes from the proxy, so browser secure-context features work even though the app itself serves plain HTTP.)
2. Protocols on the wire
Two channels share the one HTTPS origin:
flowchart TB
subgraph client["Browser SPA"]
rest["REST calls<br/>fetch() JSON"]
sse["1 long-lived SSE stream<br/>EventSource('/api/events')"]
end
subgraph server["mobile-app"]
api["/api/* handlers<br/>(request/response)"]
hub["SSE hub<br/>(fan-out)"]
end
rest -->|REST calls + auth header| api
api -.->|after persist| hub
hub ==>|SSE id + data frames| sse
- REST: score/decision/lineup writes, config, participants. Auth through the
X-Tournament-Passwordheader (two modes, §5). - SSE: one stream per client carrying
match_updated,competition_started/completed,competitor_status_updated,draw_generated,schedule_updated, plus the resilience control eventsresync_requiredandheartbeat. Every real event is stamped with a monotonicseqwritten as the SSEid:line, so the browser'sLast-Event-IDadvances automatically.
3. Real-time delivery & reconnect (SSE hub)
sequenceDiagram
autonumber
participant B as Browser (EventSource)
participant C as Caddy
participant H as SSE hub
B->>C: GET /api/events
C->>H: proxied (unbuffered)
H-->>B: id: N · data: event (real-time, as matches change)
Note over H: each event stamped seq=N,<br/>retained in a 100-event ring
B--xH: Wi-Fi blip, connection drops
B->>C: auto-reconnect, Last-Event-ID: N
C->>H: proxied
alt gap satisfiable from ring
H-->>B: replay N+1 … head
else ring rolled past N, or server restarted (seq reset)
H-->>B: resync_required (full refetch)
end
H-->>B: heartbeat every 15s (observable frame)
- Replay ring:
DefaultHistorySize(100) recent events.Last-Event-IDreplays the gap. resync_required: emitted when a gap-free replay is impossible (ring eviction or a server restart that resetseq). The client resets itslastSeqand full-refetches. Emitted without anid:line when head seq is 0 so it can't forceLast-Event-IDto "0".- Observable heartbeat: a real
{"type":"heartbeat","nowMs":<unix ms>}frame (noid:) every 15s, so the client can tell "quiet" from "dead".nowMsis the server clock at send time and is a tripwire for a client whose own clock has drifted, never a source to set the clock from: a one-way push carries no round trip to correct against, so a delayed frame would drag the client's clock backwards. A client that sees a large divergence relearns the offset from the time endpoint instead. - Per-client buffered channel; a stalled client that can't drain is dropped (non-blocking
send). Subscriber cap
SSE_MAX_CLIENTS(default 5000).
4. Client resilience on flaky Wi-Fi
The client treats the link as unreliable by default; the following flows show how.
Only a confirmed write ever empties the queue. A write that cannot be confirmed is never dropped in silence: it is either kept and retried, parked until the operator can fix the cause, or discarded with a visible notice naming the reason.
flowchart TD
submit["Operator submits a score / decision / lineup"] --> try{"fetch (12s AbortController timeout)"}
try -->|2xx| done["Confirmed<br/>queue entry cleared"]
try -->|transient or offline| q["Enqueue in outbox<br/>(localStorage, 12h TTL)"]
q --> flush["Background flush<br/>(backoff to 8s max)"]
flush -->|2xx| done
flush -->|5xx / 429 / 403<br/>server error or misconfigured| retrying["Stays queued, keeps retrying<br/>10 rejections: notice + 'Not saving' pill"]
retrying --> flush
flush -->|401<br/>invalid password| parked["Parked: stops retrying<br/>'Sign in to save' pill"]
parked -->|operator signs in| flush
flush -->|other 4xx: 400 / 413 / 409| refused["Discarded, permanently refused<br/>notice names the match + reason"]
q -.->|still queued after 12h TTL| expired["Discarded on next page load<br/>notice raised at load time"]
online["browser 'online' event, tab visible again"] --> flush
flowchart LR
subgraph watchdog["SSE liveness"]
arm["watchdog armed on connect"] --> silent{"no message/heartbeat for 35s?"}
silent -->|yes| reconnect["close + reconnect<br/>(backoff + jitter)"]
silent -->|no| arm
end
vis["visibilitychange: tab → visible"] --> rc["reconnectEvents() + refetch"]
Key client mechanisms (all in web-mobile/js/api_client.jsx + consumers):
| Concern | Mechanism |
|---|---|
| Half-open / stalled sockets | 12s write timeouts, 35s SSE silence watchdog (armed at connect, not only onopen) |
| Reconnect storms | exponential backoff + jitter (vs. a fixed delay) |
| Lost writes | durable outbox persisted to localStorage (12h TTL), retried; survives tab refresh |
| Server errors (5xx / 429), and 403 | write stays queued and keeps retrying for as long as the tab stays open (the TTL is applied at page load, not during a session); after 10 consecutive rejections the operator gets a notice and the sync pill shows "Not saving". On this server 403 is never a bad credential (that is 401): it means the tournament is not configured yet, or is missing its password. Only an admin fixing the server state clears it, so signing in again cannot help and the write keeps retrying instead of parking |
| Invalid credential (401) | the one 4xx a retry can fix: the write is parked, not discarded, stops retrying, and shows "Sign in to save"; signing in again re-sends it with the new credential |
| Other non-retryable 4xx (400 validation, 413, generic 409) | write is discarded (it can never succeed on retry), and the operator always gets a visible notice naming the match and the server's reason |
| Missed events | Last-Event-ID replay + checkSeqGap on every event → scoped refetch; resync_required |
| Tab resume | visibilitychange → force reconnect + refetch |
| False success | terminal writes show pending / parked / still-retrying / failure state, never a false "saved"; a write dropped for exceeding the TTL, or because the stored entry was corrupt, also raises a visible notice at page load, never a silent loss |
| Storage full | if the browser can't persist the queue (storage quota exhausted), that is surfaced to the operator rather than swallowed |
| Credential change | queue cleared on logout (the operator is asked to confirm first if writes are still unsent) or on password_reset (no stale-password retries) |
5. Authentication on the network
flowchart TD
req["Request with X-Tournament-Password"] --> verifier{"PasswordVerifier<br/>(auth_source.go)"}
verifier -->|file mode| md["plaintext compare vs tournament.md"]
verifier -->|locked mode| bcrypt["bcrypt compare vs TOURNAMENT_PASSWORD_HASH (env)"]
md --> ok["authorised → handler"]
bcrypt --> ok
note["locked mode also 404s POST /api/tournament/reset"]
- File mode (default): plaintext compare against
tournament.md.POST /api/tournament/resetis available (for a forgotten admin password). - Locked mode (
--lock-password/LOCK_PASSWORD=true+TOURNAMENT_PASSWORD_HASH): bcrypt compare; reset endpoint returns 404.GET /api/auth-configreports the mode to the SPA.
6. Server-side network hardening (cmd/mobile_app.go)
| Setting | Value | Why |
|---|---|---|
ReadHeaderTimeout |
10s | slowloris-header defense |
ReadTimeout |
30s | slow-body defense (still allows multi-MB CSV import) |
IdleTimeout |
120s | bounds fd commitment per idle keep-alive |
WriteTimeout |
0 | SSE is infinite; cancellation through the request context |
MaxHeaderBytes |
1 MB | header-bomb defense |
| Body cap (admin JSON) | 1 MB | MaxBodyBytes middleware → 413 |
Body cap (/tournament/import) |
64 MB | matches multipart CSV import |
| Graceful shutdown | 30s | Hub.Close through RegisterOnShutdown |
7. Scale limit = egress
Because every real-time update is fanned out to every connected viewer, network egress is the practical ceiling, not CPU/RAM. See Infrastructure architecture for per-tier audience guidance (for example, GCP free tier compared with Oracle for 1000+ viewers).