9.2 KiB
title, tags, created
| title | tags | created | ||||
|---|---|---|---|---|---|---|
| Real-time Layer |
|
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.tsxconsumed viauseSocket().
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: booleanso UI can show a "reconnecting…" indicator.
On the server:
- The
connectionhandler verifies the JWT fromsocket.handshake.auth.token. - If invalid →
socket.disconnect(true). - If valid →
socket.data.user = decodedis 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:adminroom membership only added for admins. - Tampering —
userIdarguments injoin-*-roomevents are NOT trusted blindly; the server must verifysocket.data.user.id === userIdbefore adding to the room (cite: needs verification insocketService.ts).
Warning
If
join-user-roomaccepts any userId from the client, malicious users could subscribe to other users' notifications. Always cross-check withsocket.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:
- On reconnect, the SocketProvider re-fires
join-*-roomfor the user's current state. - Critical hooks (notifications, chat) call
refetch()on reconnect to backfill from REST. - 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:acceptedinstantly.
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)