Add persistence with Corestore
Second of four onboarding steps: switch to new PearRuntime({ ... }), wire OTA events through a bridge, and persist the chat transcript with Corestore.
This is part 2 of 4 in the getting started path. You take the working chat from part 1 and reshape it to match the official hello-pear-electron template — the same template the Holepunch team ships and the same shape Keet and PearPass use under the hood. The biggest user-visible change is a Corestore-backed transcript so the chat survives a restart; under the hood you also wire OTA events, a single-instance lock, a paparam CLI, and a worker proxy.
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.
By the end you have:
- The full
new PearRuntime({ ... })instance form, with a Corestore and a Hyperswarm the runtime uses to replicate updates. - A preload
bridgethat mirrorshello-pear-electron's API surface (startWorker,onWorkerIPC,writeWorkerIPC,onPearEvent,applyUpdate). paparamflags (--storage,--no-updates) so you can run two local peers from one packaged binary.- A single-instance lock and a
pear-chat://deep-link handler. - A chat transcript persisted on disk, replayed into the UI on every launch.
- OTA event wiring ready for part 3 and part 4 to flip on.
Before you start
You need the working project from part 1 — build the peer-to-peer chat.
What you'll change
| File | Change |
|---|---|
package.json | Add corestore, paparam, which-runtime, bare-path; add an upgrade placeholder link; flip start to forward --no-updates. |
electron/main.js | Rewrite around new PearRuntime({ ... }), paparam, which-runtime, single-instance lock, deep-link handler, and a getWorker helper that forwards pear:worker:* IPC to every window. |
electron/preload.js | Expose window.bridge with pkg, applyUpdate, appAfterUpdate, onPearEvent, startWorker, onWorkerStdout, onWorkerStderr, onWorkerIPC, onWorkerExit, writeWorkerIPC. |
workers/main.mjs | Open a Corestore on Bare.argv[2] (the storage path), keep a chat-transcript Hypercore, replay it on startup, append every message. |
renderer/app.js | Switch from window.chat to window.bridge; start the worker through the bridge; render an "Update ready!" button driven by bridge.onPearEvent. |
The renderer/index.html from part 1 needs one small addition (the update button); the rest stays put.
Install the new dependencies
From the pear-chat folder:
npm install corestore@^7.9.1 paparam@^1.10.0 which-runtime@^1.3.2 bare-path@^3.0.0corestoremanages one on-disk store and hands you named Hypercores. The runtime needs it for its update drive; the worker uses it for the chat transcript.paparamis Pear's tiny command-and-flag parser. We use it for--storage,--no-updates, and--no-sandbox.which-runtimeexposesisMac,isLinux,isWindowsbooleans so you can pick the correct per-OS storage directory.bare-pathis the Bare-friendlypathmodule the worker uses (Bare does not have Node's built-inpath).
Modify package.json
-
Add an
upgradeplaceholder topackage.jsonTheupgradelink below is the publichello-pear-electronlink — a workingpear://address you can use as a placeholder so the runtime initialises cleanly. Part 3 replaces it with your own link frompear touch. -
Tweak
startto forward--no-updatesin development. In development, you typically want to start the app without OTA updates, so you can test the app's functionality without having to wait for the update to be downloaded and applied. -
Add a
productNamefield. This is the user-facing app nameelectron-forgeuses when it builds distributables (the.app/.dmg/.msixyou see in Finder or Explorer). If you don't have aproductNamefield,electron-forgewill use thenamefield as the product name.The two fields play different roles and have different rules:
nameis the package identifier. Pear'spear stagerequires it to be a single lowercase word — letters, numbers, hyphens (-), underscores (_), forward slashes (/), or asperands (@) only. It also becomes yourpear-chat://deep-link scheme.productNameis the display name. It can contain uppercase letters and any characters that are valid in a filesystem path, since it shows up in distributable filenames and the app's window title.
{
"name": "pear-chat",
"productName": "PearChat",
"version": "1.0.0",
"upgrade": "pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o",
"main": "electron/main.js",
"type": "commonjs",
"scripts": {
"start": "electron . --no-updates"
}
}Replace electron/main.js with the instance form
This file gets larger because it does five jobs at once:
- Parse CLI flags
- Resolve a per-OS storage directory
- Construct the
PearRuntimeinstance - Manage worker lifecycle
- Broadcast OTA events to every renderer
It mirrors hello-pear-electron/electron/main.js almost line-for-line.
const { app, BrowserWindow, ipcMain } = require('electron')
const os = require('os')
const path = require('path')
const crypto = require('crypto')
const Hyperswarm = require('hyperswarm')
const Corestore = require('corestore')
const PearRuntime = require('pear-runtime')
const { isMac, isLinux, isWindows } = require('which-runtime')
const { command, flag } = require('paparam')
const pkg = require('../package.json')
const { name, productName, version, upgrade } = pkg
const protocol = name // used for pear-chat:// deep links
const appName = productName ?? name
// Same per-username topic as part 1
const topic = crypto
.createHash('sha256')
.update('pear-getting-started-chat:' + os.userInfo().username)
.digest('hex')
const workers = new Map()
let pear = null
// 1. Parse CLI flags
const cmd = command(
appName,
flag('--storage <dir>', 'pass custom storage to pear-runtime'),
flag('--no-updates', 'start without OTA updates'),
flag('--no-sandbox', 'start without Chromium sandbox').hide()
)
cmd.parse(app.isPackaged ? process.argv.slice(1) : process.argv.slice(2))
const pearStore = cmd.flags.storage
const updates = cmd.flags.updates
if (pearStore) app.setPath('userData', pearStore)
ipcMain.on('pkg', (evt) => {
evt.returnValue = pkg
})
// 2. Resolve a per-OS storage directory
function getAppPath() {
if (!app.isPackaged) return null
if (isLinux && process.env.APPIMAGE) return process.env.APPIMAGE
if (isWindows) return process.execPath
return path.join(process.resourcesPath, '..', '..')
}
function getPear() {
if (pear) return pear
const appPath = getAppPath()
let dir = null
if (pearStore) {
dir = pearStore
} else if (appPath === null) {
dir = path.join(os.tmpdir(), 'pear', appName)
} else if (isMac) {
dir = path.join(os.homedir(), 'Library', 'Application Support', appName)
} else if (isLinux) {
dir = path.join(os.homedir(), '.config', appName)
} else {
dir = path.join(os.homedir(), 'AppData', 'Local', appName)
}
const extension = isLinux ? '.AppImage' : isMac ? '.app' : '.msix'
const store = new Corestore(path.join(dir, 'pear-runtime/corestore'))
const swarm = new Hyperswarm()
// 3. Construct the PearRuntime instance
pear = new PearRuntime({
dir,
app: appPath,
updates,
version,
upgrade,
name: productName + extension,
store,
swarm,
// Tutorial setting: detect new releases immediately. The default polls
// with a random delay of up to one hour after the 60s boot grace period —
// great for production (no thundering herd on seeders) but hides the OTA
// flow from anyone watching live. Remove or raise for production builds.
delay: 0
})
if (updates !== false) {
swarm.on('connection', (connection) => store.replicate(connection))
swarm.join(pear.updater.drive.core.discoveryKey, {
client: true,
server: false
})
}
pear.on('error', console.error)
return pear
}
function sendToAll(channel, data) {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(channel, data)
}
}
// 4. Manage worker lifecycle
function getWorker(specifier) {
if (workers.has(specifier)) return workers.get(specifier)
const pear = getPear()
const worker = pear.run(require.resolve('..' + specifier), [pear.storage, topic])
const onIPC = (data) => sendToAll('pear:worker:ipc:' + specifier, data)
const onStdout = (data) => sendToAll('pear:worker:stdout:' + specifier, data)
const onStderr = (data) => sendToAll('pear:worker:stderr:' + specifier, data)
ipcMain.handle('pear:worker:writeIPC:' + specifier, (_evt, data) => {
return worker.write(Buffer.from(data))
})
workers.set(specifier, worker)
worker.on('data', onIPC)
worker.stdout.on('data', onStdout)
worker.stderr.on('data', onStderr)
worker.once('exit', (code) => {
ipcMain.removeHandler('pear:worker:writeIPC:' + specifier)
worker.removeListener('data', onIPC)
worker.stdout.removeListener('data', onStdout)
worker.stderr.removeListener('data', onStderr)
sendToAll('pear:worker:exit:' + specifier, code)
workers.delete(specifier)
})
return worker
}
// Bare workers spawn as plain child processes and do NOT die when Electron
// exits. Always destroy them on quit and before applying an update — otherwise
// the orphaned worker keeps an exclusive lock on the Corestore storage and the
// next launch hangs on `await store.ready()`.
async function destroyWorkers() {
const pending = []
for (const worker of workers.values()) {
pending.push(
new Promise((resolve) => {
worker.once('exit', resolve)
worker.destroy()
})
)
}
workers.clear()
await Promise.all(pending)
}
app.on('before-quit', (evt) => {
if (workers.size === 0) return
evt.preventDefault()
destroyWorkers().finally(() => app.quit())
})
async function createWindow() {
const win = new BrowserWindow({
width: 480,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
sandbox: true,
nodeIntegration: false,
contextIsolation: true
}
})
const pear = getPear()
// 5. Broadcast OTA events to every renderer
const onUpdating = () => {
if (!win.isDestroyed()) win.webContents.send('pear:event:updating')
}
const onUpdated = () => {
if (!win.isDestroyed()) win.webContents.send('pear:event:updated')
}
pear.updater.on('updating', onUpdating)
pear.updater.on('updated', onUpdated)
win.on('closed', () => {
pear.updater.removeListener('updating', onUpdating)
pear.updater.removeListener('updated', onUpdated)
})
await win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
}
ipcMain.handle('pear:applyUpdate', async () => {
const pear = getPear()
await pear.updater.applyUpdate()
})
ipcMain.handle('pear:startWorker', (_evt, specifier) => {
getWorker(specifier)
return true
})
ipcMain.handle('app:afterUpdate', async () => {
await destroyWorkers()
if (isLinux && process.env.APPIMAGE) {
app.relaunch({
execPath: process.env.APPIMAGE,
args: ['--appimage-extract-and-run', ...process.argv.slice(1).filter((a) => a !== '--appimage-extract-and-run')]
})
} else if (!isWindows) {
app.relaunch()
}
app.exit(0)
})
function handleDeepLink(url) {
console.log('deep link:', url)
}
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((a) => a.startsWith(protocol + '://'))
if (url) handleDeepLink(url)
})
app.whenReady().then(() => {
createWindow().catch((err) => {
console.error('failed to create window:', err)
app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (!isMac) app.quit()
})
}What changed from part 1, in the order it appears in the file:
paparamcommand + flags.paparamparses--storage,--no-updates, and--no-sandbox.--storagelets you point a second running copy at a different folder so its Corestore does not collide with the first.--no-updateslets development bypass the OTA flow.- Per-OS
dirresolution. Production builds land at~/Library/Application Support/<appName>on macOS,~/.config/<appName>on Linux, and%USERPROFILE%\AppData\Local\<appName>on Windows. In development (no packaged app) the runtime falls back to<tmpdir>/pear/<appName>. The decision is the same onehello-pear-electrondocuments in its Storage section. For the full picture see Storage and distribution. new PearRuntime({ ... }). Instead of the staticPearRuntime.run()helper from part 1, you instantiate a runtime. It owns a Corestore (store) and a Hyperswarm (swarm); they are needed for the OTA updater to replicate the application drive from peers. When updates are on,swarm.join(pear.updater.drive.core.discoveryKey, ...)looks for seeders of your upgrade link.delay: 0makes the updater check for new content as soon as it arrives — see TunePearRuntimedelayfor live OTA visibility for the production trade-off.getWorker(specifier)pattern. The Electron main process becomes a thin proxy: any window can askbridge.startWorker(specifier), and the worker's IPC, stdout, stderr, and exit events get fanned out to every window overpear:worker:*channels. This is the same surfacehello-pear-electronuses, so the same renderer code can work against any worker you add later.- Worker argv:
[pear.storage, topic]. The first argument matcheshello-pear-electron's Workers convention:pear.storageis the runtime-managed directory the worker can put its data in. The second argument is the same per-username topic hex from part 1. - OTA event fan-out.
pear.updater.on('updating'|'updated')is forwarded to every window viawebContents.send('pear:event:*').applyUpdateswaps the app on disk;appAfterUpdatedestroys live workers and restarts the process. The renderer wires those into a button. destroyWorkers()before quit or relaunch. Bare workers are plain child processes — they do not exit when Electron does. If you let them outlive the parent, the next launch hangs onawait store.ready()because Corestore holds an exclusive lock on<storage>/chat-corestore. Thebefore-quithandler defers the actual quit until every worker has emittedexit;app:afterUpdateawaits the same helper before callingapp.exit(0). See Worker did not exit cleanly.- Single-instance lock + deep links.
requestSingleInstanceLockensures apear-chat://deep link from the OS goes to the running instance instead of spawning a second one.setAsDefaultProtocolClient(protocol)registers the scheme.
Expose the bridge in electron/preload.js
The preload script is now bigger because it exposes the same bridge API every hello-pear-electron-shaped app uses:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('bridge', {
pkg() {
return ipcRenderer.sendSync('pkg')
},
applyUpdate: () => ipcRenderer.invoke('pear:applyUpdate'),
appAfterUpdate: () => ipcRenderer.invoke('app:afterUpdate'),
onPearEvent: (name, listener) => {
const wrap = () => listener(name)
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(Buffer.from(data))
ipcRenderer.on('pear:worker:stdout:' + specifier, wrap)
return () => ipcRenderer.removeListener('pear:worker:stdout:' + specifier, wrap)
},
onWorkerStderr: (specifier, listener) => {
const wrap = (_evt, data) => listener(Buffer.from(data))
ipcRenderer.on('pear:worker:stderr:' + specifier, wrap)
return () => ipcRenderer.removeListener('pear:worker:stderr:' + specifier, wrap)
},
onWorkerIPC: (specifier, listener) => {
const wrap = (_evt, data) => listener(Buffer.from(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)
}
})The renderer no longer needs a custom chat protocol — it speaks to any worker through the same set of IPC channels.
Persist the chat transcript in workers/main.mjs
The worker now reads Bare.argv[2] as the storage directory and Bare.argv[3] as the topic hex. It opens a Corestore, keeps a single named Hypercore as the chat transcript, replays it before joining the swarm, and appends every message it sees:
import path from 'bare-path'
import Hyperswarm from 'hyperswarm'
import Corestore from 'corestore'
import b4a from 'b4a'
const storage = Bare.argv[2]
const topic = b4a.from(Bare.argv[3], 'hex')
const store = new Corestore(path.join(storage, 'chat-corestore'))
await store.ready()
const core = store.get({ name: 'chat-transcript' })
await core.ready()
// Replay anything already on disk so the UI matches the last session
for (let i = 0; i < core.length; i++) {
Bare.IPC.write(b4a.toString(await core.get(i)))
}
const swarm = new Hyperswarm()
const conns = []
swarm.on('connection', (conn) => {
const id = b4a.toString(conn.remotePublicKey, 'hex').slice(0, 6)
conns.push(conn)
Bare.IPC.write(JSON.stringify({ type: 'peers', count: conns.length }))
conn.on('data', async (data) => {
const payload = JSON.stringify({ type: 'message', from: id, text: b4a.toString(data) })
await core.append(b4a.from(payload, 'utf8'))
Bare.IPC.write(payload)
})
conn.on('error', () => {})
conn.once('close', () => {
conns.splice(conns.indexOf(conn), 1)
Bare.IPC.write(JSON.stringify({ type: 'peers', count: conns.length }))
})
})
Bare.IPC.on('data', async (data) => {
const text = b4a.toString(data)
const payload = JSON.stringify({ type: 'message', from: 'you', text })
await core.append(b4a.from(payload, 'utf8'))
for (const conn of conns) conn.write(text)
})
await swarm.join(topic, { client: true, server: true }).flushed()
Bare.IPC.write(JSON.stringify({ type: 'ready' }))Notice:
- The store lives under
<storage>/chat-corestore. The Electron main process owns<storage>itself (the runtime's update drive is also under there inpear-runtime/corestore). Keeping the chat data in its own subdirectory keeps the runtime's append-only update log separate from your application data. - Every emitted line is a complete JSON event. The same string is appended to the Hypercore and sent over IPC, so replay drops straight into the renderer's existing
onMessagehandler. Corestoretakes an exclusive lock on its directory. Two windows pointing at the same<storage>collide withFile descriptor could not be locked. The next step shows how to give a second window its own--storagedirectory.
Read Workers for the wider pattern — why the worker is the application's local backend, what crosses the IPC boundary, and where the host/worker split sits on mobile and terminal.
Rewire the renderer to use the bridge
The renderer drops window.chat and uses window.bridge instead. It asks the main process to start the worker, then listens on bridge.onWorkerIPC for events. It also renders the OTA update banner.
Add an Update ready! button to renderer/index.html. The whole header now looks like this:
<header class="flex items-center justify-between border-b border-zinc-800 bg-zinc-900/60 px-4 py-3">
<div class="flex items-center gap-2">
<h1 class="text-sm font-semibold">Pear chat</h1>
<span id="v" class="text-xs text-zinc-500"></span>
<button
id="update-btn"
class="hidden rounded-md bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-300 hover:bg-amber-500/30"
>
Update ready!
</button>
</div>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span class="size-2 rounded-full bg-emerald-500"></span>
<span>peers: <span id="peers" class="font-medium text-zinc-100">0</span></span>
</div>
</header>Replace renderer/app.js with:
const decoder = new TextDecoder('utf-8')
document.getElementById('v').textContent = 'v' + window.bridge.pkg().version
window.bridge.onPearEvent('updating', () => {
document.getElementById('v').textContent = 'updating…'
})
window.bridge.onPearEvent('updated', () => {
const btn = document.getElementById('update-btn')
btn.classList.remove('hidden')
btn.onclick = async () => {
btn.disabled = true
btn.textContent = 'restarting…'
try {
await window.bridge.applyUpdate()
await window.bridge.appAfterUpdate()
} catch (err) {
btn.textContent = 'update failed: ' + err.message
}
}
})
const workers = { main: '/workers/main.mjs' }
window.bridge.startWorker(workers.main)
const log = document.getElementById('log')
const peers = document.getElementById('peers')
const input = document.getElementById('input')
const fromColor = {
system: 'text-zinc-500 italic',
you: 'text-emerald-400 font-medium',
peer: 'text-sky-400 font-medium'
}
function append(from, text) {
const row = document.createElement('div')
row.className = 'flex gap-2 items-baseline'
const fromEl = document.createElement('span')
fromEl.className = fromColor[from] ?? fromColor.peer
fromEl.textContent = from + ':'
const textEl = document.createElement('span')
textEl.textContent = text
row.append(fromEl, textEl)
log.appendChild(row)
log.scrollTop = log.scrollHeight
}
window.bridge.onWorkerIPC(workers.main, (data) => {
let event
try {
event = JSON.parse(decoder.decode(data))
} catch {
return
}
if (event.type === 'peers') peers.textContent = event.count
else if (event.type === 'message') append(event.from, event.text)
else if (event.type === 'ready') append('system', 'connected to swarm')
})
window.bridge.onWorkerStderr(workers.main, (data) => {
console.error('[worker]', decoder.decode(data))
})
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || !input.value) return
const text = input.value
input.value = ''
append('you', text)
window.bridge.writeWorkerIPC(workers.main, text)
})contextBridge turns bridge into a global in the renderer scope, so do not write const bridge = window.bridge — that throws Identifier 'bridge' has already been declared. Use window.bridge.* directly.
The Pear concept here: the renderer is now generic. It talks to a named worker through the bridge without knowing what that worker does. Swap '/workers/main.mjs' for a different specifier later and the same plumbing works.
Run two peers locally
Corestore will not let two processes share a folder, so the second instance needs its own --storage:
# terminal 1
npm start
# terminal 2
npm start -- --storage /tmp/pear-chat-peernpm start -- <flags> forwards <flags> after the script command, so this expands to electron . --no-updates --storage /tmp/pear-chat-peer. paparam picks --storage up; the runtime then uses /tmp/pear-chat-peer instead of the per-OS default folder.
The first instance writes to your real per-OS storage directory; the second to /tmp/pear-chat-peer. Both windows show peers: 0, then tick to peers: 1 after Hyperswarm finds them on the DHT (typically 5–15 seconds). Send a few messages, quit both windows, and start them again — your half of the transcript reappears from disk before any live traffic.
The README's Storage → Setting Storage for Additional Instances covers the same trick for packaged binaries on each OS.
What you added
| Piece | Role |
|---|---|
paparam flags | --storage, --no-updates, --no-sandbox — the same surface every Pear Electron binary exposes. |
Per-OS dir resolution | Matches the Storage and distribution table; production builds land in the right OS folder. |
new PearRuntime({ ... }) | The full runtime instance: holds the Corestore, the Hyperswarm, and the updater. |
getWorker(specifier) + bridge | One IPC vocabulary (pear:worker:*) for any worker the app spawns. |
| Corestore-backed transcript | The chat replays from disk on every start. Each window has its own log; they sync on connection only because both speakers also conn.write(...) live. |
| OTA event wiring | `pear.updater.on('updating' |
| Single-instance lock + deep links | pear-chat://... from the OS opens the running instance instead of spawning a fresh one. |
Where to go next
- Continue the path: Ship your app (part 3 of 4) —
pear touch,npm version,electron-forge make,pear-build, andpear stageto publish your first version. Then Deploy over-the-air updates (part 4 of 4) ships a second version live and previewspear provisionand multisig. - For the Architecture explanations that this part mirrors, read Pear desktop application architecture and Storage and distribution.
- The full
hello-pear-electronrepository — the template every snippet here came from.