Skip to content

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 ​

ts
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 ​

RoomJoinedCarries
user:<userId>Automatically on connectNotifications, media updates
dm:<conversationId>Explicitly, after authorizationDM traffic
room:<roomId>Explicitly, after authorizationChannel room traffic
channel:<channelId>Channel membershipChannel lifecycle
presence:<userId>presence:subscribe, after authorizationThat user's presence:update
post:<postId>post:join, after authorizationThat post's comments, replies and reactions

Client → server ​

DeclaredImplementedPayloadAck
presence:pingping ⚠️{ ts }{ ts }
presence:state✔{ active }optional
presence:subscribe✔{ userIds }{ ok, statuses: Record<userId, "online" | "offline"> }
presence:unsubscribe✔{ userIds }optional
dm:joindm:join{ conversationId }Ack
dm:leavedm:leave{ conversationId }Ack
dm:typing:startdm:typing:start{ conversationId }optional
dm:typing:stopdm:typing:stop{ conversationId }optional
room:joinroom:join{ roomId }Ack
room:leaveroom:leave{ roomId }Ack
room:typing:startroom:typing:start{ roomId }optional
room:typing:stoproom:typing:stop{ roomId }optional
post:join✔{ postId }Ack
post:leave✔{ postId }Ack

Ack shape ​

ts
type Ack = (res: { ok: true } | { ok: false; error: string; code?: string }) => void;

Joins are authorized through the API, so they can be refused:

ts
client.joinDm(conversationId);
// → { ok: false, error: "Cannot join conversation (403)", code: "AUTHZ_DENIED" }

Server → client ​

DeclaredImplementedPayload
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:typingroom: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:

ts
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 routeBodyEmits
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/newevent payload + excludeUserIdspost:comment:new
POST /admin/emit/post/comment/deletedevent payload + excludeUserIdspost:comment:deleted
POST /admin/emit/post/reactionevent payload + excludeUserIdspost:reaction
POST /admin/emit/post/comment/reactionevent payload + excludeUserIdspost: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:join is authorized by GET /post/:postId/authz on the API, which applies the same rule as GET /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) wraps RealtimeClient.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. PostItem subscribes while it is viewable in the home feed, and while mounted elsewhere.
  • Emitting. apps/api/src/services/postRealtime.ts fires 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), and esu emits with .except(user:<id>) for each one, so blocked users never see the actor's activity live. commentCount in 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:reaction and post:comment:reaction include 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 ​

ts
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 ​

  1. Declare it in packages/types/src/socket-events.ts.
  2. Handle or emit it in apps/esu/src/socket/handlers.ts.
  3. If the API triggers it, add an /admin/emit/* route in apps/esu/src/interservice/adminRoutes.ts and call it from the API.
  4. Add a helper to RealtimeClient if clients will emit it.
  5. 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:

  1. Update packages/types to match what esu actually emits (ping, lastSeen, room:typing, lastReadMessageId), correcting messagedId and memeber in the process.
  2. Type registerSocketHandlers against Server<ClientToServerEvents, ServerToClientEvents> so the compiler catches the next divergence.
  3. Rename toward the intended names later, as a coordinated client-and-server change.

Doing step 2 first is what stops this from recurring.

Internal documentation — PlayPalz platform