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

@desolint/socket-server

v0.0.1

Published

Socket.IO server wiring for Desol Int. projects

Readme

@desolint/socket-server

Socket.IO server wiring. Attaches to an HTTP server you already created — it never calls createServer or listen.

Requirements

  • socket.io 4.8 or newer (peer dependency)
  • npm 7 or newer — npm 7+ installs peer dependencies automatically

Install

npm install @desolint/socket-server

socket.io is a peer dependency and npm installs it for you on npm 7+. @desolint/socket-shared is a pinned regular dependency and comes down automatically — you never install it yourself.

yarn add @desolint/socket-server socket.io
# or
pnpm add @desolint/socket-server socket.io

Why socket.io is a peer dependency, not a regular one

socket.io owns the HTTP server attachment and the connected-socket registry. Two copies means two registries: sockets accepted by one would be invisible to the other, so emits would silently reach nobody. Your application also needs to attach Socket.IO to its HTTP server, which only works if both sides use the same copy.

Declaring it as a peer means npm reuses the copy your application already has, and you keep control of the version.

Quick start

@desolint/socket-shared is a pinned dependency of this package, not a peer — you never install it, and its types are re-exported here, so @desolint/socket-server is the only import specifier you need.

1. Declare the contract

All three type arguments are yours, including what lands on socket.data. Keep a matching copy in the frontend repo.

// types/socketContract.ts
import type {SocketContract} from '@desolint/socket-server';

export interface AppSocketData {
  userId: string;
  organizationId?: string;
}

export type AppContract = SocketContract<
  Record<string, never>, // clientToServer
  {message: (text: string) => void}, // serverToClient
  AppSocketData // socket.data
>;

2. Attach to your server

There is no preset — auth, socket.data and rooms are project decisions, so they live in the project.

import * as http from 'node:http';

import {
  attach,
  authenticateFromCookie,
  joinRoom,
} from '@desolint/socket-server';

export const createIo = ({server}: {server: http.Server}) =>
  attach<AppContract>({
    server,
    logger,
    // NOTE: cors does NOT restrict who may open a socket. See "Design notes".
    options: {cors: corsOptions},

    authenticate: authenticateFromCookie<AppContract>({
      cookieName: getCookieTokenHeaderName(),
      decode: ({token}) => {
        const decoded = verifyToken({token}); // your own jwtUtils

        // Return false on anything unexpected — a truthy result is not
        // necessarily the shape you signed.
        if (!decoded || typeof decoded !== 'object' || !decoded.user)
          return false;

        return {userId: String(decoded.user._id)};
      },
    }),

    onConnection: ({socket}) => {
      // Socket.IO already put this socket in a room named after its own id.
      joinRoom({inRoom: socket.id, room: `user:${socket.data.userId}`});
    },
  });
// entrypoint
const server = http.createServer(app);
createIo({server});
server.listen(PORT);

attach() can be called once per process — a second call throws Socket instance already exists, and getSocket() throws if called before attach(). Both read a module-level singleton, which matters if anything in your stack could re-invoke the module in one process (some hot-reload setups).

The package never signs, verifies or inspects a token. authenticateFromCookie reads the named cookie and hands it to your decode, so your own JWT utilities stay the single owner of secret derivation and payload shape. A missing cookie short-circuits without calling decode at all; both that and a decode returning false reject with AUTH_FAILED_MESSAGE.

false is the failure sentinel and the only value meaning "refused", so it is the one thing your data cannot be. The check is === false, not truthiness — if your data is a primitive, 0 and '' are real payloads and log in normally. null and undefined still reject.

Rooms

There is no auto-join and no default room. Which rooms exist is a product decision, so every join — including the first — is an explicit joinRoom call.

The first join belongs in onConnection, targeting socket.id (Socket.IO puts every socket in a room named after its own id before onConnection runs). Every later membership change uses the same joinRoom / leaveRoom / disconnectRoom, called from wherever that change already happens — typically the HTTP endpoint or service function that owns it:

// controllers/groupsController.ts
await GroupServices.addMember({groupId, userId});
joinRoom({inRoom: userRoom({userId}), room: groupRoom({groupId})});

// switched active organization — stop the previous tenant's broadcasts
leaveRoom({
  inRoom: userRoom({userId}),
  room: orgRoom({organizationId: previous}),
});

