diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3dab6e..94879bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,11 @@ jobs: # it, and with it the badge. - name: Run the suite against smocket run: pnpm test:mock + # The example is documentation that executes, so it rots the moment nothing + # runs it. This builds `dist/` and runs the program, which fails the job if + # the example stops working against the package it demonstrates. + - name: Run the chat room example + run: pnpm example:chat-room browser: name: browser (chromium) diff --git a/README.md b/README.md index ae5f60f..c710240 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,13 @@ b.emit('join', 'room-1'); - Multi-client simulation - Membership cleanup on `disconnect` +## Examples + +Runnable programs live in [examples/](examples/), outside the published package. +[chat-room](examples/chat-room/) is one room, two clients, and one broadcast, the +smallest thing that shows an event reaching someone other than the sender. From a +clean checkout, `pnpm install` then `pnpm example:chat-room`. + ## Scope smocket reproduces the delivery and routing layer of Socket.IO. The following are out of scope: diff --git a/examples/chat-room/README.md b/examples/chat-room/README.md new file mode 100644 index 0000000..4435650 --- /dev/null +++ b/examples/chat-room/README.md @@ -0,0 +1,50 @@ +# Chat room example + +One room, two clients, one broadcast. The smallest program that shows an event +reaching someone other than the sender, which is the routing a mock without +[rooms](../../docs/glossary.md#room) cannot reproduce. + +## Run it + +From the repository root, after `pnpm install`. + +```bash +pnpm example:chat-room +``` + +``` +[alice] bob joined +[bob] alice: hello +``` + +Both lines are fixed, and for two different reasons. + +The first one is ordering that the library already guarantees. Connection +completion and every emit are scheduled through one FIFO defer +([0004](../../docs/decisions/0004-connection-deferred-one-tick.md), +[0010](../../docs/decisions/0010-single-defer-primitive-and-fifo.md)), so alice, +created first, also joins first. That first join is broadcast the same way as the +second, with `socket.to(room)`, which reaches the room and skips the sender. The +room holds nobody else at that point, so `alice joined` is sent and received by no +one, which is why the output opens with `bob joined` instead. + +The second line is what the acknowledgements are for. Awaiting both joins holds +the message until bob is in the room. Without that, the message would sit in the +queue directly behind alice's own join and reach the server while bob was still +connecting, so the broadcast would find an empty room and the line would be lost. + +## What it uses + +- `new Server(url)` and `io.on('connection')`, the server entry point socket.io + applications already write against. +- `connect(url, { auth })`, the client side, with the name read back on the + server as `socket.handshake.auth.name`. +- `socket.join(room)` and `socket.to(room).emit(...)`, the + [broadcast](../../docs/glossary.md#broadcast) that reaches the room and skips + the sender. +- `emitWithAck`, which is what makes the last line deterministic rather than + dependent on how far two clients happen to have got when the message is sent. + +The server half is the code an application runs against real socket.io. Swapping +a real client for smocket inside a test runner is a separate setup, documented +under `docs/`. diff --git a/examples/chat-room/index.js b/examples/chat-room/index.js new file mode 100644 index 0000000..05ab5d0 --- /dev/null +++ b/examples/chat-room/index.js @@ -0,0 +1,47 @@ +// A minimal chat room: two clients in one room, one of them speaks, and the +// other one hears it. The shortest program that shows delivery reaching someone +// other than the sender, which is the part a mock without rooms cannot do. +import { connect, Server } from 'smocket'; + +const url = 'http://localhost:3000'; + +// The server side, exactly as it is written against real socket.io. The client +// is `connect` here rather than the `io` alias, so the name `io` stays free for +// the server the way an application would use it. +const io = new Server(url); + +io.on('connection', (socket) => { + const name = socket.handshake.auth.name; + + socket.on('join', (room, ack) => { + socket.join(room); + // `socket.to(room)` reaches the room and skips this socket, so the joiner is + // not told about its own arrival. The first join therefore reaches nobody, + // the room being empty until it lands. + socket.to(room).emit('system', `${name} joined`); + ack(); + }); + + socket.on('message', (room, text) => { + socket.to(room).emit('message', `${name}: ${text}`); + }); +}); + +// Two clients, each one carrying the name the server reads off the handshake. +const alice = connect(url, { auth: { name: 'alice' } }); +const bob = connect(url, { auth: { name: 'bob' } }); + +for (const [label, client] of [ + ['alice', alice], + ['bob', bob], +]) { + client.on('system', (line) => console.log(`[${label}] ${line}`)); + client.on('message', (line) => console.log(`[${label}] ${line}`)); +} + +// Awaiting both joins is what fixes the last line. bob has to be in the room +// before alice speaks, and without the ack the message would be queued right +// behind alice's own join and arrive while bob was still connecting. +await Promise.all([alice.emitWithAck('join', 'general'), bob.emitWithAck('join', 'general')]); + +alice.emit('message', 'general', 'hello'); diff --git a/examples/chat-room/package.json b/examples/chat-room/package.json new file mode 100644 index 0000000..72d62b9 --- /dev/null +++ b/examples/chat-room/package.json @@ -0,0 +1,11 @@ +{ + "name": "chat-room-example", + "private": true, + "type": "module", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "smocket": "workspace:*" + } +} diff --git a/package.json b/package.json index c95ff8d..cfdce97 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "attw": "attw --pack .", "assert:no-imports": "node scripts/assert-no-imports.js", "check:package": "pnpm build && pnpm assert:no-imports && pnpm publint && pnpm attw", + "example:chat-room": "pnpm build && pnpm --filter chat-room-example start", "conformance": "node scripts/conformance-report.mjs", "check:conformance": "node scripts/conformance-report.mjs --check" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0151ad1..b6de83a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,12 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)) + examples/chat-room: + dependencies: + smocket: + specifier: workspace:* + version: link:../.. + packages: '@andrewbranch/untar.js@1.0.3': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09a02ca..51ceb88 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +packages: + - examples/* allowBuilds: esbuild: true onlyBuiltDependencies: