Real-time chat system for live streaming. Built with Spring Boot backend and React frontend. Similar to Twitch/YouTube Live chat with moderation, WebSocket support, and JWT auth.
- π¬ Real-time messaging via WebSocket (STOMP)
- π JWT authentication
- π‘οΈ Moderation tools (timeout, ban, delete messages)
- β‘ Rate limiting per user role
- π User roles (Broadcaster, Moderator, VIP, Subscriber)
- π Chat modes (slow mode, followers-only, subscribers-only)
- π Scalable with Redis pub/sub
- π Message history & replay for reconnects (2.5)
- π Audit logging for all moderation actions
- π‘οΈ Optimistic locking with
@Versionfor concurrent safety (2.4)
- Stream-keyed WebSocket endpoint (A3):
stomp-client.tscan connect to/ws-chat/stream/{streamKey}(VITE_USE_STREAM_AFFINITY=true) so the stream key is carried in the path for consistent-hash load balancing;StreamAffinityHandshakeInterceptorresolves it server-side. Default SockJS/ws-chatpath preserved when affinity is off. replyToMessageIdwire field: normalized to the camelCase name the frontend sends; the STOMP reply path no longer drops the field on the wire.- Local-first broadcast:
chat.broadcast.local-firstdelivers WS sends directly through the in-memory broker when stream affinity pins the sender to the owner instance (forceRediskeeps REST sends on the Redis fan-out path). - Consistent-hash load balancing (A4):
deploy/stream-affinity-lb.mddocuments the nginx + Envoy routing + rollback for instance pinning. - Broadcast observability (A5):
MetricsServicerecords local-delivery vs. orphaned broadcasts (chat.broadcast.local,chat.broadcast.orphaned); Prometheus smoke test validates the gauges. - Character-count enforcement (works):
maxMessageLengthis enforced server-side inChatService.validateMessageContentand surfaced live on the frontendMessageInputcounter; over-limit sends are rejected with400 Bad Request. - Slow mode: enforced in
ChatService.enforceSlowModevia an atomic Lua GET+SET against Redis (cross-instance) with an in-process Caffeine fallback. The fallback is what the integration tests exercise; see Current State Notes below.
@Versionoptimistic locking onBannedUserandTimedOutUser- Flyway V9 migration adding
version BIGINTcolumns with backfill - Idempotent upserts in
banUser/timeoutUserviaDataIntegrityViolationExceptionrace guard - Idempotent unbans (
unbanUser/removeTimeout) β silent success if already-unbanned - Optimistic lock retry-once pattern;
ConflictException(409) on exhaustion - Role cache eviction (
streamAuthorizationService.evictRoleCache()) fires after DB commit
GET /api/streams/{streamKey}/messages/replay?afterSequenceId={id}&limit={n}β fetch missed messages on reconnect- Backend cursor pagination; the replay endpoint reuses the history query (sequence-based trimming is best-effort for now)
- Frontend tracks
lastSeenMessageIdand merges replayed messages via deduplication
- Chat hanging ("Connecting to chat⦠forever"):
useStompChat.tsderivedws://localhost:3000fromwindow.locationwhenVITE_WS_URLwas unset (Docker build excludes.envfiles). SockJS requireshttp://βws://causes indefinite hang. FixedbuildSockJSUrl()instomp-client.tsto convertws://βhttp://andwss://βhttps:// - Theme stuck on dark:
tailwind.config.jshad nodarkModesetting β Tailwind defaulted to'media'mode, ignoring thedarkclass toggled byDarkModeToggle. Fixed: addeddarkMode: 'class' - Theme styling:
index.csshardcodedcolor-scheme: darkand a darkbodygradient.ChatWindow.tsxandStreamSettingsForm.tsxused hardcoded dark classes withoutdark:prefix. Fixed: all components now usedark:prefixes for dark-only styles, light defaults for light mode - Sidebar placeholder:
StreamSettingsFormonly showed 3 settings with hardcoded dark styling and no error handling. Rewrote: 9 settings fields, loading/error states, proper validation, light/dark theme support
frontend/.dockerignoreexcluded.envbut not.env.production, which hadVITE_API_URL=https://api.yourdomain.com/apibaked into the frontend at Docker build time β causing all API calls to hit a non-existent domain- Fix: Added
.env.productionand.env.developmentto.dockerignoreso Vite falls back to relative/api - Fix: Added nginx reverse proxies for
/api/β backend:8080 and/ws-chat/β backend:8080 WebSocket - Frontend now uses relative URLs; nginx routes to the
stream-chat-appcontainer on the Docker network
- Implemented
ChatControllerwithPOST /api/streams/{streamKey}/messagesendpoint (GET /messagesandGET /presencealready handled byStreamController) - Pre-populated
.envwith all required values including a secure 256-bitJWT_SECRET - Backend profile defaults to
prodin Docker (-P !devexcludes H2 driver) - Postgres
POSTGRES_DBsourced from${POSTGRES_DB}for consistency withDATABASE_URL - Dockerfile uses
-Dmaven.test.skip=trueto exclude test compilation from production image
- Frontend WS URL derives from
window.locationifVITE_WS_URLunset (fail-fast only on SSR) - Postgres uses strict env vars
${POSTGRES_USER:?must be set}and${POSTGRES_PASSWORD:?must be set} - Backend
DATABASE_USERNAME/DATABASE_PASSWORDmapped to same Postgres env vars - Healthcheck uses
curl(Amazon Corretto image now includes curl viamicrodnf)
UserBadgeRepository.findBadgeTypesByUserIdAndStreamIdOrGlobalIn()β batched badge queryUserStreamRoleRepository.findByUserIdAndStreamIdIn()β batched roles query
- Tests: 315 total (295 backend + 20 frontend). 6 backend tests are Testcontainers-gated and skip when Docker is unavailable to the test runner (Redis Lua-script + multi-instance fan-out tests); all non-gated tests pass.
- Slow mode: functional and integration-tested (a second rapid message is rejected with HTTP 429 / a STOMP error on
/user/queue/errors). The backend enforces it via the in-process Caffeine store by default; the Redis-backed Lua path is configured (chat.slowmode.storage) but theRedisConfigbean is gated by a@ConditionalOnPropertytied tospring.autoconfigure.exclude, so the Redis template is not created when Redis auto-configuration is excluded β slow mode therefore falls back to the in-process store. Wire up Redis to get cross-instance enforcement. - Char-count enforcement: working β the server validates
maxMessageLengthand the frontend shows a live counter. - Gap replay: the
/messages/replayendpoint exists and the frontend deduplicates backfill, but sequence-based trimming is best-effort (the endpoint currently reuses the history query).
mvn spring-boot:runRuns on http://localhost:8080 with an H2 in-memory database and the in-process Caffeine
store (Redis is excluded from the default dev profile β see Docker for the full PostgreSQL +
Redis stack).
cd frontend
npm install
npm run devRuns on http://localhost:5173 and connects to backend at http://localhost:8080/api.
The fastest way to run the full stack (Spring Boot backend, React frontend, PostgreSQL, Redis).
- Docker Engine 24+ and Docker Compose v2 (
docker compose). Legacydocker-composev1 is not supported.
The .env file is pre-populated with all required variables. To use your own
values, copy the example and edit:
-
Backend environment file (runtime settings for the API container):
cp .env.example .env
All required variables are already set in the committed
.env. Edit them as needed β the critical ones are:Variable Required Purpose JWT_SECRETYes JWT signing key, β₯ 32 bytes. Compose refuses to start without it. POSTGRES_USERYes Must match DATABASE_USERNAMEin the backend config.POSTGRES_PASSWORDYes Must match DATABASE_PASSWORDin the backend config.POSTGRES_DBYes Database name. Must match the DB name in DATABASE_URL.DATABASE_URLYes JDBC URL, e.g. jdbc:postgresql://postgres:5432/<POSTGRES_DB>.REDIS_HOSTNo Redis hostname. Docker default: redis.REDIS_PORTNo Redis port. Default: 6379.JWT_EXPIRATIONNo Access-token lifetime in ms. Default 86400000.JWT_ACCESS_TOKEN_TTLNo Access-token TTL. Default PT15M.JWT_REFRESH_TOKEN_TTLNo Refresh-token TTL. Default P30D.CORS_ALLOWED_ORIGINSNo Comma-separated browser origins allowed to call the API. Defaults to http://localhost:3000,http://localhost:8080.SPRING_PROFILES_ACTIVENo Spring profile. prodis the Docker default.CHAT_RETENTION_DAYSNo Message retention window for cleanup. Default 90.AUTOMOD_*No AutoMod thresholds (caps, spam, trust decay, shadow-ban). LOG_FORMATNo TEXTorJSON. DefaultTEXT.Postgres credentials must match between
docker-compose.ymlenv vars andDATABASE_USERNAME/DATABASE_PASSWORDin.env. Mismatch causes backend connection failure. -
Generate a
JWT_SECRETif you want a different one:openssl rand -base64 32
JWT_SECRETis mandatory β the backend refuses to boot if it is shorter than 32 bytes, and Compose refuses to start without it. -
The frontend environment file is only needed for local development (
npm run dev). In Docker, the frontend env vars are baked into the image at build time:cp frontend/.env.example frontend/.env
Required variables:
VITE_API_URLandVITE_WS_URL. IfVITE_WS_URLis unset, the client derives fromwindow.location.protocol/host(development only).
docker compose up --build -dForeground variant (watch logs in the terminal): docker compose up --build
| Service | Container | Port | URL |
|---|---|---|---|
| Backend API | stream-chat-app |
8080 | http://localhost:8080 |
| Backend health | stream-chat-app |
β | http://localhost:8080/actuator/health |
| Frontend | stream-chat-frontend |
3000 | http://localhost:3000 |
| PostgreSQL | stream-chat-postgres |
5432 | localhost:5432 (internal) |
| Redis | stream-chat-redis |
6379 | localhost:6379 (internal) |
The backend starts only after Postgres and Redis report healthy; first start runs Flyway migrations automatically.
- Health:
curl http://localhost:8080/actuator/healthβ{"status":"UP"}. - Open http://localhost:3000.
- Register a user, then open the same stream in two browser tabs and send a message in one β it should appear in the other in real time (WebSocket/STOMP over
/ws-chat).
API endpoints available on the backend:
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/streams/{streamKey}/messages |
Public | Paginated chat history (via StreamController) |
GET |
/api/streams/{streamKey}/messages/replay |
Auth | Missed messages for reconnect recovery (via StreamController) |
POST |
/api/streams/{streamKey}/messages |
Auth required | Send a chat message (via ChatController) |
GET |
/api/streams/{streamKey}/presence |
Public | Active viewer count |
GET |
/api/streams/{streamKey}/settings |
Auth | Stream chat settings (via SettingsController) |
PUT |
/api/streams/{streamKey}/settings |
Auth (owner) | Update chat settings (via SettingsController) |
POST |
/api/streams/{streamKey}/moderate/* |
Auth (moderator+) | Timeouts, bans, shadow-ban, pinning, logs (via ModerationController) |
With Docker available, the Testcontainers-gated integration tests run too:
mvn clean test # backend β all tests
cd frontend && npm run test -- --run # frontend315 tests (295 backend + 20 frontend); 6 backend tests are Testcontainers-gated and skip without Docker. All non-gated tests pass.
Run a subset of tests with Maven:
mvn testFrontend tests (non-watch mode):
cd frontend && npm run test -- --runMIT