Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WebSocket Core

CI

A minimal, complete reference implementation of two-way, real-time communication between two distinct browser UIs, routed through a Spring Boot WebSocket backend.

Open the Control Panel and any number of Display Boards in separate windows: commands sent from the control panel render instantly on every display, and the displays talk back — every announcement is acknowledged, viewers send live reactions, and any display can broadcast an announcement of its own to everyone. Each client gets a server-assigned identity (a unique id plus a name like display-3f2a) and can rename itself — say, to Nick or Sarah — with renames flowing to all clients through a live roster.

┌─────────────────┐         ┌──────────────────────┐         ┌─────────────────┐
│  Control Panel   │   WS    │  Spring Boot backend  │   WS    │  Display Boards  │
│  (React, /control)◄───────►│  /ws  message router  │◄───────►│  (React, /display)│
│                 │         │                      │         │      × N        │
│  announcements  │────────►│  assigns identities,  │────────►│  renders text,  │
│  color commands │         │  stamps sender + time,│         │  changes color  │
│                 │◄────────│  routes events to the │◄────────│  acks, reactions│
│  live log       │         │  opposite role,       │         │  renames itself │
│  live roster    │◄───────►│  broadcasts to all,   │◄───────►│  announces to   │
│                 │         │  shares the roster    │         │  everyone       │
└─────────────────┘         └──────────┬───────────┘         └─────────────────┘
                                       │ every routed frame
                                       ▼
                            ┌──────────────────────┐
                            │  Observer (/observer) │
                            │  read-only live feed  │
                            └──────────────────────┘

There's also a third role: the Observer — a read-only dashboard that receives every event flowing in either direction plus all broadcasts, and can send nothing (the server rejects it, not just the UI). It exists to prove the routing generalizes — see the routing table under The protocol.

What it looks like

The Control Panel — the announcement composer and color swatches drive every display at once; the live roster shows everyone connected by name (here: two control panels and four displays, including ones renamed to Sarah, Arjune, Nick, and Steve Jobs); and the message log captures the two-way traffic — reactions and acks coming back from displays, plus display broadcasts (📢):

Control Panel with live roster and message log

A Display Board — this one renamed itself to "Steve Jobs" (see the status bar and the rename field), and its billboard is showing a broadcast it sent to everyone, attribution included. The reaction bar sends emoji back to the control panel, and the announce field broadcasts to every connected client:

Display Board showing an attributed broadcast

Stack

Layer Tech
Backend Java 21 · Spring Boot 4 · spring-boot-starter-websocket (raw WebSocket API, no STOMP) · Jackson 3
Frontend React 19 · TypeScript · Vite · React Router · native browser WebSocket API

Deliberately no STOMP, no SockJS, no socket.io — the point of this core is to show the actual mechanics: the handshake, the JSON protocol, session tracking, routing, and reconnection are all visible in ~300 lines of application code.

Running it

Backend (terminal 1):

cd backend && ./mvnw spring-boot:run

Frontend (terminal 2):

cd frontend && npm install && npm run dev

Then open http://localhost:5173 — launch the Control Panel and the Display Board in two separate windows and start sending.

The protocol

Every frame in either direction is one JSON envelope (WsMessage.java / protocol.ts):

{ "type": "EVENT", "sender": { "id": "3f2a91cc", "name": "Nick", "role": "display" }, "payload": { "kind": "reaction", "emoji": "🎉" }, "timestamp": 1753993921000 }

sender is stamped by the server on routed messages — a client-supplied identity is never trusted, so clients always send sender: null.

Type Direction Meaning
JOIN client → server First message after connecting; declares control or display
WELCOME server → joiner Reply to JOIN carrying the assigned identity {id, name, role}
EVENT client ⇄ server App payload; stamped with the sender and forwarded to all clients of the opposite role
BROADCAST client ⇄ server App payload; stamped and forwarded to everyone, the sender included
RENAME client → server Change this client's display name (trimmed, 1–32 chars)
PRESENCE server → all The full roster of connected clients; sent on every join/leave/rename
ERROR server → sender The previous message couldn't be handled

App-level events ride inside EVENT/BROADCAST payloads (announcement, set-color, reaction, ack), so adding a new feature means adding a payload kind — the transport never changes.

