Build a peer-to-peer chat
First of four onboarding steps: type along five files to get a working Electron chat that finds peers on the public DHT and streams messages directly.
This is part 1 of 4 in the getting started path. You build a peer-to-peer desktop chat with Pear. By the end you have an Electron app that finds peers on the public distributed hash table (DHT) and streams messages directly between them.
The example mirrors hello-pear-electron—Pear's official Electron template—but uses the simplest form of the runtime (PearRuntime.run) and skips persistence, over-the-air updates, and packaging. Part 2 reshapes it into the full production scaffold with all of those layered on top.
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 small desktop window with a message list, a text input, and a peer counter. Two windows on the same machine—or on a friend's machine on the other side of the world—discover each other through the DHT and stream messages directly.
Each peer shows up under a six-character hex id derived from its public key (line 10 of workers/main.mjs). That's what your worker emits when a new peer connects.
The four moving parts:
pear-runtime—the library that lets a JavaScript host (Electron here) embed Bare workers.- The Bare worker—a separate process that owns the peer-to-peer code. It is your local backend for the renderer.
- Hyperswarm—finds peers on a topic and gives you an end-to-end-encrypted connection.
- The Electron preload bridge—the only door between the browser-style renderer and the worker.
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
Project layout
You create five files in a fresh folder:
pear-chat/
├─ package.json # app metadata, scripts, npm dependencies
├─ electron/
│ ├─ main.js # CommonJS — window + worker + IPC
│ └─ preload.js # CommonJS — exposes window.chat to the renderer
├─ renderer/
│ ├─ index.html # layout + Tailwind CDN script
│ └─ app.js # DOM + window.chat send/receive
└─ workers/
└─ main.mjs # ESM — Hyperswarm (Bare argv/IPC)Set up the project
1. Create the folder and initialise it with npm init -y
mkdir pear-chat && cd pear-chat && npm init -y2. Replace the generated package.json with the following
{
"name": "pear-chat",
"version": "1.0.0",
"main": "electron/main.js",
"type": "commonjs",
"scripts": {
"start": "electron ."
},
"dependencies": {
"b4a": "^1.6.7",
"hyperswarm": "^4.17.0",
"pear-runtime": "^1.1.4"
},
"devDependencies": {
"electron": "^40.2.1"
}
}type: "commonjs" on line 5 keeps .js files using require; the Bare worker uses .mjs later in this part so it stays an ES module. Line 4 points Electron at electron/main.js; lines 6–7 wire npm start.
What each dependency does (lines 9–16):
- Line 12:
pear-runtimelets Electron run a Bare worker. (npm) - Line 11:
hyperswarmdiscovers peers on a topic. - Line 10:
b4ais a small Buffer / Uint8Array helper. - Line 15:
electronis the desktop shell.
3. Install the dependencies with npm install
npm installThe renderer loads Tailwind CSS v4 from a CDN inside index.html (line 7 of renderer/index.html loads @tailwindcss/browser from jsDelivr), so Tailwind does not appear as an npm dependency.
The Bare runtime ships as a prebuilt binary inside bare-sidecar, a transitive dependency of pear-runtime. There is nothing to compile.
Write the worker
The worker is where the peer-to-peer code lives. It runs in a separate Bare process, not in Electron. That keeps the renderer free of native modules and the Electron main process free of swarm logic.
1. Create the worker file
Create workers/main.mjs. The .mjs extension matters: it forces ES module loading regardless of package.json's "type" field.
import Hyperswarm from 'hyperswarm' // topic swarms / DHT discovery
import b4a from 'b4a' // encode/decode binary topic and payloads
// Topic hex string comes from Electron via Bare.argv (see main process step)
const topic = b4a.from(Bare.argv[2], 'hex')
const swarm = new Hyperswarm()
const conns = [] // active peer sockets
swarm.on('connection', (conn) => {
const id = b4a.toString(conn.remotePublicKey, 'hex').slice(0, 6) // short display id
conns.push(conn)
Bare.IPC.write(JSON.stringify({ type: 'peers', count: conns.length }))
conn.on('data', (data) => {
Bare.IPC.write(
JSON.stringify({ type: 'message', from: id, text: b4a.toString(data) })
)
})
conn.on('error', () => {}) // ignore transient socket errors
conn.once('close', () => {
conns.splice(conns.indexOf(conn), 1)
Bare.IPC.write(JSON.stringify({ type: 'peers', count: conns.length }))
})
})
// Text from Electron main → broadcast to every peer
Bare.IPC.on('data', (data) => {
const text = b4a.toString(data)
for (const conn of conns) conn.write(text)
})
await swarm.join(topic, { client: true, server: true }).flushed()
Bare.IPC.write(JSON.stringify({ type: 'ready' }))Three things to notice:
- Lines 1–2, 6: The worker depends on nothing Bare-specific beyond
Bare.IPCandBare.argv.hyperswarmandb4aare normal npm packages that work in both Bare and Node. - Lines 12, 15–16, 28, 34:
Bare.IPCis the worker's pipe back to the Electron main process. Anything you write toBare.IPCshows up as a'data'event on the worker handle in Electron (lines 31–33 ofelectron/main.js). - Line 5:
Bare.argvholds the arguments the main process passes when it spawns the worker.Bare.argv[2]is the first user-supplied arg—the topic hex passed from line 29 ofelectron/main.js.
The worker uses ESM (import on lines 1–2) because that is Bare's recommended convention for new code. The Electron files in the next steps use CommonJS (require) because that is what most Electron projects ship with. The two coexist cleanly thanks to the file-extension split: anything ending in .mjs is ESM; anything ending in .js follows the package.json "type" field (line 5). See Runtime and languages.
Wire up Electron's main process
1. Create the main process file
The main process creates the window and starts the worker. PearRuntime.run() does most of the work; everything else is plumbing.
Create electron/main.js:
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const os = require('os')
const crypto = require('crypto')
const PearRuntime = require('pear-runtime') // spawn Bare workers from Node
// Deterministic per-username topic so two local windows meet,
// while other readers running the same example stay separate by default
const topic = crypto
.createHash('sha256')
.update('pear-getting-started-chat:' + os.userInfo().username)
.digest('hex')
let worker = null // Bare worker handle (duplex stream)
function createWindow() {
const win = new BrowserWindow({
width: 480,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false
}
})
const workerPath = path.join(__dirname, '..', 'workers', 'main.mjs')
worker = PearRuntime.run(workerPath, [topic]) // argv[2] in the worker is the topic hex
worker.on('data', (data) => {
if (!win.isDestroyed()) win.webContents.send('chat:from-worker', data.toString())
})
worker.stderr.on('data', (data) => {
console.error('[worker]', data.toString())
})
ipcMain.handle('chat:send', (_evt, text) => {
worker.write(Buffer.from(text)) // forwarded to Bare.IPC in the worker
})
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (worker) worker.destroy()
app.quit()
})Three things happen here:
- Lines 9–12: The topic is derived from the OS username with a fixed prefix. Two running instances of this app on your machine automatically find each other. Other readers following this tutorial are isolated by default because their usernames produce a different topic hash. Note that two people on different machines who happen to share the same OS username will also land on the same topic and connect unexpectedly. To share a room intentionally with a specific friend, or to guarantee you stay private, change
'pear-getting-started-chat:'on line 11 to any string only the two of you know. - Lines 28–29, 31–33:
PearRuntime.run(workerPath, [topic])is the static helper onpear-runtime(line 5) that spawns a Bare worker. The second argument becomes the worker'sBare.argv—that is how the topic reaches line 5 ofworkers/main.mjs. The call returns a duplex stream whose other end isBare.IPCinside the worker; lines 31–33 forward worker output to the renderer. - Lines 28–29: This is the simplest form of
pear-runtime. Part 2 reshapes this into the production scaffold, where a dedicated Bare worker owns the OTA updater and a shared Corestore.
Add the preload bridge
1. Create the preload script
The renderer runs in a sandboxed browser context with contextIsolation: true (line 22 of electron/main.js). It cannot talk to the worker directly. The preload script is a tiny adapter that exposes a window.chat object the renderer uses.
Create electron/preload.js:
const { contextBridge, ipcRenderer } = require('electron')
// Expose a minimal API to the sandboxed renderer (no direct Node or worker access)
contextBridge.exposeInMainWorld('chat', {
send(text) {
return ipcRenderer.invoke('chat:send', text) // → ipcMain.handle in main
},
onMessage(listener) {
const wrap = (_evt, payload) => listener(JSON.parse(payload))
ipcRenderer.on('chat:from-worker', wrap) // ← webContents.send from main
return () => ipcRenderer.removeListener('chat:from-worker', wrap)
}
})Line 4: contextBridge.exposeInMainWorld puts a single chat object on the renderer's window with two methods:
- Lines 5–7:
send(text)ships a message to the worker through the main process (lines 39–41 ofelectron/main.js). - Lines 8–11:
onMessage(listener)subscribes to anything the worker pushes back (lines 31–33 ofelectron/main.js).
Build the renderer
The renderer is a normal HTML page. Nothing here is Pear-specific—it calls window.chat.send and window.chat.onMessage to send and receive.
Styling uses Tailwind CSS loaded in the page with the v4 browser build from jsDelivr (@tailwindcss/browser). That script compiles utility classes in the browser, which keeps this tutorial to five files with no CSS build step. For a shipped app you switch to the Tailwind CLI or a bundler.
1. Create the index.html file
Create renderer/index.html:
<!doctype html>
<html class="h-full">
<head>
<meta charset="utf-8" />
<title>Pear chat</title>
<!-- Tailwind v4 in the browser (no npm tailwind in this tutorial) -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body class="flex h-full flex-col bg-zinc-950 font-sans text-zinc-100 antialiased">
<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">
<svg class="h-5 w-auto" viewBox="0 0 265 358" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Pear">
<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-sm font-semibold">Pear chat</h1>
</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>
<main id="log" class="flex-1 space-y-1 overflow-y-auto px-4 py-3 text-sm"></main>
<footer class="border-t border-zinc-800 bg-zinc-900/60 px-4 py-3">
<input
id="input"
placeholder="type and press enter"
autofocus
class="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-zinc-100 placeholder:text-zinc-500 focus:border-amber-500/50 focus:outline-none focus:ring-2 focus:ring-amber-500/30"
/>
</footer>
<script src="app.js"></script>
</body>
</html>- Line 7: Tailwind v4 browser build from jsDelivr.
- Lines 14–16: peer counter in the header (
#peers). - Line 19: scrollable message log (
#log). - Lines 21–26: text input (
#input). - Line 28: loads
app.js.
2. Create the app.js file
Create renderer/app.js. It sets up the DOM and listens for events from the worker:
- Lines 29–31: if the event type is
peers, update the counter; ifmessageorready, append a row. - Lines 34–39: when you press Enter, append locally and call
window.chat.send(text)—which flows through lines 5–7 ofpreload.jsand lines 39–41 ofelectron/main.jsto line 28 ofworkers/main.mjs.
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 // keep latest line visible
}
// JSON lines from the worker, forwarded by preload → main → here
window.chat.onMessage((event) => {
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')
})
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || !input.value) return
const text = input.value
input.value = ''
append('you', text)
window.chat.send(text) // preload → main → worker Bare.IPC
})Run it
From the pear-chat folder run:
npm startA small dark window opens. The header shows peers: 0 (line 16 of index.html) and the message log is empty. After about 5 seconds your worker finishes joining the DHT and the log shows system: connected to swarm (line 31 of app.js, triggered by line 34 of workers/main.mjs).
Open a second terminal in the same folder and run npm start again. Within a few seconds the two peers find each other and the header on both windows ticks to peers: 1.
Type hey in one window and press Enter. The other window shows it prefixed with the sender's short peer id—a six-character hex slice of the remote public key.
What is running:
- two Electron processes
- two Bare workers
- one DHT topic
- a direct, end-to-end encrypted peer-to-peer connection between them
First connection typically takes 5–15 seconds while Hyperswarm announces the topic to the DHT. If both peers are on the same machine they connect over the local network; on different networks they hole-punch through the DHT. If two readers happen to share an OS username they find each other on the public DHT—change the prefix string on line 11 of electron/main.js if that bothers you.
What you built
A peer-to-peer desktop chat that runs without a server. Each piece maps to one concept:
| File | What it does | Pear concept |
|---|---|---|
package.json | Declares Electron, Hyperswarm, and pear-runtime as deps | The Pear stack you embed into a JS host |
workers/main.mjs | Hyperswarm + topic + connection handlers | Peer-to-peer code lives in a Bare worker |
electron/main.js | PearRuntime.run() spawns the worker | The worker is your local backend |
electron/preload.js | contextBridge.exposeInMainWorld('chat', { ... }) | The single door from renderer to worker |
renderer/*.{html,js} | Plain DOM, no Pear imports | The view layer is a plain web page |
Three things are deliberately missing—part 2 adds them:
- No persistence. Messages disappear when you close the window. Part 2 adds an Autobase-backed room persisted to disk with Corestore.
- No over-the-air updates. Part 2 wires a dedicated OTA updater worker with
pear.updaterevents. - No packaging.
npm startruns Electron in development mode. Ship your app coverselectron-forge,pear build, and the first stage; Deploy over-the-air updates walks the live OTA loop and thestage → provision → multisigproduction flow.
Where to go next
- Continue the path: Reshape into a production app.
- For the production-grade Electron starter you would actually clone for a real project, see
hello-pear-electron. - To swap Hyperswarm for a direct one-to-one connection by public key, see Connect two peers by key with HyperDHT.
- To understand why Pear ships with two runtimes (Node-style for Electron, Bare for workers), read Runtime and languages.