221 lines
9.2 KiB
Markdown
221 lines
9.2 KiB
Markdown
---
|
|
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]] · [[Escrow Flow]] · [[Dispute Flow]]
|
|
- [[Security Architecture]] — socket auth concerns
|
|
- [[Socket Events]] — full event reference (developer-facing API doc)
|