// session revoked — drops the sockets; a reconnect re-runs `authenticate`
disconnectRoom({room: userRoom({userId})});

Skipping a membership change is a correctness bug, not a convenience gap: the socket keeps receiving a room's traffic after it should have stopped, and a browser can hold a connection open for days. Omitting the first join entirely means the socket joins nothing, so emitToRoom silently reaches nobody.

Keep room names in one module and build both the join and the emit from it — a joiner and an emitter disagreeing about a string fails silently. Prefixes also keep your names clear of Socket.IO's own per-socket rooms:

// rooms.ts
export const userRoom = ({userId}: {userId: string}) => `user:${userId}`;
export const orgRoom = ({organizationId}: {organizationId: string}) =>
  `org:${organizationId}`;
export const groupRoom = ({groupId}: {groupId: string}) => `group:${groupId}`;

userRoom, orgRoom and groupRoom are helpers you write, not exports of this package.

Choosing an audience

Not every audience needs a room. A room is a cache of membership: worth it when membership is stable and the audience large, a liability when it is dynamic — because then you own the join/leave choreography.

| | Room, joined via joinRoom | Emit-time fan-out | | --------------------------------------- | --------------------------- | ----------------------------- | | Membership derivable from socket.data | required | not required | | Membership stable for the connection | required | not required | | Cost | one join per connect | one lookup per emit | | Fits | one room per user, per org | groups, channels, ad-hoc sets |

// Stable and large — give it a room.
emitToRoom({room: orgRoom({organizationId}), event: 'announcement', data: [x]});

// 1:1 — no conversation room. Socket.IO dedupes the union, so the sender's
// own tabs stay in sync too.
emitToRoom({
  room: [userRoom({userId: a}), userRoom({userId: b})],
  event: 'msg',
  data: [x],
});

// A group is the SAME operation — fan out over member user rooms, so adding a
// member takes effect on the next emit with no socket surgery.
const ids = await GroupServices.memberIds({groupId});
emitToRoom({
  room: ids.map((userId) => userRoom({userId})),
  event: 'msg',
  data: [x],
});

Once you fan out over user rooms, which organization each person belongs to stops being a transport concern and becomes an authorization question for your service layer, not a room name.

Emitting

Event name and payload are both checked against the contract. data is the full argument tuple, so multi-argument events aren't limited to one value:

import {emitToRoom, emitToAll} from '@desolint/socket-server';

emitToRoom<AppContract, 'message'>({
  room: orgId,
  event: 'message',
  data: ['hi'],
});
emitToAll<AppContract, 'message'>({event: 'message', data: ['hi']});

These read a module-level singleton, so they work from controllers, workers and cron jobs without threading io through every signature. To reach one socket, emit to the room named after its id — socket.io puts every socket in one: emitToRoom({room: socketId, ...}).

Acknowledgements

A client→server event can get a typed response back — this is socket.io's own mechanism, not a package API. Declare the event's last parameter as a callback in the contract, and both sides are typed from it automatically:

export type AppContract = SocketContract<
  {
    'chat:send': (
      payload: {recipientId: string; text: string},
      ack: (
        result: {ok: true; messageId: string} | {ok: false; reason: string}
      ) => void
    ) => void;
  },
  {message: (text: string) => void},
  AppSocketData
>;
onConnection: ({socket}) => {
  socket.on('chat:send', async (payload, ack) => {
    try {
      const messageId = await ChatServices.send(payload);
      ack({ok: true, messageId});
    } catch {
      ack({ok: false, reason: 'Could not send message'});
    }
  });
},

ClientEvent<T>/ClientPayload<T, E> (re-exported from @desolint/socket-shared, mirroring ServerEvent/ServerPayload) type a handler declared outside onConnection instead of inline:

const handleChatSend = async (
  ...[payload, ack]: ClientPayload<AppContract, 'chat:send'>
) => {
  /* same body as above */
};

Design notes

cors does not gate the WebSocket handshake

This is a real security footgun, and it matters most when auth is a cookie.

options.cors only sets CORS response headers. Browsers do not apply the same-origin policy to a WebSocket upgrade, and a client can skip HTTP long-polling entirely with transports: ['websocket'] — so a page on an origin your corsOptions does not list can still complete the handshake. Engine.IO does run the cors middleware on the upgrade request, but that middleware only chooses response headers: it calls next() either way and never rejects.

