Skip to content

@playpals/socket-client ​

packages/socket-client

A typed Socket.IO client plus the React hook the mobile app uses to reach it. Two files: RealtimeClient and useRealtime.

RealtimeClient ​

A thin wrapper over socket.io-client, typed against @playpals/types:

ts
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;

Connection ​

ts
this.socket = io(this.url, {
  transports: ["websocket"],
  auth: { token },
  reconnection: true,
  reconnectionDelay: 200,
  timeout: 8000,
});

The token travels in the handshake, so it is held on the client and changed through setToken:

ts
await client.setToken(token);   // stores the token, then connects

setToken ignores falsy tokens — consumers mount with token ?? "" while auth is still hydrating, and one of them must not be able to tear down a connection another has established. A token that differs from the current one disconnects first, because re-authenticating requires a new socket.

Concurrent connect() calls are de-duplicated with a stored promise, and an existing socket is never replaced by a second one (socket.io is already retrying it on its own):

ts
async connect() {
  if (this.connectingPromise) return this.connectingPromise;
  if (this.socket) return;
  if (!this.token) { console.warn("[esu-realtime] No token available, skipping connection"); return; }
  this.connectingPromise = this.doConnect().finally(() => { this.connectingPromise = null; });
  return this.connectingPromise;
}

Note the client requests transports: ["websocket"] only, while the server allows both websocket and polling. That is the right choice for a mobile client — it skips the polling handshake — but it means the client will not fall back if a network blocks WebSocket upgrades.

Typed listeners with cleanup ​

ts
on<E extends keyof ServerToClientEvents>(event: E, fn: ServerToClientEvents[E]) {
  this.socket?.on(event, fn as any);
  return () => this.socket?.off(event, fn as any);
}

Returning the unsubscribe function makes it drop straight into a useEffect:

ts
useEffect(() => client.on("dm:message:new", handleMessage), [client]);

Handlers are also kept in a map on the client and re-attached to every socket it opens, so a listener registered before the socket exists — or one that outlives a re-authentication — keeps firing.

Helpers ​

Each wraps an emit with an ack into a promise:

DMRoom
joinDm(conversationId)joinRoom(roomId)
leaveDm(conversationId)leaveRoom(roomId)
startDmTyping(conversationId)startRoomTyping(roomId)
stopDmTyping(conversationId)stopRoomTyping(roomId)

emitWithAck resolves { ok: false, error: "Socket not connected" } rather than rejecting when there is no socket, so callers never need a try/catch around a join.

subscribePost(postId) ​

Joins the post:<postId> room for live comments and reactions and returns a release function. Unlike the DM and room helpers it is ref-counted: one post can be held by a feed row, its detail screen, and the comment sheet at once, and the room is left only when the last holder releases it. It is safe to call before the socket connects, because every held post is joined on connect and replayed on reconnect. A denied join (the post was deleted, or a block) is dropped from the replay set. See Post engagement.

waitForConnection(timeoutMs = 5000) ​

Polls isConnected until it is true or the timeout elapses. Useful when a screen mounts before the connection is established. It polls rather than waiting on a single connect event so that it stays correct when the socket is replaced mid-wait, which is what a token arriving or changing does.

useRealtime ​

ts
const client = useRealtime({ url, token });

The hook keeps a module-level map of clients keyed by URL, with reference counting:

ts
const clients = new Map<string, ClientEntry>();   // { client, refCount }

Mounting a component increments the count; unmounting decrements it, and the socket is torn down only when the last consumer goes away. So ten screens calling useRealtime share one socket, and navigating between them never causes a disconnect/reconnect cycle.

Connecting is driven by the token, not by mount. A separate effect keyed on [client, token] calls setToken, so a consumer that mounts before auth has hydrated still connects once the token arrives, and a token that changes re-authenticates rather than leaving a stale socket open. Passing a falsy token simply does not connect yet.

Usage in the app ​

ts
const { token } = useAuth();
const client = useRealtime({ url: Env.realtimeUrl, token });

useEffect(() => {
  if (!token || !conversationId) return;
  client.joinDm(conversationId);
  const off = client.on("dm:message:new", ({ message }) => { /* … */ });
  return () => { off(); client.leaveDm(conversationId); };
}, [client, conversationId]);

Gotchas ​

  • The client is keyed by URL only. Re-authenticating is handled by setToken (a different token forces a new socket), and logout unmounts the consumers, which drops the refcount to zero and disconnects. A shared client is still shared, though: every consumer of a URL must pass the same token.
  • The token must reach useAuth().token. It is AuthProvider state, not just storage — see setSession in AuthContext. A login path that only calls setUser leaves the socket unauthenticated for the whole session ([esu-realtime] No token available, skipping connection).
  • Connection errors reject connect(). useRealtime catches and logs them, so a failed connection is silent to the UI. Surface it if a screen depends on realtime.
  • Typed against a contract that has drifted. See Socket Events for the events where the server's wire format differs from @playpals/types.

Internal documentation — PlayPalz platform