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.

publishsend
Who gets itevery awake, subscribed roomone room you name
If the target is asleepit misses the messageit wakes up and gets it
Deliverybest effortat least once
Answers younoa 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:

ErrorWhat happened
E_BUS_NO_SUCH_ROOMno room by that id in this project, or it is a relay room and runs no code
E_BUS_PAYLOAD_TOO_LARGEover 4 KiB
E_BUS_MAILBOX_FULLthe target has 64 undelivered messages and is not keeping up
E_BUS_RATE_LIMITEDthis 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

Payload4 KiB
Channels one room may subscribe to32
Undelivered messages per room64
Delivery attempts before a message is dropped5
Bus operations per room50 a second, bursting to 100

Both verbs draw on the same budget. Going over it fails the call rather than slowing the room down.