Skip to content

Repository files navigation

oneroom

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.


Why it has no dependencies

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.

What it does

  • an open room — nicknames, history, live broadcast
  • persistence — messages append to data/room.jsonl, entitlements to data/entitlements.json. Kill the process, start it again, the conversation is still there.
  • a paid lanedm messages check entitlement and get refused with a paywall frame if absent
  • static hosting — serves a blog/ directory alongside, with path traversal blocked

The paywall is one function

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.

Testing

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

Deploying

./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.

What it isn't

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.

About

A persistent chat room with a paywall seam, in two files and zero dependencies. Hand-rolled RFC6455 so it deploys to a Raspberry Pi with one scp and no npm install.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages