The room bus
Rooms are isolated. A match room and a notifications room are separate workers with separate state and no way to see each other, which is what keeps one room’s bad afternoon from being every room’s.
That isolation has one cost, and it shows up the first time something happens in one room that another room needs to know about. A match fills and the notifications hub should tell the players’ friends. A tournament bracket advances and the lobby should update. Before the bus, the only route between two rooms went through a player’s browser: room A tells a client, the client tells room B. That is slow, and worse, it is a lie waiting to happen: the client can edit the message, drop it, or close the tab halfway through.
room.bus is the direct route. It is scoped to your project, always, and there is no way to
address another one.
Two verbs, on purpose
They are not two spellings of the same thing. Picking the wrong one is the mistake this API invites, so the difference is worth reading before you write either.
publish | send | |
|---|---|---|
| Who gets it | every awake, subscribed room | one room you name |
| If the target is asleep | it misses the message | it wakes up and gets it |
| Delivery | best effort | at least once |
| Answers you | no | a promise that resolves or rejects |
| Use it for | “something changed” | “this needs to happen” |
publish is a broadcast that nobody is obliged to hear
room.bus.publish('match.ready', JSON.stringify({ match: room.id, players: 4 })); Every room of your project that is awake and subscribed to match.ready runs its handler. Rooms
that are hibernated miss it, exactly the way they miss the passage of time, and pick their state up
again when they wake. Nothing queues. Nothing wakes. There is no ordering promise between two
different channels, and there is not going to be one.
That sounds weak, and it is meant to. Publish is for facts a room can recompute if it missed them.
send is a delivery
await room.bus.send('hub:global', JSON.stringify({ kind: 'match-ready', match: room.id })); This one is durable. If hub:global is hibernated, the platform wakes it and runs its handler. If
the message cannot be delivered right now, it stays parked and is tried again. The promise resolves
when the message has been accepted, and rejects with a named error when it cannot be:
| Error | What happened |
|---|---|
E_BUS_NO_SUCH_ROOM | no room by that id in this project, or it is a relay room and runs no code |
E_BUS_PAYLOAD_TOO_LARGE | over 4 KiB |
E_BUS_MAILBOX_FULL | the target has 64 undelivered messages and is not keeping up |
E_BUS_RATE_LIMITED | this room is over its bus budget |
Receiving
Handlers go in the room definition’s config, next to alarms, and not in a call you make at
runtime. The reason is specific: a send wakes a sleeping room, and a handler you registered
before the room went to sleep would not be there when it woke up. Config is code, so it is always
there.
export default defineRoom(hubSchema, {
mode: 'event',
memoryMb: 32,
bus: {
channels: {
'match.ready'(state, event) {
// event.from is the roomId that published. The platform stamps it.
state.notices.add(event.payload, { from: event.from, seen: 0 });
},
},
onMessage(state, message) {
const notice = JSON.parse(message.payload) as { kind: string; match: string };
// Keyed on the match, so a repeat overwrites instead of doubling. See below.
state.notices.add(notice.match, { from: message.from, seen: 0 });
},
},
}); Declaring a channel in channels also subscribes the room to it, when the room starts and again
every time it wakes. Most rooms never need room.bus.subscribe at all; it is there for a room that
wants to leave a channel and rejoin it during its life.
Write onMessage so it can run twice
send is at least once, and the words mean what they say: your handler can see the same
message twice for one send. A delivery that was posted to a room which then crashed, restarted, or
hibernated mid-message is retried, because the alternative is losing it silently.
So make the handler idempotent. In practice that means keying on something in the message rather than appending:
// Good: a repeat lands on the same row.
state.notices.add(notice.match, { from: message.from, seen: 0 });
// Trouble: a repeat is a second notification for one match.
state.notices.add(`n${state.notices.size}`, { from: message.from, seen: 0 }); If a message keeps failing, it is retried a few times, logged, and dropped. There is no dead-letter queue to go and read it out of. A handler that throws also counts toward the room’s crash threshold, the same as any other handler, which is why the retries are bounded rather than endless.
What the bus will not do
It cannot reach another project. Not “it refuses to”. There is nowhere to put a project: the verbs take a channel and a roomId, and both are resolved inside your project. This is not going to be relaxed.
It is not a data transfer. Payloads are capped at 4 KiB and it is a signalling channel: an id, a couple of names, a reason. Anything larger belongs in a room the recipient joins, or in storage the recipient reads.
It is not ordered, and it will not get stronger. These guarantees are deliberately the weakest
ones that are still useful, because a project that outgrows one machine will have its rooms split
across several, at which point send becomes a network hop and publish becomes a fan-out across
machines. Everything above survives that. A stronger promise made today would be one we had to
break later.
Rolling a room back across an upgrade can lose parked mail. Undelivered send messages are
stored alongside a room’s alarms. A server running a version of the platform older than the bus
reads that store, keeps the alarms, and does not understand the messages. This only matters during
a platform rollback and it is stated here rather than discovered.
Limits
| Payload | 4 KiB |
| Channels one room may subscribe to | 32 |
| Undelivered messages per room | 64 |
| Delivery attempts before a message is dropped | 5 |
| Bus operations per room | 50 a second, bursting to 100 |
Both verbs draw on the same budget. Going over it fails the call rather than slowing the room down.