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 it active, and publishes presence to that user's watchers. 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 |
presence:<userId> | presence:subscribe, after authorization | That user's presence:update |
post:<postId> | post:join, after authorization | That post's comments, replies and reactions |
Client → server
| Declared | Implemented | Payload | Ack |
|---|---|---|---|
presence:ping | ping ⚠️ | { ts } | { ts } |
presence:state | ✔ | { active } | optional |
presence:subscribe | ✔ | { userIds } | { ok, statuses: Record<userId, "online" | "offline"> } |
presence:unsubscribe | ✔ | { userIds } | optional |
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 |
post:join | ✔ | { postId } | Ack |
post:leave | ✔ | { postId } | Ack |
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 | ✔ | { userId, status, lastSeenAt } — sent to presence:<userId> watchers only |
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 } |
post:comment:new | ✔ | { postId, parentId, comment, commentCount, parentReplyCount } |
post:comment:deleted | ✔ | { postId, commentId, parentId, commentCount, parentReplyCount } |
post:reaction | ✔ | { postId, actorId, actorReaction, reactions, reactionCount } |
post:comment:reaction | ✔ | { postId, commentId, parentId, actorId, actorLiked, likeCount } |
post:link-preview | ✔ | { postId, linkPreview } |
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, postStatus } | media:processing:update |
POST /admin/emit/post/comment/new | event payload + excludeUserIds | post:comment:new |
POST /admin/emit/post/comment/deleted | event payload + excludeUserIds | post:comment:deleted |
POST /admin/emit/post/reaction | event payload + excludeUserIds | post:reaction |
POST /admin/emit/post/comment/reaction | event payload + excludeUserIds | post:comment:reaction |
POST /admin/emit/post/link-preview | { postId, userId, linkPreview } | post:link-preview (post room and author) |
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.
media:processing:update goes to the author only
It targets user:<authorId> and nothing else, because a post is withheld from every other viewer until its media is ready — nobody else is looking at a processing placeholder. media carries its variants, and postStatus is the post's status recomputed across all its media, so a client can tell "this attachment is done" from "the whole post is now published". See Media Pipeline.
Post engagement
Comments, replies, and reactions update live for everyone viewing a post: in a feed, on its detail screen, or in its comment sheet.
- Joining.
post:joinis authorized byGET /post/:postId/authzon the API, which applies the same rule asGET /posts/:id: the post exists, is published (or is yours), and there is no block between you and its author. On mobile,usePostSubscription(postId, enabled)wrapsRealtimeClient.subscribePost(). That is ref-counted, because one post can be in the feed, the detail view, and the sheet at once, and it is replayed on reconnect.PostItemsubscribes while it is viewable in the home feed, and while mounted elsewhere. - Emitting.
apps/api/src/services/postRealtime.tsfires after every comment create or delete and every reaction change. It is fire-and-forget, so a realtime outage never fails the write. - Absolute counts. Every payload carries counts read after the write, never deltas, so a client that receives an event twice or out of order still ends up correct. Clients apply them as-is. The one delta is the author's own optimistic
+1, which the event then overwrites. - Blocks. The API sends
excludeUserIds(everyone on either side of a block with the actor), andesuemits with.except(user:<id>)for each one, so blocked users never see the actor's activity live.commentCountin the event is the raw total. A viewer with blocks may see it differ from the list they can open until their next fetch, which subtracts hidden comments. - Your other devices.
post:reactionandpost:comment:reactioninclude the actor's resulting state (actorReaction/actorLiked), so the actor's other devices update their own highlight too. Everyone else's highlight is left alone.
useRealtimePostEvents (mounted once in app/(app)/_layout.tsx) writes every event into the React Query cache through lib/post-cache.ts. patchPostEverywhere covers the home feed, profile feeds, discover, trending clips, and the post detail entry. insertComment dedupes by id, because the author receives their own comment both as the HTTP response and as the event.
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.
