LogoPear Docs

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 build

For 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:

workers/worker-task.js
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 = WorkerTask

The lifecycle is a canonical shape for this kind of worker:

  • Construct (L9–L29)—wire dependencies, do not perform I/O. The Corestore, Hyperswarm, and ChatRoom are created. On every swarm connection the store replicates and the peer count ticks (L20–L24); the room's update event 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 room update.

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 messages and invites tables.
  • HyperDispatch for typed Autobase append payloads (@pear-chat/add-message, @pear-chat/add-writer, @pear-chat/add-invite).
  • blind-pairing so 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:

workers/chat-room.js
  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:

workers/chat-room.js
    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:

workers/chat-room.js
    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:

workers/chat-room.js
    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 writablePromise

Any 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:

schema.js — records
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:

schema.js — collections
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:

schema.js — dispatch
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

On this page