8.4 KiB
title, tags
| title | tags | ||||
|---|---|---|---|---|---|
| Socket Events |
|
Socket Events
The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in backend/src/app.ts and exposed globally as global.io. Helper functions for emitting events from services live in backend/src/infrastructure/socket/socketService.ts:
emitGlobalEvent(event, data); // io.emit
emitToRoom(room, event, data); // io.to(room).emit
setSocketServer(server);
getSocketServer();
Connection
import { io } from "socket.io-client";
const socket = io(process.env.NEXT_PUBLIC_API_URL!, {
withCredentials: true,
transports: ["websocket"],
});
The CORS policy mirrors the REST API: only FRONTEND_URL is allowed, and credentials are enabled. There is no token-based handshake — the server identifies users by the join-* events the client emits after connecting. (A future improvement would authenticate via the JWT on handshake; for now, ownership is checked at the REST layer when a mutation triggers an event.)
The server logs 🔌 User connected: <socketId> on connect and 🔌 User disconnected: <socketId> on disconnect. There is no broadcast user-offline on disconnect — see "Online status" below.
Rooms
Rooms are the targeting mechanism. The client joins/leaves rooms with these events:
| Client → Server | Room joined |
|---|---|
join-user-room (userId) |
user-<userId> |
join-request-room (requestId) |
request-<requestId> |
leave-request-room (requestId) |
leaves request-<requestId> |
join-seller-room (sellerId) |
seller-<sellerId> + sellers (global) |
leave-seller-room (sellerId) |
leaves both |
join-buyer-room (buyerId) |
buyer-<buyerId> + buyers (global) |
leave-buyer-room (buyerId) |
leaves both |
join-chat-room (chatId) |
chat-<chatId> |
leave-chat-room (chatId) |
leaves chat-<chatId> |
user-online (userId) |
joins user-<userId>, broadcasts user-status-change |
typing-start ({ chatId, userId, userName }) |
broadcasts user-typing to chat-<chatId> |
typing-stop ({ chatId, userId }) |
broadcasts user-typing (isTyping=false) |
Joining a room is unauthenticated — clients are expected to only join their own rooms. Sensitive data is filtered at the REST layer that emits the event.
Server → Client events
Grouped by the service that emits them.
Marketplace
| Event | Room | Payload | Source |
|---|---|---|---|
new-purchase-request |
sellers |
PurchaseRequest document |
marketplaceController.createPurchaseRequest |
new-offer |
buyer-<buyerId> |
{ requestId, offer, sellerId } |
marketplaceController.createSellerOffer |
seller-offer-update |
seller-<sellerId> (and global on payment confirm) |
{ sellerId, requestId, eventType: "payment-completed" | "offer-rejected" | "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } } |
marketplaceController, shkeeperRoutes, shkeeperWebhook, SellerOfferService |
purchase-request-update |
request-<requestId> |
{ type, requestId, status?, paymentId?, txHash?, provider? } |
marketplaceController, PurchaseRequestService, shkeeperRoutes, paymentCoordinator |
request-cancelled |
user-<buyerId>, user-<sellerId> |
{ requestId, reason } |
PurchaseRequestService |
transaction-completed |
user-<buyerId>, user-<sellerId> |
{ requestId, paymentId, amount, currency } |
marketplaceController |
delivery-code-generated |
request-<requestId> |
{ requestId, deliveryCode } (only the seller UI uses this) |
DeliveryService |
delivery-update |
request-<requestId> |
{ requestId, status, carrier?, trackingNumber? } |
DeliveryService |
delivery-confirmed |
request-<requestId> |
{ requestId } |
DeliveryService |
buyer-confirmed-delivery |
user-<sellerId> |
{ requestId, buyerId } |
DeliveryService |
template-checkout-payment-confirmed |
global + template-checkout-<id> |
{ checkoutId, requestIds, paymentId } |
templateCheckoutWebhook, paymentCoordinator |
template-checkout-payment-pending |
global | { checkoutId } |
templateCheckoutWebhook |
template-checkout-payment-failed |
global | { checkoutId, reason } |
templateCheckoutWebhook |
Payment
| Event | Room | Payload | Source |
|---|---|---|---|
payment-created |
global | { paymentId, provider, requestId, buyerId, sellerId, amount, currency } |
shkeeperService, decentralizedPaymentService |
payment-received |
user-<sellerId> |
{ purchaseRequestId, amount, currency, buyerId } |
paymentRoutes, marketplace/routes |
payment-update |
global + room-specific | { paymentId, status, escrowState?, txHash? } |
paymentCoordinator |
payout-created |
global | { payoutId, taskId, sellerId, amount, currency } |
shkeeperPayoutService |
payout-completed |
global, user-<sellerId> |
{ payoutId, taskId, txHash } |
shkeeperPayoutService, decentralizedPaymentService |
payout-updated |
global | { payoutId, status } |
shkeeperPayoutService |
Chat
| Event | Room | Payload |
|---|---|---|
new-message |
chat-<chatId> |
{ chatId, message: { _id, content, senderId, createdAt, attachments? }, senderId } |
messages-read |
chat-<chatId> |
{ chatId, userId, upToMessageId, modifiedCount } |
message-edited |
chat-<chatId> |
{ chatId, messageId, content, editedAt } |
message-deleted |
chat-<chatId> |
{ chatId, messageId, deletedAt } |
participants-added |
chat-<chatId> |
{ chatId, addedUserIds } |
participant-removed |
chat-<chatId> |
{ chatId, removedUserId } |
user-typing |
chat-<chatId> |
{ userId, userName?, isTyping } |
user-status-change |
broadcast | { userId, status: "online", lastSeen } |
Sources: ChatService.ts, chatController.ts, and app.ts socket handlers.
Notification
| Event | Room | Payload |
|---|---|---|
new-notification |
user-<userId> |
{ notification: { _id, type, title, body, data, createdAt } } |
unread-count-update |
user-<userId> |
{ unreadCount } |
Source: NotificationService.ts.
Points
| Event | Room | Payload |
|---|---|---|
level-up |
user-<userId> |
{ oldLevel, newLevel, lifetimePoints, perks } |
referral-reward |
user-<referrerId> |
{ referredUserId, points, transactionId } |
referral-signup |
user-<referrerId> |
{ referredUserId, name, joinedAt } |
Sources: PointsService.ts, authController.ts.
Online status
A client emits user-online after connecting; the server broadcasts user-status-change (status "online"). There is currently no matching offline emit on disconnect because the server does not track which userId belongs to which socketId. To implement presence, store the mapping on connect and emit user-status-change (status "offline") in the disconnect handler.
Reconnection
Socket.IO defaults are used: exponential backoff starting at 1s, capped at 5s, with jitter. The recommended client policy is:
- On reconnect, re-emit every
join-*for the rooms the user cares about. - Re-fetch any state that may have moved while disconnected (
GET /api/notifications/unread-count, the active purchase request, chat history with?before=<lastSeen>). - Treat the first 5 seconds after reconnect as "catching up" in the UI.
The server does not buffer missed events; if delivery guarantees matter, fall back to REST.
Server-side helpers (for service authors)
import { emitGlobalEvent, emitToRoom } from "@/infrastructure/socket/socketService";
emitGlobalEvent("new-purchase-request", request);
emitToRoom(`request-${requestId}`, "purchase-request-update", { type: "status_changed", status });
Use emitToRoom whenever you can — it limits the broadcast surface area. Reserve emitGlobalEvent for events that genuinely need every connected client (rare).