Status: pre-1.0. The delivery core is complete and checked against real socket.io by a dual-run conformance suite. The public API can still change before 1.0.0. See the roadmap to v1.0.0 and what a version number promises.
A test needs a socket, so it writes one.
const socket = {
handlers: {} as Record<string, (...args: unknown[]) => void>,
on(event: string, handler: (...args: unknown[]) => void) {
this.handlers[event] = handler;
},
emit(event: string, ...args: unknown[]) {
this.handlers[event]?.(...args);
},
};This carries a component through its first few tests. Then the feature it was written for arrives: someone else is in the room, and the test has to assert that they received the message and the sender did not.
There is nowhere for that assertion to go. The object above has one handlers map,
so emit can only reach the sender's own listener. Adding a second one does not
help either, because who receives an event is not a property of a socket. It is
decided by room membership, by namespace, and by which broadcast variant was used,
and none of those exist here. The mock stops at the point the feature starts.
Room membership and the broadcast rules are the thing being mocked, rather than something layered on afterwards.
io.on('connection', (socket) => {
socket.on('say', (room: string, text: string) => {
// Everyone in the room except the sender, which is what `socket.to` means.
socket.to(room).emit('said', text);
});
});npm install -D smocket smocket-client// chat.test.ts
import { connect } from 'smocket-client';
import { Server } from 'smocket';
import { afterEach, beforeEach, expect, test } from 'vitest';
const URL = 'http://localhost:3000';
let io: Server;
beforeEach(() => {
// The server your app talks to, wired exactly as in socket.io.
io = new Server(URL);
io.on('connection', (socket) => {
socket.on('join', async (room: string, ack: () => void) => {
await socket.join(room);
ack();
});
socket.on('say', (room: string, text: string) => {
socket.to(room).emit('said', text);
});
});
});
afterEach(async () => {
await io.close();
});
test('a broadcast reaches the other member of the room', async () => {
const alice = connect(URL);
const bob = connect(URL);
await new Promise<void>((done) => alice.emit('join', 'lobby', done));
await new Promise<void>((done) => bob.emit('join', 'lobby', done));
const heard = new Promise((resolve) => bob.on('said', resolve));
alice.emit('say', 'lobby', 'hello');
await expect(heard).resolves.toBe('hello');
});npx vitest runThe afterEach waits for close() to disconnect both clients and remove the
server from smocket's origin registry, so the
next test starts without this server or its room state.
That run is green. The file above is a Vitest test because the suite here uses
Vitest. Smocket has no runtime dependency on a test runner: runner packages appear
only in the development dependencies. This repository executes
Smocket with Vitest and node:test, plus the documented Vitest and Jest paths
from clean consumers. Candidate validation installs synchronized root and client
tarballs outside the checkout, while published validation installs an exact root
version. Neither path counts a workspace resolution as package validation.
connect(url) and io.on('connection') are socket.io-client's and socket.io's own
entry points, so the code above keeps the same split between client and server APIs
while changing only their package names. An application that keeps its original
client import can map socket.io-client to smocket-client instead.
Bob receives what Alice sent, and Alice does not, because socket.to excludes the
sender. The test above asserts only the first half, since proving a socket did not
receive something takes the marker pattern rather than a wait, and that is a
conformance case rather than a first example.
| Coming from | Start at |
|---|---|
| Vitest | the quick start above, which is a vitest file |
| Jest, or another CJS runner | the documented, executable Jest setup |
An app that imports socket.io-client |
test-runner integration, which swaps the specifier |
| A setup that fails before the first event | troubleshooting by actual signal |
| A hand-written socket mock | the problem |
| Wanting to read a program rather than a test | examples/chat-room |
| Wanting the exact guarantees | the conformance report |
An existing application does not have to be rewritten to run against smocket.
smocket-client preserves socket.io-client's ESM default, named io, named
connect, callable CommonJS root, and client Socket type for the supported
surface. A test runner pointed at that package resolves the app's own import and
the app's code runs unchanged. Load smocket and smocket-client through the same
module format so both use the same in-process registry.
Test-runner integration has the executable Vitest
and Jest setups.
Runnable programs live in examples/, outside the published package.
chat-room follows three participants through two rooms, a
moderated announcement, and a disconnect. From a clean checkout, pnpm install
then pnpm example:chat-room. CI runs it on every push, so it fails rather than
rots.
The chat room package consumer runs the same application after npm installs either the released package or a tarball built from a pull request. It is also available as a repository-backed StackBlitz project.
Against a hand-written mock. In the one-workflow case study, the handwritten mock passed the same workflow and assertions as real Socket.IO and published Smocket. It had no package dependency and needed no port; Smocket needed one package dependency and also needed no port, while real Socket.IO owned the local server and port setup. The dependency and port setup was therefore simpler for Smocket than for real Socket.IO, and simpler still for the handwritten target than for Smocket. The separate ownership tradeoff was that the application owned the handwritten mock's behavior implementation, while the Smocket package supplied that behavior behind a smaller fixture bootstrap.
It is reasonable to infer that a change to the exercised event or room semantics may require the application to maintain its handwritten implementation. The study did not observe such a future change, and its single workflow does not establish a universal productivity result or describe every handwritten mock.
Against HTTP mocking. A different layer, not a different tool for the same job. HTTP mocking answers what a request returns, at the transport. socket.io's delivery rules sit above the transport and answer which socket receives an event. A suite usually wants both, and they do not overlap, so smocket stays off the transport rather than reaching down into it. See decision 0009.
Every behaviour smocket claims was measured against a real socket.io server first and against smocket second, from the same test file, and is published only when both passed. The CI badge above covers both runs, so it goes red if either target does.
The conformance report is generated from that run. It lists every verified case linked to the test that pins it, the surface not yet measured, and where the two deliberately differ. Reading it is the fastest way to see how wide the reproduced surface is, from rooms and broadcast chaining through connection middleware, acknowledgement timeouts, volatile emits, catch-all listeners, socket.data, and disconnect.
Each row is answered by a CI job rather than by a claim. The jobs are in
ci.yml, and the same table with its reasoning is in
the report.
| Question | Answer | Job |
|---|---|---|
| Which Node runs the suite | 22 and 24 on Linux, current LTS on Windows and macOS | test |
| Which Node runs the published package | 20 and up, the floor engines.node declares |
declared node floor |
| Which socket.io the cases hold for | 4.7 and 4.8 | real target |
| Which browser the mock runs in | Chromium, mock target only | browser |
The package ships ESM and CJS builds with type declarations for both, verified on
every run by publint and arethetypeswrong.
These are not unbuilt features. A mock never opens a real connection, so there is nothing for them to act on, and implementing them would mean inventing behaviour with no source to check it against.
- Reconnection behaviour reproduction. There is no dropped connection to re-establish. A trigger that forces a disconnected state, so your own reconnect handlers can be exercised, is a separate planned feature.
- Transport fallback. There is no WebSocket or HTTP long-polling transport to fall back between.
- Heartbeat. There is no live connection to ping, so none can time out. The
disconnect a timeout would cause is still observable through
socket.disconnect(). - Multi-server scaling. One in-memory process has no second server for the Redis adapter to reach.
- Binary encoding. Nothing is serialised onto a wire, so there are no frames to encode.
The full boundary, with the layer split it follows, is in scope.md.
I already mock HTTP. Where does smocket fit?
HTTP mocking works at the transport, on requests and responses. socket.io's delivery rules sit above the transport, so getting them out of a transport-level tool would mean hand-assembling socket.io's wire protocol and then writing rooms, namespaces, and the broadcast variants on top of it. That is a separate job, and smocket does that one. See decision 0009.
Can I keep my HTTP mock and add smocket?
Yes, and that is the intended arrangement. HTTP stays with whatever already answers it, and sockets come here. Neither patches the other's surface, so a suite runs both.
Why is there no reconnection?
Reconnection is a retry over time after a real connection drops. A mock has no connection to drop and no later to wait for, so any delay it reported would be a number invented for the occasion. What tests actually want from reconnection is their own handlers running, and that is reachable by triggering the disconnected state directly. See scope.md.
Does testing without a real server drift from the backend contract?
That risk is what the dual run exists to answer, and it is answered for socket.io's half of the contract. Each case runs against a real socket.io server first, so what it asserts is socket.io's behaviour, and the same file then runs against smocket. A divergence fails CI. What smocket cannot check is your own server's handlers, which is the same thing any mock leaves to a contract or integration test.
What happens when socket.io releases a new version?
The suite runs against more than one. A CI job typechecks and runs the real target on socket.io 4.7 and 4.8, so a case or shared contract that does not hold for both fails before it can be published as settled. Where the versions differ, the contract admits the measured alternatives and the difference is recorded. Widening that range is how a new version is taken on, and it is a change to a matrix rather than to the mock.
Does smocket mock my domain logic?
No. It reproduces delivery, meaning which socket receives which event, in which room and namespace, and in what order. Your handlers stay yours, and they run unchanged. That is why the quick start above is ordinary application code with one import swapped.
Does it work in Jest, or another CJS runner?
The package ships both ESM and CJS builds with type declarations for each, and CI
verifies that both resolve. The clean adoption fixture also runs the documented
moduleNameMapper setup through a named CommonJS socket.io-client import after
installing either a candidate tarball or the exact published package. See
test-runner integration.
Why is raw WebSocket out of scope?
It sits at the transport, and it answers a different question. A raw WebSocket mock answers what bytes crossed the wire. smocket answers which sockets receive what, given a set of emits, joins, and broadcasts. Tools that intercept the transport already cover the first question well. See decision 0009.
| Document | What it holds |
|---|---|
| docs/README.md | the documentation map, by the question you arrived with |
| roadmap.md | the guarantees, dependencies, and release path toward v1.0.0 |
| test-runner-integration.md | running smocket inside Vitest or Jest, and what keeps its types |
| troubleshooting.md | reproductions, signals, causes, and corrections for adoption failures |
| conformance.md | every behaviour verified against real socket.io, generated from the run |
| scope.md | the boundary, and the layer split it follows |
| differences.md | where smocket diverges on purpose, and what it adds that socket.io lacks |
| glossary.md | the socket.io terms the other documents use |
| decisions/ | one record per design decision, with the alternatives rejected |
| adapter-registration.md | supplying your own adapter to change the routing decision |
| CONTRIBUTING-docs.md | how documents here are written |
| labels.md | what the issue and pull request labels mean |
Korean versions sit beside the originals as <name>.ko.md, with the English
version authoritative where the two diverge.
Contributions are welcome, and the most useful ones encode how socket.io actually behaves.
The shortest route in is a conformance case, because it is judged mechanically rather than by taste. The report lists the surface no case covers yet, and how to add a case is the procedure. A case that passes on real socket.io and fails on smocket is a divergence located, and it arrives with its reproduction already written.
The milestones show what each release is aiming for, and the issue tracker carries the rest.
See CONTRIBUTING.md for setup, where to report or propose work, commit conventions, and how pull requests are merged. The Korean guide covers the same path. See AGENTS.md for how to run the two test targets.
MIT. See LICENSE.
