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

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

Client → server

DeclaredImplementedPayloadAck
presence:pingping ⚠️{ ts }{ ts }
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

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:updatepresence: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: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 }

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 }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

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