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.

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