A persistent chat room with a paywall seam. Two files, zero dependencies.
node room.js # http://127.0.0.1:8060
That's the install. There isn't an npm install, because there's nothing to install.
It has to run on a Raspberry Pi that I reach over ssh, and I wanted the deploy to be a copy — not a copy plus a toolchain plus a native module that needs a compiler on a box with 1GB of RAM.
So the WebSocket layer is written out longhand: the RFC6455 handshake (a SHA-1 of the client key plus a magic GUID), and a frame codec that handles the 7/16/64-bit length forms and unmasks client payloads. It's about a hundred lines and it is the least clever code in the repo, which is the point.
The one part worth reading if you're writing your own:
// Frames arrive split and coalesced arbitrarily — a decoder that assumes one
// frame per 'data' event works on localhost and fails over a real network.
function makeDecoder(onMessage, onClose) {
let buf = Buffer.alloc(0);
return (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) { /* pull as many whole frames as are present, keep the remainder */ }
};
}Nearly every from-scratch WebSocket implementation I've read gets that wrong and passes its
tests anyway, because on loopback one write usually arrives as one data.
- an open room — nicknames, history, live broadcast
- persistence — messages append to
data/room.jsonl, entitlements todata/entitlements.json. Kill the process, start it again, the conversation is still there. - a paid lane —
dmmessages check entitlement and get refused with apaywallframe if absent - static hosting — serves a
blog/directory alongside, with path traversal blocked
function hasPaidAccess(key) {
const e = ENTITLED.get(key);
return !!(e && (!e.until || e.until > Date.now()));
}That's deliberate. Every payment processor has a different shape, and every one of them will eventually want to be swapped. One function with one return means the rail is the only thing that ever changes — nothing else in the file knows or cares who took the money.
ROOM_DEV_GRANT=1 grants entitlement on connect so you can walk the whole flow without a
processor.
node room.js &
node test-room.js
Two real clients, two real sockets, against the running server — not mocks:
handshake + hello : YES (guest-5581, paid=false)
B heard A speak : YES (david: is this thing on)
B saw nick change : YES
B hit the paywall : YES ($10)
RESULT : ALL GREEN
./deploy.sh # dry run — prints what it would do, touches nothing
./deploy.sh --go # scp two files, write a systemd unit, start it
Dry run is the default. A deploy script whose default is deploy is one stray tab-complete away from a bad afternoon.
No accounts, no auth, no moderation, no rooms-plural, no rate limiting. It is a small complete thing rather than a large unfinished one. If you need those, this is a readable starting point rather than a product.
MIT.