npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 gatingemit() 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.
  • TestabilityMockSocketAdapter is a drop-in SocketAdapter with 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-client

socket.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 mismatch

Type 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(): void

EmitOptions 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)
                                                                         → disposed

connect_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:coverage

License

MIT