Reshape into a production app
Second of four onboarding steps: take part 1's from-scratch chat and reshape it into the production hello-pear-electron scaffold — a Bare chat worker, an Autobase room with blind-pairing invites, on-disk persistence, a separate OTA updater worker, and a polished Tailwind UI with a copy-invite button.
This is part 2 of 4 in the getting started path. Part 1 built a working chat from scratch with the simplest form of the runtime (PearRuntime.run). This part reshapes it into the hello-pear-electron production scaffold — the exact shape Pear's official hello-pear-electron template ships, and the same shape Keet and PearPass use under the hood.
By the end you have the shared pear-chat scaffold every chat-family and media how-to in the How To guides extends. There is no separate intermediate app — part 2 is the finished template shape:
- A separate Bare worker that owns all the P2P code.
- An Autobase-backed shared room with blind-pairing invite codes, so two peers join from a short string without exposing keys.
- On-disk persistence via Corestore + a HyperDB view — the conversation survives restarts.
- A separate OTA updater worker, wired but inert, ready for part 3 and part 4 to flip on.
- A polished Tailwind CSS UI (compiled by the CLI, not the CDN) with a peer counter and a copy-invite button.
Full production-ready reference: hello-pear-electron. The complete version of this chat lives at holepunchto/hello-pear-electron — Holepunch's official Electron template, the same shape Keet and PearPass ship. Clone it any time to see the finished structure or to crib code. For a guided tour of the template, see Start from the hello-pear-electron template.
What you'll build
A standalone Electron app that:
- Runs the actual peer-to-peer code inside a Bare worker, keeping native modules out of the renderer and Electron's main thread:
- Hyperswarm finds and connects peers over the DHT.
- Corestore is the on-disk store that holds the app's Hypercores.
- Autobase is the multi-writer log that merges every peer's messages into one shared room view.
- blind-pairing lets a new peer join from a short invite code without either side exposing its keys.
- Pairs two peers via an invite code generated from the room owner's Autobase key — the UI exposes it behind a copy button.
- Persists every message to disk in an Autobase-backed HyperDB view, so reopening the app replays the conversation.
- Embeds the
pear-runtimeOTA updater in its own Bare worker, so update traffic never blocks the chat. - Renders a vanilla HTML + Tailwind CSS UI that talks to the worker by exchanging JSON over a framed pipe — no bundler, no framework.
For the conceptual picture behind this split — why a production app separates OTA updates, storage, and workers — read Pear desktop application architecture.
Before you start
You need:
- Node.js v22.17 or newer and npm v10.9 or newer.
- A POSIX-style terminal (macOS, Linux, or Windows with WSL).
- An IDE or text editor.
- The working chat from part 1 — build the peer-to-peer chat, so you are comfortable with
PearRuntime.run(), Bare workers, and Hyperswarm topics.
Part 1 was type-along in five files. The production scaffold is ~14 files and a generated spec/ directory — too much to retype. Clone the repo and walk the working code as this part explains it. Every snippet points to a real file so you can read the surrounding context.
Clone the example
git clone https://github.com/holepunchto/pear-docs
cd pear-docs/examples/getting-started/pear-chat
npm install
npm run buildnpm run build does two things you must run before the first npm start:
build:dbrunsnode schema.jsto generate thespec/directory (HyperSchema records, the HyperDB view, and the HyperDispatch encoders).build:tailwindcompilesinput.cssintorenderer/build/output.csswith the Tailwind CLI — replacing part 1's in-browser CDN script.
You should now have a pear-chat/ directory laid out like this:
pear-chat/
├─ build/ # icons + per-OS packaging assets
├─ electron/
│ ├─ main.js # spawns the chat worker + the updater worker
│ └─ preload.js # contextBridge exposing window.bridge
├─ renderer/
│ ├─ app.js # vanilla DOM + worker bridge
│ └─ index.html # chat shell (Tailwind classes)
├─ spec/ # generated HyperDispatch + HyperDB schemas
├─ workers/
│ ├─ chat-room.js # Autobase + blind-pairing room
│ ├─ index.js # Bare chat worker entrypoint
│ ├─ main.js # Bare updater worker (PearRuntime OTA)
│ └─ worker-task.js # WorkerTask: Corestore + Hyperswarm + ChatRoom
├─ forge.config.js # Electron Forge makers + signing hooks
├─ input.css # Tailwind v4 entrypoint
├─ package.json # app metadata, scripts, deps
├─ pear.json # multisig namespace for OTA releases
└─ schema.js # regenerates spec/ from schema definitionsThe four "moving parts" are electron/, workers/, spec/, and renderer/. Everything else is plumbing or build config.
Read the Bare chat worker
The worker is the only place P2P code lives. The renderer never imports Hyperswarm or Corestore; the main process barely touches them. That isolation keeps native modules out of the browser-style sandbox and off Electron's main thread.
Open workers/index.js. It is the Bare-side entrypoint and is intentionally tiny:
const FramedStream = require('framed-stream')
const fs = require('bare-fs')
const goodbye = require('graceful-goodbye')
const { command, flag } = require('paparam')
const path = require('bare-path')
const WorkerTask = require('./worker-task.js')
const cmd = command('pear-chat',
flag('--invite|-i <invite>', 'Room invite'),
flag('--name|-n <name>', 'Your name'),
flag('--reset', 'Reset')
)
const storage = path.join(Bare.argv[2], 'corestore')
cmd.parse(Bare.argv.slice(3))
async function main () {
if (cmd.flags.reset) {
await fs.promises.rm(storage, { recursive: true, force: true })
}
const pipe = new FramedStream(Bare.IPC)
pipe.pause()
const workerTask = new WorkerTask(pipe, storage, cmd.flags)
goodbye(() => workerTask.close())
await workerTask.ready()
pipe.resume()
console.log(`Storage: ${storage}`)
console.log(`Name: ${workerTask.name}`)
console.log(`Invite: ${await workerTask.room.getInvite()}`)
}
main().catch((err) => {
console.error(err)
Bare.exit(1)
})Things to notice:
- Lines 9–13: the worker declares its flags with
paparam:--invite/-i(the room code a joining peer pastes),--name/-n, and--reset. - Lines 15–16:
Bare.argv[2]is the storage directory the main process passes when it spawns the worker; the remaining flags are parsed on line 16. - Lines 23–24: the worker wraps
Bare.IPCin aframed-stream, then pauses it until the task is ready. From here on, worker and renderer talk by exchanging JSON over this framed pipe — no typed RPC layer. - Lines 26–27 and 29–30:
WorkerTaskextendsready-resourceso open/close are explicit lifecycle steps.graceful-goodbyeregisters itsclose()for shutdown. Once ready, line 30 resumes the stream so buffered frames flow. - Lines 32–34: the
console.loglines print the storage path, the chosen name, and the room invite — handy when running two peers from the terminal.
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 the renderer over the 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 canonical Pear shape:
- 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 renderer IPC (JSON parsed off the pipe, L35–L45), then push the room's invite (L48) and the first batch of messages (L49) to the renderer._peers()(L58–L61) — every connect/disconnect writes a{ type: 'peers', count }frame, which lights the renderer's status dot._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 the bare scaffold are the peer counter and the invite push (L48) — the worker hands the renderer the room code so the UI can show a working "Copy invite" button.
For why P2P state is owned by the worker rather than the renderer, read Workers.
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 (this is the string the worker pushes to the renderer's copy button):
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 the renderer.
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.
Read the Electron main process
Open electron/main.js. The whole app is CommonJS — mirroring hello-pear-electron. The main process keeps Electron's UI thread free of P2P work by spawning two workers — separate Bare processes — and brokering messages between them and the renderer.
Chat worker
The chat worker (workers/index.js) owns the Corestore, swarm, and ChatRoom. The main process spawns it and relays its byte streams to every window. Because Bare.IPC is a raw byte stream, the main process wraps the worker in a FramedStream and forwards deframed frames:
function getWorker (specifier) {
if (workers.has(specifier)) return workers.get(specifier)
const storage = path.join(getStorageDir(), 'app-storage')
const worker = PearRuntime.run(require.resolve('..' + specifier), [storage, ...passthroughArgs])
const pipe = new FramedStream(worker)
function sendWorkerStdout (data) {
process.stdout.write(data)
sendToAll('pear:worker:stdout:' + specifier, data)
}
function sendWorkerStderr (data) {
process.stderr.write(data)
sendToAll('pear:worker:stderr:' + specifier, data)
}
function sendWorkerIPC (data) {
sendToAll('pear:worker:ipc:' + specifier, data)
}
function onBeforeQuit () {
pipe.destroy()
}
ipcMain.handle('pear:worker:writeIPC:' + specifier, (evt, data) => {
return pipe.write(data)
})
workers.set(specifier, pipe)
pipe.on('data', sendWorkerIPC)
worker.stdout.on('data', sendWorkerStdout)
worker.stderr.on('data', sendWorkerStderr)
worker.once('exit', (code) => {
app.removeListener('before-quit', onBeforeQuit)
ipcMain.removeHandler('pear:worker:writeIPC:' + specifier)
pipe.removeListener('data', sendWorkerIPC)
worker.stdout.removeListener('data', sendWorkerStdout)
worker.stderr.removeListener('data', sendWorkerStderr)
sendToAll('pear:worker:exit:' + specifier, code)
workers.delete(specifier)
})
app.on('before-quit', onBeforeQuit)
return pipe
}getWorker spawns the worker with the PearRuntime.run shortcut and fans four named IPC channels out to every window (pear:worker:ipc, :stdout, :stderr, :exit), plus a pear:worker:writeIPC handler the renderer calls to send bytes back.
Updater worker
The updater worker (workers/main.js) runs the pear-runtime OTA updater with its own swarm and Corestore, downloads new application drives from peers, and emits updating/updated. The main process does not embed new PearRuntime({ ... }); like the template, it keeps the updater in its own Bare worker so update traffic never blocks the chat. The main process only spawns it, wraps it in a FramedStream, and relays pear:event:updating / pear:event:updated:
function getUpdaterPipe () {
if (updaterPipe) return updaterPipe
const dir = getStorageDir()
const appPath = getAppPath()
const extension = isLinux ? '.AppImage' : isMac ? '.app' : '.msix'
const worker = PearRuntime.run(require.resolve('..' + updaterSpecifier), [
dir,
appPath,
updates,
version,
upgrade,
productName + extension
])
const pipe = new FramedStream(worker)
function onData (data) {
const message = data.toString()
if (message === 'updating') sendToAll('pear:event:updating', 'updating')
else if (message === 'updated') sendToAll('pear:event:updated', 'updated')
}
function onStderr (data) {
process.stderr.write(data)
}
function onBeforeQuit () {
pipe.destroy()
}
pipe.on('data', onData)
worker.stderr.on('data', onStderr)
worker.once('exit', () => {
app.removeListener('before-quit', onBeforeQuit)
pipe.removeListener('data', onData)
worker.stderr.removeListener('data', onStderr)
updaterPipe = null
})
app.on('before-quit', onBeforeQuit)
updaterPipe = pipe
return pipe
}applyUpdate swaps the app on disk; app:afterUpdate relaunches the process. The renderer wires both into a button:
ipcMain.handle('pear:applyUpdate', () => {
const pipe = getUpdaterPipe()
return new Promise((resolve) => {
function onData (data) {
if (data.toString() === 'pear:updateApplied') {
pipe.removeListener('data', onData)
resolve()
}
}
pipe.on('data', onData)
pipe.write('pear:applyUpdate')
})
})
ipcMain.handle('pear:startWorker', (evt, filename) => {
getWorker(filename)
return true
})
ipcMain.handle('app:afterUpdate', () => {
if (isLinux && process.env.APPIMAGE) {
app.relaunch({
execPath: process.env.APPIMAGE,
args: [
'--appimage-extract-and-run',
...process.argv.slice(1).filter((arg) => arg !== '--appimage-extract-and-run')
]
})
} else if (!isWindows) {
app.relaunch()
}
app.quit()
})Single-instance lock + deep links
requestSingleInstanceLock makes a pear-chat:// deep link from the OS go to the running instance instead of spawning a second one. setAsDefaultProtocolClient(protocol) registers the scheme:
app.setAsDefaultProtocolClient(protocol)
app.on('open-url', (evt, url) => {
evt.preventDefault()
handleDeepLink(url)
})
const lock = app.requestSingleInstanceLock()
if (!lock) {
app.quit()
} else {
app.on('second-instance', (evt, args) => {
const url = args.find((arg) => arg.startsWith(protocol + '://'))
if (url) handleDeepLink(url)
})
app.whenReady().then(() => {
createWindow()
.then(() => getUpdaterPipe())
.catch((err) => {
console.error('Failed to create window:', err)
app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow().catch((err) => {
console.error('Failed to create window:', err)
})
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
}Expose the bridge in electron/preload.js
The renderer never sees the worker handle directly. It only sees window.bridge:
const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('bridge', {
pkg () {
return ipcRenderer.sendSync('pkg')
},
getPathForFile: (file) => webUtils.getPathForFile(file),
writeClipboard: (text) => ipcRenderer.invoke('clipboard:write', text),
applyUpdate: () => ipcRenderer.invoke('pear:applyUpdate'),
appAfterUpdate: () => ipcRenderer.invoke('app:afterUpdate'),
onPearEvent: (name, listener) => {
const wrap = (evt, eventName) => listener(eventName)
ipcRenderer.on('pear:event:' + name, wrap)
return () => ipcRenderer.removeListener('pear:event:' + name, wrap)
},
startWorker: (specifier) => ipcRenderer.invoke('pear:startWorker', specifier),
onWorkerStdout: (specifier, listener) => {
const wrap = (evt, data) => listener(new Uint8Array(data))
ipcRenderer.on('pear:worker:stdout:' + specifier, wrap)
return () => ipcRenderer.removeListener('pear:worker:stdout:' + specifier, wrap)
},
onWorkerStderr: (specifier, listener) => {
const wrap = (evt, data) => listener(new Uint8Array(data))
ipcRenderer.on('pear:worker:stderr:' + specifier, wrap)
return () => ipcRenderer.removeListener('pear:worker:stderr:' + specifier, wrap)
},
onWorkerIPC: (specifier, listener) => {
const wrap = (evt, data) => listener(new Uint8Array(data))
ipcRenderer.on('pear:worker:ipc:' + specifier, wrap)
return () => ipcRenderer.removeListener('pear:worker:ipc:' + specifier, wrap)
},
onWorkerExit: (specifier, listener) => {
const wrap = (evt, code) => listener(code)
ipcRenderer.on('pear:worker:exit:' + specifier, wrap)
return () => ipcRenderer.removeListener('pear:worker:exit:' + specifier, wrap)
},
writeWorkerIPC: (specifier, data) => {
return ipcRenderer.invoke('pear:worker:writeIPC:' + specifier, data)
}
})This is the single door between the HTML renderer and the Bare workers. It is intentionally narrow — startWorker, writeWorkerIPC, onWorkerIPC, onWorkerExit for the worker, onPearEvent / applyUpdate / appAfterUpdate for OTA, and one extra this part adds:
writeClipboard(line 8), which the copy-invite button calls.- The renderer sends
{ type: 'add-message', text }and receives{ type: 'messages' },{ type: 'peers' }, and{ type: 'invite' }— all plain JSON over the bridge.
For why this split exists, read the process model.
Read the renderer
The renderer is vanilla HTML + JavaScript — no framework, no bundler — styled with Tailwind CSS v4 compiled by the CLI (the build:tailwind script), not the part-1 CDN script. Two files matter.
renderer/index.html
The static chat shell:
- a header with the Pear logo, a peer-status dot,
- a Copy invite button,
- an "Update ready" button (hidden until OTA fires),
- a scrollable message list,
- a composer form, and
- every class is a Tailwind utility scanned out of the markup at build time:
<header class="px-6 py-4 border-b border-neutral-900 flex items-center justify-between gap-4">
<div class="flex items-center gap-2.5">
<svg class="h-5 w-auto" viewBox="0 0 265 358" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M123.036 0H141.964V35.3525H123.036V0Z" fill="#B0D944"/>
<path d="M113.572 46.5399V53.6999H94.643V62.6499H170.357V53.6999H151.429V39.8274H132.5V46.5399H113.572Z" fill="#B0D944"/>
<path d="M189.286 67.1249H132.5V73.8374H75.7144V89.9474H189.286V67.1249Z" fill="#B0D944"/>
<path d="M208.214 94.4224H132.5V101.135H56.7858V117.245H208.214V94.4224Z" fill="#B0D944"/>
<path d="M208.214 121.72H132.5V128.433H56.7858V144.543H208.214V121.72Z" fill="#B0D944"/>
<path d="M227.143 149.018H132.5V155.73H37.8573V171.84H227.143V149.018Z" fill="#B0D944"/>
<path d="M227.143 176.315H132.5V183.028H37.8573V199.138H227.143V176.315Z" fill="#B0D944"/>
<path d="M246.071 203.613H132.5V210.325H18.9286V226.435H246.071V203.613Z" fill="#B0D944"/>
<path d="M265 230.91H132.5V237.623H0V253.733H265V230.91Z" fill="#B0D944"/>
<path d="M265 258.208H132.5V264.92H0V281.03H265V258.208Z" fill="#B0D944"/>
<path d="M265 285.505H132.5V292.218H0V308.328H265V285.505Z" fill="#B0D944"/>
<path d="M227.143 312.803H132.5V319.515H37.8573V335.625H227.143V312.803Z" fill="#B0D944"/>
<path d="M189.286 340.1H132.5V346.812H75.7144V358H189.286V340.1Z" fill="#B0D944"/>
</svg>
<h1 class="text-base font-medium tracking-tight">Pear Chat</h1>
<span id="version" class="text-xs text-neutral-600"></span>
<button id="update" type="button"
class="hidden rounded-full bg-lime-400/15 px-3 py-1 text-xs font-medium text-lime-300 hover:bg-lime-400/25 transition">
Update ready — restart
</button>
</div>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1.5 text-xs text-neutral-500">
<span id="dot" class="size-2 rounded-full bg-neutral-600"></span>
<span id="peers">0 peers</span>
</span>
<button id="copy-invite" type="button" disabled
class="rounded-full border border-neutral-800 bg-neutral-900 px-3.5 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-40 transition">
Copy invite
</button>
</div>
</header>renderer/app.js
The entrypoint. It talks to the worker entirely through window.bridge. The composer sends { type: 'add-message', text }; the worker pushes back messages, peers, and invite frames:
bridge.startWorker(SPECIFIER)
const offWorkerIPC = bridge.onWorkerIPC(SPECIFIER, (data) => {
let message
try {
message = JSON.parse(decoder.decode(data))
} catch {
return
}
if (message.type === 'messages') renderMessages(message.messages)
else if (message.type === 'peers') renderPeers(message.count)
else if (message.type === 'invite') {
invite = message.invite
copyEl.disabled = false
}
})The copy button writes the cached invite to the clipboard through the bridge:
copyEl.addEventListener('click', () => {
if (!invite) return
bridge.writeClipboard(invite)
copyEl.textContent = 'Copied!'
setTimeout(() => { copyEl.textContent = 'Copy invite' }, 1500)
})And the OTA banner is driven entirely by bridge.onPearEvent — inert in development, live once part 4 ships a second version:
// OTA update banner, driven by the updater worker via pear:event:* (see electron/main.js).
updateEl.addEventListener('click', async () => {
updateEl.disabled = true
updateEl.textContent = 'restarting…'
await bridge.applyUpdate()
await bridge.appAfterUpdate()
})
bridge.onPearEvent('updating', () => { versionEl.textContent = 'updating…' })
bridge.onPearEvent('updated', () => { updateEl.classList.remove('hidden') })Peer-supplied text is always written with textContent, never innerHTML, so a message can't inject markup.
The split is deliberate. The renderer treats the worker like a remote API — and that's exactly what it is. Swap the worker specifier and the same plumbing works.
Run two peers locally
Corestore takes an exclusive lock on its directory, so each peer needs its own --storage. From the project root:
npm run build
# user1: create the room + print an invite
npm start -- --storage /tmp/pear-chat-user1 --name user1Watch the terminal for an Invite: … line — or click Copy invite in the window. Copy that z32-encoded string.
In a second terminal, still in pear-chat/:
npm start -- --storage /tmp/pear-chat-user2 --name user2 --invite <paste-invite>A second window opens. Within a few seconds the two peers pair via blind-pairing, the second becomes a writer on the shared Autobase, and both windows show the message history and tick the peer dot to green. Type in one window, hit Enter, and watch it appear in the other.
Both peers persist messages under --storage. Close the apps, reopen them with the same paths, and the conversation is still there. To wipe a peer's local state:
npm start -- --storage /tmp/pear-chat-user1 --name user1 --resetnpm start already forwards --no-updates, so the OTA updater stays inert in development — part 4 flips it on.
What you built
A complete P2P desktop chat with persistence, pairing, OTA-update wiring, and three-platform packaging config — all on the canonical Pear + Electron template.
| Layer | File | Concept |
|---|---|---|
| Distribution | forge.config.js, build/, pear.json | Build desktop distributables |
| Shell | electron/main.js, electron/preload.js | Pear desktop application architecture |
| Updater | workers/main.js (PearRuntime OTA in Bare) | Runtime |
| Worker | workers/index.js, workers/worker-task.js | Workers |
| Room | workers/chat-room.js | Autobase + blind-pairing |
| Storage | Corestore, HyperDB view | Corestore, Hyperbee |
| Transport | electron/preload.js, framed worker pipe | JSON messages over window.bridge IPC |
| UI | renderer/index.html, renderer/app.js | Vanilla DOM + Tailwind CSS; copy-invite + peer dot |
Where to go next
- Continue the path: Ship your app (part 3 of 4) — mint a
pear://link, build per-OS distributables, and stage your first release. Then Deploy over-the-air updates (part 4 of 4) ships a second version live and the OTA banner you wired here goes hot.
Or pick a capability to add on top of this scaffold — each how-to under the How To guides documents only the delta over this exact code:
- Add blind peering to a chat app — keep the room online when its writers are offline.
- Add Keet identity to a chat app — anchor messages to a portable identity key.
- Host multiple rooms in one chat app — extend from one room to an account with many.
- Share files in a peer-to-peer app — swap the room's HyperDB view for a Hyperdrive.
Or zoom out to the conceptual picture:
- Pear desktop application architecture — why a production app splits OTA updates, storage, and workers.
- Workers — what a Bare worker is and why P2P state lives there.
- Release pipeline — staging links, multisig, and the OTA loop.