socket.io-client-utils
v0.1.0
Published
Typed socket.io-client wrapper with emit gating, re-emit on reconnect, and a drop-in mock adapter.
Maintainers
Readme
socket.io-client-utils
A thin, typed wrapper around socket.io-client that removes the boilerplate every
consumer app re-writes: emitting before the socket is up, replaying subscriptions after a
reconnect, and testing socket-driven code without a server.
- Connection gating —
emit()before connect is queued and flushed, in order, on connect. - Re-emit on reconnect — subscription-style emits replay automatically, so server-side subscription state is restored without you tracking it.
- Testability —
MockSocketAdapteris a drop-inSocketAdapterwith no server and no timers.
Reconnection itself is not reimplemented here. socket.io-client's manager owns backoff,
retry counts and jitter; this library configures it and stays out of the way.
Install
npm install socket.io-client-utils socket.io-clientsocket.io-client (^4.8.0) is a peer dependency — you control the version and only
one copy is bundled. The package ships unbundled ESM plus .d.ts ("type": "module", no
CJS build), so your bundler tree-shakes it.
Quick start
import { SocketIOAdapter } from "socket.io-client-utils";
const socket = new SocketIOAdapter<ChatServerEvents, ChatClientEvents>(
"https://api.example.com",
{ auth: { token }, path: "/socket" },
);
// Registered synchronously — no events are missed while the socket is still connecting.
const unsubscribe = socket.on("message", (message) => render(message));
// Queued now, sent on connect. Replayed after every reconnect.
socket.emit("join", { conversationId }, { reEmitOnReconnect: true });
// Later
socket.removeSavedEmit("join", { conversationId });
socket.emit("leave", { conversationId });
unsubscribe();
socket.dispose();Typing your events
You supply two event maps: S for server→client, C for client→server. Keys are event
names, values are the payload type. Both are plain interfaces — no enums ship in this
package.
interface ChatServerEvents {
message: { id: string; body: string };
typing: { userId: string };
"conversation.updated": { conversationId: string };
}
interface ChatClientEvents {
join: { conversationId: string };
leave: { conversationId: string };
postMessage: { conversationId: string; body: string };
}
const socket = new SocketIOAdapter<ChatServerEvents, ChatClientEvents>(url, { auth });
socket.on("message", (m) => m.body); // m is { id: string; body: string }
socket.on("nope", () => {}); // ✗ compile error — not in ChatServerEvents
socket.emit("join", { wrong: 1 }); // ✗ compile error — payload mismatchType against the SocketAdapter interface, not the class, so the mock can be substituted:
import type { SocketAdapter } from "socket.io-client-utils";
function subscribe(socket: SocketAdapter<ChatServerEvents, ChatClientEvents>) { /* … */ }There is no factory function — construct SocketIOAdapter directly.
API
new SocketIOAdapter<S, C>(url, options?)
| Option | Default | |
| --- | --- | --- |
| auth | — | Static auth object passed to io(). See Auth. |
| path | "/socket.io" | socket.io's own default; the library adds no opinion. |
| transports | ["websocket", "polling"] | |
| autoConnect | true | false starts in state idle until you call connect(). |
| maxPendingEmits | 1000 | Bounded pending queue; the oldest is dropped on overflow. |
| defaultAckTimeoutMs | 10_000 | Used by emitWithAck when the call site gives no timeout. |
| logger | no-op | The library never writes to console. |
| onError | — | Called with transport errors (connect_error). |
| socketOptions | — | Forwarded verbatim to io(). The named options above win. |
All reconnection options (reconnection, reconnectionAttempts, reconnectionDelay,
reconnectionDelayMax, randomizationFactor) pass through socketOptions.
Methods
on(event, handler): Unsubscribe // registered against the socket synchronously
once(event, handler): Unsubscribe
off(event, handler?): void // no handler → removes all handlers for that event
emit(event, payload?, options?): void // fire-and-forget
emitWithAck(event, payload?, { timeoutMs? }): Promise<unknown>
removeSavedEmit(event, payload?): void
clearSavedEmits(): void
connected: boolean
state: ConnectionState
onConnectionStateChange(handler): Unsubscribe
connect(): void
disconnect(): void
dispose(): voidEmitOptions is { reEmitOnReconnect?, callback?, ackTimeoutMs? }.
Every method is safe to call before connect and after dispose(). Post-dispose calls are
no-ops that log a warning and never throw; emitWithAck returns a rejected promise rather
than one that never settles.
A handler that throws is caught and routed to logger.error — it never prevents the other
handlers for that event from running.
Connection state
idle → connecting → connected → reconnecting → connected → …
↘ disconnected (disconnect(), server close, or manager gave up)
→ disposedconnect_error is surfaced as a state change plus the onError hook — never swallowed.
When the manager exhausts reconnectionAttempts the state becomes disconnected and an
error is logged; recovery is your decision, via an explicit connect().
Emit gating and saved emits
Gating. While disconnected, emit() goes into a bounded FIFO and is flushed in call
order once connected. When already connected it dispatches synchronously — no microtask
deferral, so emit ordering relative to your own code needs no reasoning about the
microtask queue. On overflow the oldest entry is dropped and a warning is logged.
Saved emits. { reEmitOnReconnect: true } records { event, payload } in a registry
at call time and replays it on every connect, including the first, in insertion order,
before the pending queue is flushed. Two consequences worth knowing:
- A saved emit is never also pending-queued. That is what makes
removeSavedEmit()before the first connect actually prevent the send. - Replay drives off a single source (
connect), so it fires exactly once per reconnect.
The registry is deduplicated using a stable, key-order-independent structural comparison,
so { a: 1, b: 2 } and { b: 2, a: 1 } are the same entry — and cyclic payloads do not
throw.
removeSavedEmit() only stops future replay. It sends no unsubscribe message to the
server; sending that is yours to do:
socket.removeSavedEmit("join", { conversationId });
socket.emit("leave", { conversationId });disconnect() is distinguishable from a transport drop: it does not clear saved emits, so
a later connect() replays them. dispose() does clear them.
Acks
// Fire-and-forget with a callback: your callback gets the server's args verbatim.
socket.emit("postMessage", body, { callback: (ack) => { … } });
// With a timeout, socket.io's shape applies: (err | null, ...args).
socket.emit("postMessage", body, { ackTimeoutMs: 2000, callback: (err, ack) => { … } });
// Promise form. Rejects with AckTimeoutError.
try {
const ack = await socket.emitWithAck("postMessage", body, { timeoutMs: 2000 });
} catch (err) {
if (err instanceof AckTimeoutError) retry();
}emitWithAck resolves with the single ack argument, or with an array when the server acks
with more than one. The ack clock starts when the emit reaches the socket, not when
you call it, so an emit queued while offline does not time out waiting for the connection.
emitWithAck takes no reEmitOnReconnect — an ack-bearing emit is a request/response,
not a subscription, and the types make that unrepresentable. Replays never carry the
original ack callback.
Testing with the mock
MockSocketAdapter implements the same interface with the same type parameters and the
same gating and saved-emit semantics — emits issued while disconnected only appear in
emittedEvents after simulateConnect(), and removeSavedEmit really removes.
import { MockSocketAdapter } from "socket.io-client-utils";
const socket = new MockSocketAdapter<ChatServerEvents, ChatClientEvents>();
socket.simulateConnect();
subscribeToConversation(socket, "c1");
expect(socket.emittedEvents).toEqual([
{ event: "join", payload: { conversationId: "c1" }, options: { reEmitOnReconnect: true } },
]);
// Prove the subscription is actually restored after a reconnect.
socket.simulateReconnect();
expect(socket.emittedEvents.filter((e) => e.event === "join")).toHaveLength(2);
// Drive server → client.
socket.simulateEvent("message", { id: "m1", body: "hi" });| Member | |
| --- | --- |
| emittedEvents | Everything that has gone out, replays included, in order. |
| savedEmits | The replay registry. |
| pendingEmits | How many emits are still waiting for a connection. |
| simulateEvent(event, payload) | Dispatch a server→client event. |
| simulateConnect() | Replays saved emits, then flushes the queue. |
| simulateDisconnect() / simulateReconnect() | |
| simulateError(err) | Routes to onError and the logger. |
| respondToAck(event, ...response) | Answers the most recent unanswered ack. |
| failAck(event, err?) | Fails it instead; defaults to AckTimeoutError. |
| reset() | Back to constructed state, listeners included. |
The mock runs no timers, so acks never resolve on their own — drive them with
respondToAck / failAck.
Migrating from the SocketAdapter.ts prototype
| Prototype | Now |
| --- | --- |
| new SocketIOAdapter(url, auth) | new SocketIOAdapter<S, C>(url, { auth }) |
| ServerToClientEvents / ClientToServerEvents enums | Your own event-map interfaces as type parameters — no domain enums ship here |
| on() returns void | Returns an Unsubscribe |
| handler: Function, handler as any | Fully typed; no any in the public surface |
| Listeners deferred behind connectionPromise.then(...) | Registered synchronously, so nothing is missed on the connect tick |
| Emits chained onto a promise replaced on every connect_error | Explicit bounded queue plus a state flag; emits are not orphaned |
| connect_error handler calling socket.connect() itself | Reconnection delegated entirely to socket.io-client's manager |
| Replay driven by both connect and io.on("reconnect") | A single source, so replay fires once per reconnect |
| JSON.stringify payload comparison | Key-order-independent, cycle-safe structural comparison |
| console.log("[Socket error]", …) | options.logger (no-op by default) and options.onError |
| Mock's removeSavedEmit an empty stub | Really removes; the mock mirrors the real gating and replay rules |
| Hardcoded path: "/socket", websocket-only | Caller-controlled; defaults match socket.io-client |
Rooms, namespaces and dynamic auth are unchanged in spirit: use one adapter instance per
namespace, and rotate tokens by mutating socket.socket.auth on the underlying socket or
by disposing and reconstructing the adapter.
Auth
Auth is a static object passed at construction. There is no dynamic auth callback in v1.
Scripts
yarn build # tsc → dist/ (ESM + .d.ts, one file per source file, no bundler)
yarn typecheck # tsc --noEmit over src and test
yarn test # vitest
yarn test:coverageLicense
MIT
