--- title: Real-time Layer tags: [architecture, realtime, websocket, socket.io] created: 2026-05-23 --- # Real-time Layer Socket.IO 4.8 is the single transport for all server-pushed updates: chat messages, notifications, payment status, offer arrivals, dispute updates. > [!info] > Backend handler: `backend/src/infrastructure/socket/socketService.ts`. Frontend context: `frontend/src/contexts/socket-context.tsx` consumed via `useSocket()`. --- ## 1. Connection lifecycle ```mermaid sequenceDiagram actor U as Browser participant FE as React (SocketProvider) participant BE as Socket.IO Server U->>FE: Login completes (JWT stored) FE->>BE: io.connect(url, { auth: { token } }) BE->>BE: verifyJwt(token) → req.user BE-->>FE: "connect" + socket.id FE->>BE: emit "join-user-room", userId BE-->>FE: ack Note over BE,FE: Long-lived connection. Auto-reconnect handles drops. BE-->>FE: emit "notification:received" (when triggered) FE->>FE: update React Query cache / show snackbar ``` On the client: - Socket is **created lazily** after auth state is known. - Connection auto-recovers on disconnect (Socket.IO default exponential backoff). - The provider exposes `connected: boolean` so UI can show a "reconnecting…" indicator. On the server: - The `connection` handler verifies the JWT from `socket.handshake.auth.token`. - If invalid → `socket.disconnect(true)`. - If valid → `socket.data.user = decoded` is set for use in subsequent handlers. --- ## 2. Room model Rooms are joined explicitly by the client via emitter events. Names use a `prefix-{id}` convention so socket addressing matches the canonical entity ID. | Room | Joined by | Purpose | |---|---|---| | `user-{userId}` | every authenticated socket | User-targeted events (notifications, payment updates for them) | | `seller-{userId}` | sellers on login | Seller-targeted events (new offer requests in their category) | | `sellers` | all sellers | Broadcast to all sellers (e.g., new public request) | | `buyer-{userId}` | buyers on login | Buyer-targeted events | | `buyers` | all buyers | Broadcast to all buyers | | `request-{requestId}` | participants of a request (buyer + accepted seller) | Per-request updates (status, offer arrivals) | | `chat-{chatId}` | conversation participants | Live chat messages, typing, read receipts | | `admin` | admin users | Mod-only events (new dispute opened, suspicious activity) | Client-side join/leave emitters (defined in `socketService.ts`): | Emit | Args | Effect | |---|---|---| | `join-user-room` | `userId` | Adds socket to `user-{userId}` | | `join-request-room` | `requestId` | Adds to `request-{requestId}` | | `leave-request-room` | `requestId` | Removes from `request-{requestId}` | | `join-seller-room` | `sellerId` | Adds to `seller-{sellerId}` and `sellers` | | `leave-seller-room` | `sellerId` | Removes from both | | `join-buyer-room` | `buyerId` | Adds to `buyer-{buyerId}` and `buyers` | | `leave-buyer-room` | `buyerId` | Removes from both | | `join-chat-room` | `chatId` | Adds to `chat-{chatId}` | --- ## 3. Emit helpers (server-side) The socket service exposes typed emitters used across all services so individual modules never touch `io` directly: ```ts // src/infrastructure/socket/socketService.ts export function emitToRoom(room: string, event: string, payload: unknown): void; export function emitToUser(userId: string, event: string, payload: unknown): void; export function emitToSellers(event: string, payload: unknown): void; export function emitToBuyers(event: string, payload: unknown): void; export function emitGlobalEvent(event: string, payload: unknown): void; ``` This indirection makes it trivial to: - Add per-event logging - Throttle / batch in the future - Swap to a Redis pub/sub adapter when scaling out --- ## 4. Event catalog ### 4.1 Notifications | Event | Payload | Emitted by | Rooms | |---|---|---|---| | `notification:received` | `Notification` doc | NotificationService | `user-{recipientId}` | | `notification:read` | `{ id, readAt }` | NotificationService.markRead | `user-{id}` | ### 4.2 Marketplace | Event | Payload | Emitted by | Rooms | |---|---|---|---| | `request:created` | `PurchaseRequest` | PurchaseRequestService.create | `sellers` (or category-scoped sellers in future) | | `request:offer-received` | `{ requestId, offer: SellerOffer }` | SellerOfferService.create | `user-{buyerId}`, `request-{requestId}` | | `request:status-updated` | `{ requestId, status }` | various | `request-{requestId}` | | `offer:negotiation-update` | `{ offerId, message }` | SellerOfferService.counter | `request-{requestId}` | | `offer:accepted` | `{ offerId }` | SellerOfferService.accept | `seller-{sellerId}`, `user-{buyerId}` | ### 4.3 Payment | Event | Payload | Emitted by | |---|---|---| | `payment:status-updated` | `{ paymentId, status, providerPaymentId }` | shkeeperWebhook → PaymentService | | `payment:created` | `Payment` | PaymentService.createPayInIntent | | `payment:completed` | `{ paymentId }` | PaymentService on webhook completion | | `payout:created` | `Payout` | shkeeperPayoutService | | `payout:completed` | `{ payoutId, txHash }` | payout polling / webhook | ### 4.4 Chat | Event | Payload | Rooms | |---|---|---| | `chat:new-message` | `Message` | `chat-{chatId}` and `user-{participantId}` (for badge) | | `chat:message-read` | `{ chatId, messageId, userId, readAt }` | `chat-{chatId}` | | `chat:typing` | `{ chatId, userId }` | `chat-{chatId}` | | `chat:stopped-typing` | `{ chatId, userId }` | `chat-{chatId}` | ### 4.5 Dispute | Event | Payload | Rooms | |---|---|---| | `dispute:opened` | `Dispute` | `request-{requestId}`, `admin` | | `dispute:evidence-added` | `{ disputeId, evidence }` | `request-{requestId}` | | `dispute:resolved` | `{ disputeId, resolution }` | `request-{requestId}`, `user-{buyerId}`, `user-{sellerId}` | ### 4.6 System | Event | Payload | Rooms | |---|---|---| | `system:maintenance` | `{ message, startsAt }` | global broadcast | | `system:announcement` | `{ message }` | global broadcast | --- ## 5. Client-side hooks Higher-level React hooks consume the socket and integrate with React Query so UI stays consistent: | Hook | Listens for | Effect | |---|---|---| | `useChatSocket(chatId)` | `chat:new-message`, `chat:typing` | Appends to chat query cache; updates typing state | | `useConversations()` | `chat:new-message` (any chat) | Bumps conversation in list; updates unread count | | `useNotifications()` | `notification:received` | Prepends to notification list; shows snackbar | | `usePurchaseRequests()` | `request:status-updated`, `request:offer-received` | Invalidates `['requests']` cache | | `useMarketplaceSocket()` | `request:created` (seller side) | Bumps seller feed | | `useUnifiedRealTime()` | multi | Aggregates the above for the dashboard overview | Pattern: each hook subscribes inside a `useEffect` and unsubscribes in cleanup. Use `socket.off(event, handler)` to avoid handler leaks on re-render. --- ## 6. Authentication & authorization on sockets - **Connection-time auth** — JWT verified in handshake; invalid token → disconnect. - **Per-event auth** — Critical handlers re-check role from `socket.data.user.role`. Example: `admin` room membership only added for admins. - **Tampering** — `userId` arguments in `join-*-room` events are **NOT trusted blindly**; the server must verify `socket.data.user.id === userId` before adding to the room (cite: needs verification in `socketService.ts`). > [!warning] > If `join-user-room` accepts any userId from the client, malicious users could subscribe to other users' notifications. Always cross-check with `socket.data.user.id`. --- ## 7. Reconnection & buffering Socket.IO buffers up to N events per disconnected client by default — but **the server does not replay missed events after a long disconnect**. Frontend strategy: 1. On reconnect, the SocketProvider re-fires `join-*-room` for the user's current state. 2. Critical hooks (notifications, chat) call `refetch()` on reconnect to backfill from REST. 3. Last-event-id pattern is **not implemented**; consider adding for chat reliability. --- ## 8. Scaling to multiple backend nodes Socket.IO requires a shared bus when running >1 backend instance. Recommended adapter: ```ts import { createAdapter } from '@socket.io/redis-adapter'; import { createClient } from 'redis'; const pub = createClient({ url: config.redisUri, password: config.redisPassword }); const sub = pub.duplicate(); await Promise.all([pub.connect(), sub.connect()]); io.adapter(createAdapter(pub, sub)); ``` Without this adapter, emits from node A never reach sockets connected to node B. Sticky sessions on the load balancer are also required so a given client always lands on the same node (otherwise the handshake / connection upgrade fails). --- ## 9. Testing - Backend tests (`backend/__tests__/`) mock the socket emitter via the indirection layer — no real socket server in unit tests. - Manual smoke test: open two browser tabs as buyer + seller, accept an offer, watch both receive `offer:accepted` instantly. --- ## Related - [[Backend Architecture]] · [[Frontend Architecture]] - [[Chat Flow]] · [[Notification Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]] - [[Security Architecture]] — socket auth concerns - [[Socket Events]] — full event reference (developer-facing API doc)