@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:
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;Connection
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:
await client.setToken(token); // stores the token, then connectssetToken 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):
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
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:
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:
| DM | Room |
|---|---|
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
const client = useRealtime({ url, token });The hook keeps a module-level map of clients keyed by URL, with reference counting:
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
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 isAuthProviderstate, not just storage — seesetSessioninAuthContext. A login path that only callssetUserleaves the socket unauthenticated for the whole session ([esu-realtime] No token available, skipping connection). - Connection errors reject
connect().useRealtimecatches 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.