Because a cookie travels automatically, the browser attaches it to that cross-origin handshake, authenticateFromCookie reads it, your decode verifies it — and the socket is authenticated as the genuine user, from a page you do not control.

allowRequest is the option that actually rejects, and it runs on both transports:

const allowedOrigins = ['https://app.example.com']; // build corsOptions from this too

attach<AppContract>({
  server,
  options: {
    cors: corsOptions,
    allowRequest: (req, callback) => {
      const {origin} = req.headers;

      // A missing Origin means a non-browser client (curl, a mobile app, a
      // server-to-server worker). Allowing it is a deliberate choice: those
      // clients can spoof any Origin anyway, so this header only ever protects
      // against *browsers* being used as the attacker's proxy. Reject it if
      // every client of yours is a browser.
      const allowed = !origin || allowedOrigins.includes(origin);

      callback(allowed ? null : 'origin not allowed', allowed);
    },
  },
});

allowRequest runs before the authenticate middleware, so a rejected origin never reaches your decode. Its failure is deliberately not AUTH_FAILED_MESSAGE, so the client keeps retrying — a blocked origin is a misconfiguration or an attack, and neither should look like a bad credential.

Whether you need SameSite=None depends on site, not origin. https://app.example.com and https://api.example.com are different origins but the same site, so the default SameSite=Lax already sends the cookie. SameSite=None is only for a genuinely cross-site deployment, and browsers reject it without Secure.

More than one server process

Every room operation is process-local by default. Run two instances behind a load balancer and an org broadcast reaches only the fraction connected to that box; a DM arrives only if both parties land on the same instance. There is no error or log — it just under-delivers, which makes it invisible in single-instance dev and staging.

Pass a multi-process adapter as soon as there is a second instance:

import {createAdapter} from '@socket.io/redis-adapter';

attach<AppContract>({
  server,
  options: {cors: corsOptions, adapter: createAdapter(pubClient, subClient)},
});

When the credential isn't a cookie

authenticateFromCookie is a convenience, not a requirement. attach takes an authenticate callback directly — return whatever should go on socket.data, or throw to refuse:

import {attach, AUTH_FAILED_MESSAGE} from '@desolint/socket-server';

attach<MyContract>({
  server,
  authenticate: async ({handshake}) => {
    const client = await lookUpClient({key: handshake.auth?.apiKey});

    // Throw AUTH_FAILED_MESSAGE for a bad credential so the client stops
    // retrying; any other error means "retry", right for a transient failure.
    if (!client) throw new Error(AUTH_FAILED_MESSAGE);

    return {clientId: client.id};
  },
});

API

| Export | Notes | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | attach(opts) | The entry point. Options:server, options, authenticate, onConnection, logger (optional, {info, error} — satisfied by winston; today only error is called, when authenticate throws). | | authenticateFromCookie({cookieName, decode}) | Builds anauthenticate callback for the cookie case: reads the cookie, calls your decode({token}), rejects with AUTH_FAILED_MESSAGE if there's no cookie or decode returns false (or nullish). Never sees the token's contents. | | emitToRoom / emitToAll | Typed emit helpers. | | joinRoom / leaveRoom ({inRoom, room}) | The only join/leave mechanism there is — used for the first join inonConnection (target socket.id) and for every membership change after it. | | disconnectRoom({room}) | Force-disconnect a room's sockets; a reconnect re-runsauthenticate. | | getSocket() | Singleton access, for code that needsio itself. | | resetSocket() | Test-only; clears the singleton so a suite can re-init. |

There is deliberately no attachDesolSocket. An earlier version shipped one that fixed the JWT payload shape, socket.data and the room topology; see the root README for why all three moved back into the project.

Deliberately not exported: setSocket (attach is the only legitimate initialiser), and parseCookies / getCookie (an implementation detail of authenticateFromCookie). There is no disconnectleave handler because Socket.IO clears a socket's rooms on disconnect automatically.

Development

npm install        # install dependencies (from the repo root)
npm run build      # build all three packages
npm test           # type-check + jest
npm run lint       # eslint

This package is part of the package-socket-io workspaces monorepo — run the commands from the repository root, not this directory.


License

MIT © Desolint — see LICENSE.

Free to use, modify and redistribute, commercially or otherwise. Provided "as is", without warranty or liability of any kind.