Skip to content

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: websocket only — long-polling is disabled at the gateway (@WebSocketGateway({ transports: ['websocket'] }), apps/rt/src/gateway/client.gateway.ts:48).
  • CORS origins: APP_FE_ORIGIN (default http://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:

  1. Implicit at connect time. The browser already has the access_token cookie set by POST /auth/sign-in; the cookie is sent automatically on the upgrade request. handleConnection reads it and emits AuthSuccess (or AuthError) as the first event.
  2. Explicit message. Send { event: 'Authorization', data: { accessToken } }; the server replies via the Authorization ack and emits AuthSuccess on 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:

  1. Wrap ClientGateway.handleConnection in a manual span.
  2. Wrap BackendService.emitEvent / sendEvent in a span (or add an interceptor) so RPC fan-out is visible.
  3. Inject traceparent into 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.