Socket Events
The realtime contract between the mobile client and esu. Declared in packages/types/src/socket-events.ts, implemented in apps/esu/src/socket/handlers.ts.
Verify against the implementation
The declared types are not enforced against the handlers — esu registers them on a loosely typed Server. Several events differ between the contract and the wire. Both are documented below; the Implemented column is what actually happens.
Connecting
io(realtimeUrl, {
transports: ["websocket"],
auth: { token }, // the same JWT the REST API issued
reconnection: true,
reconnectionDelay: 200,
timeout: 8000,
});On connect the server verifies the token, joins the socket to user:<userId>, marks the user online, and broadcasts presence. Authentication failure disconnects immediately.
Use @playpals/socket-client rather than talking to Socket.IO directly — it handles connection sharing, token refresh, and typed listeners.
Rooms
| Room | Joined | Carries |
|---|---|---|
user:<userId> | Automatically on connect | Notifications, media updates |
dm:<conversationId> | Explicitly, after authorization | DM traffic |
room:<roomId> | Explicitly, after authorization | Channel room traffic |
channel:<channelId> | Channel membership | Channel lifecycle |
Client → server
| Declared | Implemented | Payload | Ack |
|---|---|---|---|
presence:ping | ping ⚠️ | { ts } | { ts } |
dm:join | dm:join | { conversationId } | Ack |
dm:leave | dm:leave | { conversationId } | Ack |
dm:typing:start | dm:typing:start | { conversationId } | optional |
dm:typing:stop | dm:typing:stop | { conversationId } | optional |
room:join | room:join | { roomId } | Ack |
room:leave | room:leave | { roomId } | Ack |
room:typing:start | room:typing:start | { roomId } | optional |
room:typing:stop | room:typing:stop | { roomId } | optional |
Ack shape
type Ack = (res: { ok: true } | { ok: false; error: string; code?: string }) => void;Joins are authorized through the API, so they can be refused:
client.joinDm(conversationId);
// → { ok: false, error: "Cannot join conversation (403)", code: "AUTHZ_DENIED" }Server → client
| Declared | Implemented | Payload |
|---|---|---|
presence:update | presence:update ⚠️ | Declared { userId, status, lastSeenAt }; emitted with lastSeen |
dm:message:new | ✔ | { conversationId, message } |
dm:message:edited | ✔ | { conversationId, messageId, body, editedAt } |
dm:message:deleted | ✔ | { conversationId, messagedId, deletedAt } — note the typo in the contract |
dm:read | ✔ | { conversationId, userId, lastReadAt, lastReadMsgId } |
dm:typing | ✔ | { conversationId, userId, isTyping } |
room:message:new | ✔ | { roomId, message } |
room:message:edited | ✔ | { roomId, messageId, body, editedAt } |
room:message:deleted | ✔ | { roomId, messageId, deletedAt } |
room:message:typing | room:typing ⚠️ | { roomId, userId, isTyping } |
channel:room:created | ✔ | { channelId, room } |
channel:room:updated | ✔ | { channelId, roomId, patch } |
channel:member:joined | ✔ | { channelId, userId } |
channel:member:left | ✔ | { channelId, userId } |
notification:new | ✔ | { notification } |
media:processing:update | ✔ | { postId, media } |
The dm:read field name differs too
The type declares lastReadMsgId, and esu's admin route emits lastReadMessageId:
router.post("/emit/dm/read", (req, res) => {
const { conversationId, userId, lastReadAt, lastReadMessageId } = req.body;
io.to(roomNames.dm(conversationId)).emit("dm:read", { conversationId, userId, lastReadAt, lastReadMessageId });
});Interservice events
Sent by api and ogun to esu over HTTP, not over a socket. Guarded by X-Realtime-Admin-Token.
| HTTP route | Body | Emits |
|---|---|---|
POST /admin/emit/dm/message/new | { conversationId, message } | dm:message:new |
POST /admin/emit/dm/read | { conversationId, userId, lastReadAt, lastReadMessageId } | dm:read |
POST /admin/emit/room/message/new | { roomId, message } | room:message:new |
POST /admin/emit/channel/room/created | { channelId, room } | channel:room:created |
POST /admin/emit/notification/new | { userId, notification } | notification:new |
POST /admin/emit/media/processing/update | { userId, postId, media } | media:processing:update |
packages/types also declares an InterServiceEvents map with emit:* names. It documents the payloads but is not the transport — these are HTTP POSTs. It also contains a typo: emit:channel:memeber:joined.
ogun cannot reach these routes in production
Its helper omits the /admin prefix that the API's helper adds, while both deployments set the same REALTIME_SERVICE_URL. So media:processing:update from image processing is never delivered. See Service Topology.
Typed client usage
const client = useRealtime({ url: Env.realtimeUrl, getToken });
// join, listen, clean up
useEffect(() => {
client.joinDm(conversationId);
const off = client.on("dm:message:new", ({ message }) => appendMessage(message));
return () => { off(); client.leaveDm(conversationId); };
}, [client, conversationId]);
// typing
client.startDmTyping(conversationId);
client.stopDmTyping(conversationId);Adding an event
- Declare it in
packages/types/src/socket-events.ts. - Handle or emit it in
apps/esu/src/socket/handlers.ts. - If the API triggers it, add an
/admin/emit/*route inapps/esu/src/interservice/adminRoutes.tsand call it from the API. - Add a helper to
RealtimeClientif clients will emit it. - Rebuild
@playpals/types.
Use the exact same string in all of them. Every discrepancy on this page came from that step being skipped.
Fixing the drift
The cleanest sequence, given the app is already shipping against the current wire format:
- Update
packages/typesto match whatesuactually emits (ping,lastSeen,room:typing,lastReadMessageId), correctingmessagedIdandmemeberin the process. - Type
registerSocketHandlersagainstServer<ClientToServerEvents, ServerToClientEvents>so the compiler catches the next divergence. - Rename toward the intended names later, as a coordinated client-and-server change.
Doing step 2 first is what stops this from recurring.
