rt — websocket events¶
The rt app (port 4001, namespace /events) is the only websocket surface in the stack. It does not expose Swagger — events are listed here from apps/rt/src/gateway/events.ts, the gateway controller wirings under apps/api/src/.../*-gateway.controller.ts (decorated with @GatewayMethod), and the per-app event registries (libs/shared/src/events/{api,bo,bj,speed-roulette}.events.ts).
Connection¶
- URL:
ws://localhost:4001/events - Transport:
websocketonly — long-polling is disabled at the gateway (@WebSocketGateway({ transports: ['websocket'] }),apps/rt/src/gateway/client.gateway.ts:48). - CORS origins:
APP_FE_ORIGIN(defaulthttp://localhost:3000) and, in non-prod,https://admin.socket.io. - Max client message size:
WS_CLIENT_MESSAGE_MAX_LEN(apps/rt/src/gateway/const.ts).
Handshake / authentication¶
Per project_otel_integration_gotchas.md: socket.io reads the access token from the cookie jar, not from a custom header. The order of resolution in extractSocketAuthToken is: socket.handshake.auth.socket_token → cookie socket_token → cookie access_token.
Two ways to authenticate:
- Implicit at connect time. The browser already has the
access_tokencookie set byPOST /auth/sign-in; the cookie is sent automatically on the upgrade request.handleConnectionreads it and emitsAuthSuccess(orAuthError) as the first event. - Explicit message. Send
{ event: 'Authorization', data: { accessToken } }; the server replies via theAuthorizationack and emitsAuthSuccesson success.
Unauthenticated sockets stay open but receive only public events (e.g. LiveDropJoin-room broadcasts). Any event handler that requires auth returns 401 via the ack response.
Lifecycle¶
client rt
│ WS upgrade Cookie: access_token=… │
├────────────────────────────────────────────────►│
│ │ handleConnection
│ │ read cookie → AuthService.authorizeConnection
│ ← AuthSuccess { user: { id, ... } } │
│◄─────────────────────────────────────────────────┤
│ │
│ client subscribes / emits events │
│ … │
│ ← server-pushed events (BalanceUpdated, ...) │
│◄─────────────────────────────────────────────────┤
Throttling¶
Configured in libs/ws-throttler/src/ws-throttler.module.ts:
| Knob (env) | Default | Meaning |
|---|---|---|
WS_THROTTLER_TTL |
60000 |
Sliding window in ms (1 min). |
WS_THROTTLER_LIMIT |
240 |
Messages per window per IP. |
WS_THROTTLER_BLOCK_DURATION |
600000 |
Ban duration in ms after limit (10 min). |
WS_THROTTLER_DISABLE |
unset | Set true to disable. |
The team-lead memo cites "120 req per 60s default" — that's an older value; the running code defaults to 240 req per 60s. Bursts beyond the limit cause a synthetic error emission ("Too many requests") and a temporary IP ban.
A separate connection-level throttle (ws-throttler.service.ts) emits error "Too many connections" on excessive parallel sockets per IP.
Core lifecycle events (apps/rt/src/gateway/events.ts)¶
| Event | Direction | Payload | Notes |
|---|---|---|---|
AuthSuccess |
server → client | { user, ... } |
First event after a valid token is recognised. |
AuthError |
server → client | { error?: string, ... } |
Token missing/expired/too-long. |
Unauthorized |
server → client | { message } |
Emitted when an authenticated event is sent on an anonymous socket. |
ServerError |
server → client | { message, code } |
Generic server fault. |
TimeoutError |
server → client | { message } |
Backend RPC to the producing app timed out. |
Authorization |
client → server (method) | { accessToken } |
Re-auth on an already-open socket. ACKable. |
Server → client events¶
Every server-emitted event is namespaced by channel: server_channel_event.<EventName>. The client receives the bare <EventName> (channel prefix stripped by parseEvent).
From api (libs/shared/src/events/api.events.ts)¶
| Event | When | Payload (summary) |
|---|---|---|
BalanceUpdated |
After any balance-mutating action (bet settle, deposit, tip, vault). | { currencyId, amount, usdAmount, source } |
AffiliateClaimSuccess |
Affiliate commission claimed. | { amount, currencyId } |
UsersOnlineUpdated |
Online-tracker tick (per minute warm-up via ClientGateway.warpUpOnlineUsers). |
{ count } |
ChatMessageUpdate |
New / edited / deleted chat message. | { roomId, message } |
ProfileUpdated |
Profile, KYC, or VIP-level change. | PublicUserDto patch |
BalanceTippedSuccess |
Admin-tip received. | { amount, currencyId, fromAdminUsername } |
LatestBets |
Live-feed: every settled bet (firehose). | BetDto[] |
BigWins |
Live-feed: top-payout bets. | BetDto[] |
HighRollers |
Live-feed: top-stake bets. | BetDto[] |
LuckyWins |
Live-feed: top-multiplier bets. | BetDto[] |
MyBets |
Per-user live feed. | BetDto[] |
NotificationEventMessage |
New in-app notification. | { id, type, payload } |
ValidationErrors |
Pushed when an enqueued action fails validation post-ack. | { field, message }[] |
From bo, bj, speed-roulette¶
bo pushes admin-channel events (chat moderation, withdrawal approvals); bj and speed-roulette push table-state updates (deal, bet-settled, round-start). The full event registries are in libs/shared/src/events/bo.events.ts, bj.events.ts, speed-roulette.events.ts. The rt client gateway aggregates them transparently — clients don't need to know which app emitted a given event.
Client → server events (rooms / queries)¶
Sent as { event, data, isAck? }. If isAck: true, the gateway returns the value in the socket.io ack callback; otherwise it's fire-and-forget.
From api (subset — see libs/shared/src/events/api.events.ts Client.*)¶
| Event | Auth | Purpose | Result |
|---|---|---|---|
LiveDropJoin |
optional | Subscribe to live-bets feed for a slug. | room joined |
LiveDropLeave |
optional | — | room left |
LiveBetsJoin / LiveBetsLeave |
optional | Subscribe to global live-bets channel (apps/api/src/bet/live/live-bets.controller.ts). |
room joined/left |
ChatJoin / ChatLeave |
required | Subscribe to a chat room. | room joined/left |
ChatSendMessage |
required | Post a chat message. | { id, ... } (ackable) |
ChatGetMessages |
optional | Paginated history. | MessageDto[] |
ChatGetRooms |
optional | Available rooms. | RoomDto[] |
NotificationGet |
required | Pull current notifications. | NotificationDto[] |
NotificationViewed |
required | Mark notification(s) read. | ack |
Private (admin-only, BO-fronted)¶
Private.* events (e.g. UserBanUser, WithdrawApprove, LeaderboardGivePrize) are the websocket equivalents of admin REST endpoints. They go through bo, are gated by per-permission guards, and are only callable from authenticated admin sockets. Each handler is decorated with @GatewayMethod(GATEWAY_API_EVENTS.Private.<Name>, { permissions: [...] }). Full list: grep @GatewayMethod under ebit-api/apps/api/src/.
Tracing context¶
Blind spot (high) per docs/audits/perf-trace-coverage-audit.md: socket.io is not auto-instrumented by @opentelemetry/instrumentation-nestjs-core. There are no spans for connection, handshake auth, event emission, or room joins. The Redis-backed auth RPC fired during handshake produces ioredis spans that have no parent context. Recommended fixes:
- Wrap
ClientGateway.handleConnectionin a manual span. - Wrap
BackendService.emitEvent/sendEventin a span (or add an interceptor) so RPC fan-out is visible. - Inject
traceparentinto RPC payloads so the producing apps (api,bo,bj,speed-roulette) can re-parent their work.
Until these land, expect every websocket interaction to show up as orphan ioredis spans.
Quick test from the CLI¶
# Sign in to get the cookie jar:
curl -X POST http://localhost:4000/auth/sign-in \
-H 'Content-Type: application/json' \
-H 'x-captcha-token: pass' \
-d '{"email":"u@example.com","password":"S3cret!!"}' \
-c /tmp/cj.txt
# Use a tiny node script with socket.io-client v4:
node -e "
const io=require('socket.io-client');
const fs=require('fs');
const cookie=fs.readFileSync('/tmp/cj.txt','utf8').match(/access_token\s+(\S+)/)[1];
const s=io('ws://localhost:4001/events',{transports:['websocket'],extraHeaders:{Cookie:'access_token='+cookie}});
s.onAny((e,d)=>console.log(e,d));
"
You should see AuthSuccess within a few hundred ms, followed by LatestBets / BalanceUpdated once any activity happens.