Real-Time Operation Events via Socket.IO in SEAM API
SEAM API emits Socket.IO events during every operation, letting clients show live progress indicators and automatically refresh data when mutations occur.
Use this file to discover all available pages before exploring further.
Every service call in SEAM API reports its progress over Socket.IO in real time. This means your frontend can show an accurate loading message at each step of an operation (“Verifying credentials…”, “Generating session token…”) and automatically refresh a data table the moment another user makes a change — without polling.
SEAM API emits two distinct Socket.IO events. They serve different purposes and are delivered to different audiences.
operation:progress
Emitted only to the socket that initiated the request. Carries granular status updates so the requesting client can drive a loader or notification component.
data:changed
Broadcast to all connected clients when a mutation succeeds. Lets every open tab or session refresh its data without polling.
{ operation: string; // e.g. "user:edit", "permission:assign" message: string; // success message from the service data: any; // the mutated entity or null initiatorSocketId: string | null; // socket ID of the client that triggered the mutation}
When a service throws an error, the global error middleware uses store.currentOperation (set during emitStart) to call emitError with the right operation name automatically — you don’t need to catch and emit in every service.
data:changed is only emitted for mutation operations. The socketEvents.js helper classifies an operation as a read if its name ends with one of the fetch suffixes, or if it is the login operation:
// Read-only suffixes — these never trigger data:changedconst FETCH_SUFFIXES = [':fetchAll', ':fetchOne', ':fetchByRole', ':fetchByUri'];// Read-only operations identified by full nameconst FETCH_OPS = ['auth:login'];const isMutation = (op) => !FETCH_SUFFIXES.some(s => op.endsWith(s)) && !FETCH_OPS.includes(op);
Everything else — auth:register, user:edit, permission:assign, role:delete, etc. — is treated as a mutation and triggers a broadcast.
The server needs to know which socket to target for operation:progress events without passing IDs through every function signature. It uses Node.js AsyncLocalStorage to carry a per-request context store that propagates automatically through the entire async/await chain:
// requestContext.js — store shape{ userId: number | null, // populated by auth.middleware after JWT verification socketId: string | null, // populated by context.middleware from X-Socket-ID header currentOperation: string | null, // set on emitStart, used by error.middleware}
context.middleware.js initialises a fresh store for every incoming HTTP request before any route handler runs:
The emit helper then reads the store to decide how to target the event:
if (store?.socketId) { // Precise: emit only to the exact browser tab that made the request socketService.emitToSocket(store.socketId, 'operation:progress', payload);} else if (store?.userId) { // Fallback: emit to all sockets belonging to the user socketService.emitToUser(store.userId, 'operation:progress', payload);}
socket.on('data:changed', ({ operation, data, initiatorSocketId }) => { // If this socket triggered the mutation it already has the result // from the HTTP response — skip the redundant refresh. if (initiatorSocketId === socket.id) return; refreshTable(); // reload the affected data from the API});
This room is used as the fallback target for operation:progress when no X-Socket-ID header was sent. It also provides a convenient way to push notifications to a specific user across all of their open tabs using socketService.emitToUser(userId, event, data).
initiatorSocketId and skipping redundant refreshes
When data:changed is broadcast, it includes the initiatorSocketId of the socket that triggered the mutation. The client that made the original HTTP request already received the updated data in the API response — it does not need to re-fetch. Comparing initiatorSocketId to socket.id lets that client skip the refresh while all other connected clients still update:
socket.on('data:changed', ({ operation, data, initiatorSocketId }) => { if (initiatorSocketId === socket.id) { // Already have the latest data from the HTTP response return; } // Another client or tab made this change — refresh the local view refreshTable(operation);});
The default setup works for a single-server deployment. If you run multiple SEAM API instances behind a load balancer, Socket.IO events emitted on one instance are not seen by sockets connected to other instances. To fix this, attach a Redis adapter when initialising Socket.IO: