@amatiasq/socket
v2.0.0
Published
WebSockets in three layers, on both ends of the wire. Pick the one you need and ignore the rest.
Readme
@amatiasq/socket
WebSockets in three layers, on both ends of the wire. Pick the one you need and ignore the rest.
Install with npm i --save @amatiasq/socket.
| | client (browser) | server | one connection |
| ------------------------------- | ----------------- | ---------------- | ------------------- |
| transport, reconnection, queue | ResilientSocket | — | — |
| typed JSON | JsonSocket | JsonServer | JsonConnection |
| typed messages, session identity| SessionSocket | SessionServer | SessionConnection |
Client-side names come from @amatiasq/socket, server-side from
@amatiasq/socket/server. The split is deliberate: importing the client surface
never pulls the server code into your bundle.
This package has one dependency (@amatiasq/emitter) and no WebSocket
implementation. The client uses the platform's global WebSocket; the server
takes sockets you already have. That is what lets it run on Node, Bun, Deno and
Cloudflare Workers without a per-runtime build.
ResilientSocket — reconnection and a send queue
import { ResilientSocket } from '@amatiasq/socket';
const socket = new ResilientSocket('wss://sockethost.com');
socket.onOpen(() => console.log('Socket open'));
socket.onClose(() => console.log('Socket closed'));
socket.onMessage(event => console.log(event.data));
// Fires when it gives up, after maxReconnectAttempts
socket.onError(() => console.log('Reconnection failed'));
socket.onReconnect(event =>
console.log(`Disconnected for ${Date.now() - +event.disconnectedTime}ms`),
);
// Sent now if connected, queued and flushed on reconnect if not
socket.send('hello');JsonSocket — typed JSON
import { JsonSocket } from '@amatiasq/socket';
interface Incoming {
foo: number;
}
interface Outgoing {
bar: string;
}
const socket = new JsonSocket<Incoming, Outgoing>('wss://sockethost.com');
socket.onMessage(message => console.log(message.foo));
socket.send({ bar: 'test' }); // a wrong shape is a compile errorA frame that is not valid JSON is dropped with a console.warn — a peer sending
garbage does not take the listener down.
JsonServer — the same, server-side
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { JsonServer, fromEventEmitter } from '@amatiasq/socket/server';
const server = new JsonServer<Incoming, Outgoing>();
server.onConnection(client => {
client.onClose(() => logout(client));
client.onMessage(data => console.log(data.foo));
client.send({ bar: 'hello' });
});
// You own the listening socket. `ws` here, but see "Runtimes" below.
const wss = new WebSocketServer({ server: createServer() });
wss.on('connection', ws => server.accept(fromEventEmitter(ws)));The session layer
SessionSocket / SessionServer add an identity that outlives the socket. When
a client reconnects it re-announces its ClientId, and the server rebinds it to
the same SessionConnection — every listener registered on it stays attached,
and onConnection does not fire again.
import { SessionServer, fromEventEmitter } from '@amatiasq/socket/server';
type ServerMessage = { type: 'tick'; data: number };
type ClientMessage = { type: 'move'; data: string };
const server = new SessionServer<ServerMessage, ClientMessage>();
server.onConnection(client => {
client.onMessageType('move', where => console.log(client.id, where));
client.send('tick', Date.now());
});
wss.on('connection', ws => server.accept(fromEventEmitter(ws)));Session ids are bearer tokens
A ClientId is a v4 UUID from the platform CSPRNG — 122 random bits — issued by
the server and never taken from the wire. A reconnect naming an id the server does
not know gets a fresh session rather than adopting that id.
Treat it as you would a session cookie: whoever presents the id is that client. There is nothing else distinguishing a reconnecting client from someone who obtained its id, so do not log it, do not put it in a URL, and do not persist it anywhere a third party can read.
(Before 2.0.0 these were 1, 2, 3… and any client naming another's id was
handed its session. If you are on 1.x, upgrade.)
Runtimes
The servers never create a socket, so they work anywhere. Two adapters cover the two socket shapes that exist:
import { fromEventTarget, fromEventEmitter } from '@amatiasq/socket/server';
fromEventEmitter(ws); // `ws` on Node — .on() / .off()
fromEventTarget(socket); // Deno, Cloudflare Workers, browsers — addEventListenerBun's ServerWebSocket declares its handlers on the server rather than per
socket, so build the transport by hand — it is four methods:
Bun.serve({
websocket: {
open(ws) {
server.accept({
send: data => ws.send(data),
close: () => ws.close(),
onMessage: listener => ((ws.data.onMessage = listener), () => {}),
onClose: listener => ((ws.data.onClose = listener), () => {}),
});
},
message: (ws, data) => ws.data.onMessage?.(String(data)),
close: ws => ws.data.onClose?.(),
},
});Reconnection, in detail
A dropped socket is retried after reconnectionDelay (100ms by default), then
200ms, then 400ms… After maxReconnectAttempts (14) it stops and fires
onError. Both are per-instance options:
new ResilientSocket(uri, { reconnectionDelay: 500, maxReconnectAttempts: 3 });Anything sent while disconnected is queued and flushed, in order, once the connection is back.
History
This package is the merge of four: @amatiasq/socket, @amatiasq/json-socket,
@amatiasq/nice-socket and @amatiasq/resilient-socket. The three absorbed
names are deprecated on npm and will not get further versions.
Renames, if you are coming from one of them:
| before | now |
| ------------------------------- | ------------------- |
| ClientSocket | SessionSocket |
| WebSocketServer | SessionServer |
| ServerSocket | SessionConnection |
| NiceSocketServer | JsonServer |
| NiceSocket | JsonConnection |
| sendJson() / onJsonMessage()| send() / onMessage() |
JsonSocket used to carry its own copy of the reconnection logic and no send
queue; it is now a layer over ResilientSocket, so it gained the queue. Its
onMessage/onOpen/onReconnect are subscribe functions now, matching the rest
of the package — call them, do not reach for .subscribe.
The server classes no longer take an http.Server. See "Runtimes".