EVENT routing is a declarative table in the handler — nothing else in the router is role-aware, so adding a role to the whole system is one map entry (that's how the observer was added; the commit history shows the refactor and the extension as separate steps):

Map.of(
    "control",  Set.of("display", "observer"),
    "display",  Set.of("control", "observer"),
    "observer", Set.of());   // empty = read-only

Where the interesting code is

  • CoreWebSocketHandler.java — the router: JOIN handshake, role-based forwarding, presence broadcasts, per-session write synchronization (Spring's WebSocketSession is not safe for concurrent writes).
  • SessionRegistry.java — live sessions in a ConcurrentHashMap; the handler is invoked concurrently, one thread per client event.
  • useWebSocket.ts — a React hook wrapping the native WebSocket: JOIN on open, capped exponential-backoff reconnect, and a ref-held message callback so components re-render freely without tearing down the connection.
  • WebSocketConfig.java — endpoint registration and allowed origins (the WebSocket equivalent of CORS; browsers do not enforce same-origin for WebSockets, so the server checks the Origin header).

Testing

cd backend && ./mvnw test
cd frontend && npm test

Backend (JUnit 5 + Mockito + AssertJ)

The suite is layered to mirror the architecture (44 tests), with one handler test class per protocol concern — join, routing, rename, broadcast, observer:

  • SessionRegistryTest — pure unit tests of the session store: registration, rejoin, removal, filtering, counts.
  • CoreWebSocketHandlerJoinTest — the JOIN handshake and every pre-join failure: bad roles, missing payload, events before joining, malformed JSON, unknown types. Sessions are Mockito mocks; sent frames are captured and decoded back into envelopes so assertions read at the protocol level.
  • CoreWebSocketHandlerRoutingTest — role-based fan-out, no echo to the sender's own role, server stamping, closed-session skipping, and disconnect pruning + presence re-broadcast.
  • WebSocketRoutingIntegrationTest — boots the real server on a random port and connects real WebSocket clients: an announcement flows control → display, the ack flows back, and presence updates arrive as clients come and go.

Frontend (Vitest + React Testing Library)

Vitest is the natural runner for a Vite app — it reuses vite.config.ts for TypeScript/JSX handling and is Jest-API-compatible. 35 tests:

  • useWebSocket.test.ts — the hook against a scripted FakeWebSocket that tests drive explicitly (open() / receive() / drop()): JOIN handshake, status transitions, the EVENT envelope, exponential backoff doubling and resetting (fake timers), and no reconnect after unmount.
  • ControlPanel.test.tsx / DisplayBoard.test.tsx — the real components rendered against the fake socket, driven with user-event: sends produce the right frames, inbound frames produce the right UI, and controls disable while disconnected.

Running with Docker

docker compose up --build

Then open http://localhost:3000. Two images:

  • backend — multi-stage: the Maven wrapper builds the boot jar on a JDK 21 image (dependencies cached as their own layer), and only the jar ships on a JRE image. Not published to the host; it's reachable only on the compose network.
  • frontend — the type-checked Vite build on Node 22, served by nginx with SPA fallback routing. nginx also proxies /ws to the backend, forwarding the HTTP upgrade — the browser only ever talks to port 3000.

In production builds the frontend derives its WebSocket URL from the page origin (ws(s)://<host>/ws), so the same bundle works wherever the proxy fronts it; VITE_WS_URL overrides this. The backend's allowed origin is set per environment via APP_WS_ALLOWED_ORIGINS.

Notes & gotchas learned building this

  • Spring Boot 4 modularized its starters: spring-boot-starter-websocket no longer drags in the web/Jackson stack — spring-boot-starter-web is an explicit dependency now.
  • Spring Boot 4 ships Jackson 3: packages moved from com.fasterxml.jackson.* to tools.jackson.* and exceptions became unchecked (JacksonException).
  • A plain curl http://localhost:8080/ws returns 400 — correct behavior, since the endpoint only accepts requests with WebSocket upgrade headers.
  • Presence broadcasts and event routing can write to the same session from different threads, hence the per-session synchronized block in the handler's send().

About

Core reference project: real-time two-way WebSocket communication between a Spring Boot 4 router and multiple named React UIs (control panel + display boards) — identity-aware JSON protocol, layered tests on both sides, CI, one-command Docker startup

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages