Host multiple rooms in one chat app
Extend the pear-chat scaffold from a single room to an account that owns and joins many rooms, each with its own Autobase.
This guide shows you how to extend pear-chat from a single room to an account model that owns and joins many rooms. The reference implementation is pear-chat-multi-rooms.
This guide is about the Pear-end, not the shell. The code below lives in the Bare worker — the peer-to-peer logic, not the user interface. Because the Pear-end never imports DOM APIs and never assumes a UI framework, the same worker is portable across desktop (Electron), mobile (React Native via Bare iOS / Bare Android), and terminal. The example apps ship an Electron shell, but only the UI half changes per platform — the logic here stays the same. See Runtime and languages for the cross-platform model and current support.
This is a delta-only how-to. The shared scaffold is explained in the Reshape into a production app tutorial — read it first.
Before you begin
- A working clone of
pear-chat(or your own app built from the getting-started path). - Familiarity with Autobase — each room is one Autobase.
What changes
| Layer | Change |
|---|---|
| Worker | Introduce a ChatAccount that holds a map of ChatRooms keyed by a generated room id. |
| Schema | Add a room collection persisting the rooms a user has joined ({ id, name, invite, info }). |
| Transport | Add add-room and join-room message types, push a rooms event, and make the messages event and add-message command carry a roomId. |
| Renderer | Add a left-rail room list with a "new room" button and an invite-paste input. |
The Electron shell, the build/forge configuration, and the per-room ChatRoom code stay identical to the getting-started chat app.
Steps
Replace room with a ChatAccount on WorkerTask
In workers/worker-task.js, swap the single ChatRoom for a ChatAccount (L6). The worker keeps the same Corestore + Hyperswarm setup (L17–L19), but delegates all room management to the account (L21) and forwards its messages event over the worker pipe as JSON tagged with the roomId (L24–L26).
_open opens the account (L30–L31), then handles add-room, join-room, and add-message on the pipe (L40–L46). _close tears down account → swarm → store (L51–L54):
const Corestore = require('corestore')
const debounce = require('debounceify')
const Hyperswarm = require('hyperswarm')
const ReadyResource = require('ready-resource')
const ChatAccount = require('./chat-account')
class WorkerTask extends ReadyResource {
constructor (pipe, storage, opts = {}) {
super()
this.pipe = pipe
this.storage = storage
this.invite = opts.invite
this.name = opts.name || `User ${Date.now()}`
this.store = new Corestore(storage)
this.swarm = new Hyperswarm()
this.swarm.on('connection', (conn) => this.store.replicate(conn))
this.account = new ChatAccount(this.store, this.swarm)
this.debounceRooms = debounce(() => this._rooms())
this.account.on('update', () => this.debounceRooms())
this.account.on('messages', (roomId, messages) => {
this.pipe.write(JSON.stringify({ type: 'messages', roomId, messages }))
})
}
async _open () {
await this.store.ready()
await this.account.ready()
if (this.invite) {
await this.account.joinRoom(this.invite)
}
this.pipe.on('data', async (data) => {
let message
try {
message = JSON.parse(data)
} catch {
return
}
if (message.type === 'add-room') {
await this.account.addRoom(message.name, { at: Date.now() })
} else if (message.type === 'join-room') {
await this.account.joinRoom(message.invite)
} else if (message.type === 'add-message') {
await this.account.addMessage(message.roomId, message.text, { name: this.name, at: Date.now() })
}
})
await this.debounceRooms()
}
async _close () {
await this.account.close()
await this.swarm.destroy()
await this.store.close()
}
async _rooms () {
const rooms = Object.entries(this.account.rooms).map(([id, room]) => ({
id,
name: room.name,
invite: room.invite,
info: room.info
}))
rooms.sort((a, b) => a.info.at - b.info.at)
this.pipe.write(JSON.stringify({ type: 'rooms', rooms }))
}
}
module.exports = WorkerTaskBuild ChatAccount
Create workers/chat-account.js modelled on chat-room.js. The account is itself an Autobase-backed HyperDB that stores the user's room list:
- each entry is
{ id, name, invite, info }, whereidis a generated handle andinviteis the room's pairing code. - On
_open, the account opens its base, thenopenRooms()materialises oneChatRoomper stored entry, each in its own Corestore namespace (this.store.namespace(id)) (L156–L168). addRoom(L170–L184) andjoinRoom(L186–L206) spin up a newChatRoom, then append the room metadata to the account base so it survives restarts:
async openRooms () {
const rooms = await this.view.find('@pear-chat-multi-rooms/rooms', { reverse: true, limit: 100 }).toArray()
await Promise.all(rooms.map(async (item) => {
const roomStore = this.store.namespace(item.id)
const room = new ChatRoom(roomStore, this.swarm, { name: item.name, info: item.info, invite: item.invite })
this.rooms[item.id] = room
this._watchMessages(item.id)
await room.ready()
await this._messages(item.id)
}))
}
async addRoom (name, info) {
const id = Math.random().toString(16).slice(2)
const roomStore = this.store.namespace(id)
const room = new ChatRoom(roomStore, this.swarm, { name, info })
this.rooms[id] = room
this._watchMessages(id)
await room.ready()
await room.addRoomInfo()
await this.base.append(
ChatDispatch.encode('@pear-chat-multi-rooms/add-room', { id, name: room.name, invite: room.invite, info: room.info })
)
}
async joinRoom (invite) {
const id = Math.random().toString(16).slice(2)
const roomStore = this.store.namespace(id)
const room = new ChatRoom(roomStore, this.swarm, { invite })
this.rooms[id] = room
room.on('update', async () => {
const remoteRoom = await room.getRoomInfo()
if (remoteRoom && remoteRoom.name !== room.name) {
room.name = remoteRoom.name
room.info = remoteRoom.info
await this.base.append(
ChatDispatch.encode('@pear-chat-multi-rooms/add-room', { id, name: room.name, invite, info: room.info })
)
}
})
this._watchMessages(id)
await room.ready()
}The key change: each room is an independent Autobase in its own namespace, so the account never co-mingles room data. The account base only stores room metadata (id, name, invite, info), encrypted by the account's own Autobase. See Storage and distribution for the underlying mental model.
Extend the schema
This step touches all three builders in schema.js, so it is easiest to follow the full pear-chat-multi-rooms schema.js. The changes are:
- Rename the namespace from
pear-chattopear-chat-multi-roomsin all three.namespace(...)calls (schema, db, dispatch). The type references below (@pear-chat-multi-rooms/...) resolve against this name — if you leave the namespace aspear-chat,node schema.jsthrowsTypeError: Cannot read properties of undefined (reading 'frameable')because the referenced type does not exist. - Register a
roomschema ({ id, name, invite, info }), plus aroomsHyperDB collection and anadd-roomHyperDispatch entry.
The roomId that the renderer uses to address a specific room is carried on the plain-JSON messages exchanged over the worker pipe, so the schema only needs to persist the room metadata. The new room registration looks like this (L26–L34):
schema.register({
name: 'room',
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'name', type: 'string', required: true },
{ name: 'invite', type: 'string', required: true },
{ name: 'info', type: 'json' }
]
})Regenerate spec/ from a clean directory:
rm -rf spec && npm run build:dbDelete spec/ before regenerating. The schema generators (hyperschema, hyperdispatch, hyperdb) merge into the existing manifests rather than overwriting them, so regenerating on top of the old pear-chat spec leaves stale registrations next to the new pear-chat-multi-rooms ones — the generated spec/ then carries duplicate definitions.
Update the renderer
In renderer/index.html and renderer/app.js, split the layout into a left rail (the room list) and a right pane (the active room).
The renderer:
- sends
{ type: 'add-room', name }and{ type: 'join-room', invite }over the worker pipe, - renders the left rail from the
{ type: 'rooms', rooms }events the worker pushes, - the existing message send becomes
{ type: 'add-message', text, roomId }, and - incoming
{ type: 'messages', roomId, messages }events are filed under theirroomId, so the renderer just shows the selected room's messages.
Run it
npm run build
npm start -- --storage /tmp/multi-user1 --name user1Type a room name and click Create. Copy the room invite from the left rail (not the Account Invite line in the terminal — that pairs a whole account, not a single room). In a second terminal:
npm start -- --storage /tmp/multi-user2 --name user2 --invite <room-invite>Or start without --invite and paste the room invite into the join field in the UI.
user2's app shows the joined room in its left rail. Both peers can now create or join additional rooms — each one is an independent Autobase with its own pairing flow.
Where to go next
- Add blind peering to a chat app — keep individual rooms reachable when their writers are offline.
- Work with many Hypercores using Corestore — the pattern that makes hosting many rooms in one app cheap.
- Storage and distribution — why each room can be its own Autobase without paying per-room infrastructure costs.