A production Autobase-backed chat room
Tracing the P2P worker internals of pear-chat, the production scaffold behind Pear's getting-started tutorial: a Corestore + Hyperswarm + Autobase worker, blind-pairing invites, and a generated HyperDB/HyperDispatch schema.
pear-chat is a real, production-shaped multi-writer chat room—the same scaffold Reshape into a production app wires into an Electron shell, and the base every "Extend your chat app" how-to modifies. This page traces the worker-side P2P code itself: a Corestore + Hyperswarm + Autobase worker task, a ChatRoom built from Autobase and blind-pairing, and the generated schema that keeps them typed. None of this is Pear-specific—the same code runs identically in any Bare process.
Clone the repo to follow along:
git clone https://github.com/holepunchto/pear-docs
cd pear-docs
git switch published
cd examples/getting-started/pear-chat
npm install && npm run buildFor the Electron shell, preload bridge, and OTA updater wiring around this worker, see Reshape into a production app.
Trace WorkerTask
Open workers/worker-task.js. This is the worker's "main object"—it owns a Corestore, a Hyperswarm, and a ChatRoom, and it speaks JSON to its host over a framed pipe:
const Corestore = require('corestore')
const debounce = require('debounceify')
const Hyperswarm = require('hyperswarm')
const ReadyResource = require('ready-resource')
const ChatRoom = require('./chat-room')
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.peers = 0
this.store = new Corestore(storage)
this.swarm = new Hyperswarm()
this.swarm.on('connection', (conn) => {
this.store.replicate(conn)
this._peers(1)
conn.once('close', () => this._peers(-1))
})
this.room = new ChatRoom(this.store, this.swarm, this.invite)
this.debounceMessages = debounce(() => this._messages())
this.room.on('update', () => this.debounceMessages())
}
async _open () {
await this.store.ready()
await this.room.ready()
this.pipe.on('data', async (data) => {
let message
try {
message = JSON.parse(data)
} catch {
return
}
if (message.type === 'add-message') {
await this.room.addMessage(message.text, { name: this.name, at: Date.now() })
}
})
// Push the room invite so the renderer can show its "copy invite" button.
this.pipe.write(JSON.stringify({ type: 'invite', invite: await this.room.getInvite() }))
await this.debounceMessages()
}
async _close () {
await this.room.close()
await this.swarm.destroy()
await this.store.close()
}
_peers (delta) {
this.peers += delta
this.pipe.write(JSON.stringify({ type: 'peers', count: this.peers }))
}
async _messages () {
const messages = await this.room.getMessages()
messages.sort((a, b) => a.info.at - b.info.at)
this.pipe.write(JSON.stringify({ type: 'messages', messages }))
}
}
module.exports = WorkerTaskThe lifecycle is a canonical shape for this kind of worker:
- Construct (L9–L29)—wire dependencies, do not perform I/O. The Corestore, Hyperswarm, and
ChatRoomare created. On every swarmconnectionthe store replicates and the peer count ticks (L20–L24); the room'supdateevent is wired to a debounced_messages()push (L27–L28). _open()(L31–L50)—open the store and room, subscribe to incoming host IPC (JSON parsed off the pipe, L35–L45), then push the room's invite (L48) and the first batch of messages (L49) back._peers()(L58–L61)—every connect/disconnect writes a{ type: 'peers', count }frame._messages()(L63–L67)—read every message from the room, sort by timestamp, and write a{ type: 'messages', messages }frame. Runs once on open and again, debounced, on every roomupdate.
The two additions over a bare-minimum worker are the peer counter and the invite push (L48)—the worker hands its host the room code so a UI can show a working "Copy invite" control.
Read ChatRoom
workers/chat-room.js is the actual peer-to-peer data structure. It combines four building blocks:
- An Autobase so multiple writers (each peer's local core) merge into one deterministic materialised view.
- A HyperDB view (a Hyperbee-derived database) for the
messagesandinvitestables. - HyperDispatch for typed Autobase
appendpayloads (@pear-chat/add-message,@pear-chat/add-writer,@pear-chat/add-invite). blind-pairingso a new peer joins with only a short invite code—the inviter never sees the joiner's key in plaintext.
These come together when peer B joins peer A's room.
1. A creates an invite. getInvite() appends a @pear-chat/add-invite record to the Autobase and returns a z32-encoded invite code:
async getInvite () {
const existing = await this.view.findOne('@pear-chat/invites', {})
if (existing) {
return z32.encode(existing.invite)
}
const { id, invite, publicKey, expires } = BlindPairing.createInvite(this.base.key)
await this.base.append(
ChatDispatch.encode('@pear-chat/add-invite', { id, invite, publicKey, expires })
)
return z32.encode(invite)
}2. B pairs as a candidate. In _open, an empty local core and an --invite means the room uses blind-pairing as a candidate to reach A over the swarm and receive the Autobase keys:
if (isEmpty && this.invite) {
const res = await new Promise((resolve) => {
this.pairing.addCandidate({
invite: z32.decode(this.invite),
userData: localKey,
onadd: resolve
})
})
key = res.key
encryptionKey = res.encryptionKey
}3. A confirms and adds B as a writer. A's pairing.addMember onadd handler resolves the invite, calls addWriter(B.key), and hands back the Autobase root and encryption keys:
this.pairMember = this.pairing.addMember({
discoveryKey: this.base.discoveryKey,
/** @type {function(import('blind-pairing-core').MemberRequest)} */
onadd: async (request) => {
const inv = await this.view.findOne('@pear-chat/invites', { id: request.inviteId })
if (!inv) return
request.open(inv.publicKey)
await this.addWriter(request.userData)
request.confirm({
key: this.base.key,
encryptionKey: this.base.encryptionKey
})
}
})4. B opens the shared Autobase. With those keys, B opens the base, joins the discovery topic on the swarm, and waits for the writable signal before replicating:
this.base = new Autobase(this.store, key, {
encrypt: true,
encryptionKey,
open: this._openBase.bind(this),
close: this._closeBase.bind(this),
apply: this._applyBase.bind(this)
})
const writablePromise = new Promise((resolve) => {
this.base.on('update', () => {
if (this.base.writable) resolve()
if (!this.base._interrupting) this.emit('update')
})
})
await this.base.ready()
this.swarm.join(this.base.discoveryKey)
if (!this.base.writable) await writablePromiseAny message either peer adds is appended to its local Autobase core, replicated through the swarm, and applied to the shared HyperDB view. The room emits update on every Autobase update; WorkerTask debounces those into a single { type: 'messages', messages } write back to its host.
For the deeper picture of Autobase merging, read From append-only logs to files.
Understand spec/
The spec/ directory holds generated code—that's why npm run build had to run before the first start. schema.js at the repo root regenerates everything in spec/ from declarative definitions, in three blocks.
spec/schema/—the canonical record shapes (writer, invite, message) registered with HyperSchema:
const hyperSchema = Hyperschema.from(SCHEMA_DIR)
const schema = hyperSchema.namespace('pear-chat')
schema.register({
name: 'writer',
fields: [
{ name: 'key', type: 'buffer', required: true }
]
})
schema.register({
name: 'invite',
fields: [
{ name: 'id', type: 'buffer', required: true },
{ name: 'invite', type: 'buffer', required: true },
{ name: 'publicKey', type: 'buffer', required: true },
{ name: 'expires', type: 'int', required: true }
]
})
schema.register({
name: 'message',
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'text', type: 'string', required: true },
{ name: 'info', type: 'json' }
]
})
Hyperschema.toDisk(hyperSchema)spec/db/—typed HyperDB collections (@pear-chat/messages, @pear-chat/invites), each keyed by id:
const hyperdb = HyperdbBuilder.from(SCHEMA_DIR, DB_DIR)
const db = hyperdb.namespace('pear-chat')
db.collections.register({
name: 'invites',
schema: '@pear-chat/invite',
key: ['id']
})
db.collections.register({
name: 'messages',
schema: '@pear-chat/message',
key: ['id']
})
HyperdbBuilder.toDisk(hyperdb)spec/dispatch/—typed Autobase append-payload encoders, one per record type:
const hyperdispatch = Hyperdispatch.from(SCHEMA_DIR, DISPATCH_DIR, { offset: 0 })
const dispatch = hyperdispatch.namespace('pear-chat')
dispatch.register({ name: 'add-writer', requestType: '@pear-chat/writer' })
dispatch.register({ name: 'add-invite', requestType: '@pear-chat/invite' })
dispatch.register({ name: 'add-message', requestType: '@pear-chat/message' })
Hyperdispatch.toDisk(hyperdispatch)You do not normally edit files under spec/. To change a message shape or add a record type, edit schema.js and re-run npm run build:db. For why P2P apps benefit from schema-first design, see Schema-first design.
See also
- Reshape into a production app—the Electron shell, preload bridge, and OTA updater built around this worker.
- Structured RPC and schema-first design—why schema-first design matters for peer-to-peer apps.
- Add blind peering to a chat app—keep this room online when its writers are offline.
- Host multiple rooms in one chat app—extend from one room to an account with many.
- Autobase reference—the multi-writer log API.