Files
nick-doc/01 - Architecture/Real-time Layer.md
2026-05-23 20:35:34 +03:30

9.2 KiB

title, tags, created
title tags created
Real-time Layer
architecture
realtime
websocket
socket.io
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

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:

// 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.
  • TamperinguserId 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:

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.