@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.io4.8 or newer (peer dependency)- npm 7 or newer — npm 7+ installs peer dependencies automatically
Install
npm install @desolint/socket-serversocket.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.ioWhy 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 disconnect → leave 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 # eslintThis 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.
