Workers
Why a Pear app's peer-to-peer logic lives in a Bare worker behind a single IPC stream—what the pattern buys you, where to put the boundary, and how the host and worker halves talk.
A Pear application splits into two halves:
- a UI (the renderer, terminal, or mobile shell) and
- a worker that owns everything peer-to-peer.
The worker is a Bare process the host spawns at startup, and it's where Hyperswarm, Corestore, Hypercore, and any native addons run.
The two halves only ever see each other through a single Inter-Process Communication (IPC) duplex stream.
This page is about the pattern, not the API. For the call signatures and IPC stream type, see Running workers in the runtime reference.
Workers as a local backend
The mental model is the worker is your application's local backend. The renderer is a client to that backend in the same way a web app is a client to a remote API—it makes requests, it gets streaming events back, it doesn't own state. The difference is the "server" is a sibling process on the same machine, started and stopped by the host.
In a desktop Pear app the host is the Electron main process; in a mobile Pear app it's the Bare iOS / Bare Android shell; in a terminal app it's the pear CLI itself. The worker code is identical across all of them—only the UI half and the bridge that forwards IPC change.
Why the split
Three concrete reasons to put peer-to-peer code in a worker rather than inline in the UI:
Native addons stay out of the renderer
Hypercore, Hyperswarm, sodium-native, and many other Pear building blocks load native modules. Electron's renderer cannot load native code under sandbox: true, and a sandbox: false renderer is a large attack surface. Keeping native code in the worker keeps the renderer untrusted.
The renderer becomes portable
Because the worker never imports DOM APIs and the UI never imports Corestore or Hyperswarm, you can swap the UI for a different framework (or a different platform's UI entirely) without rewriting the peer-to-peer logic. This is what makes Keet's identical experience across desktop, mobile, and terminal possible.
One IPC channel, one place to audit
Every byte that crosses from peers into your application crosses the IPC boundary first. Validation, framing decisions, rate limits, and observability all sit at that one chokepoint instead of being scattered across the UI.
See Runtime and languages for the wider "Pear-end / UI" framing and why it's the recommended app shape.
The IPC contract
The host spawns a worker by calling PearRuntime.run—the static method an Electron main process uses—or its instance alias pear.run, passing the worker's entrypoint and a list of arguments:
const IPC = pear.run('./workers/main.js', [pear.storage])
IPC.on('data', (data) => {
console.log('data from worker', data)
})
IPC.write('hello')IPC is a duplex stream: bytes the host writes show up on the worker side, and bytes the worker writes show up here. Inside the worker the other side of that same stream is Bare.IPC:
const Corestore = require('corestore')
const storage = Bare.argv[2]
Bare.IPC.on('data', (data) => console.log(data.toString()))
Bare.IPC.write('Hello from worker')
const corestore = new Corestore(storage)
// ... open Hypercores, replicate over Hyperswarm, etc.A few things about that snippet aren't obvious if you're new to Bare:
Bare.argv indexing matches Node's process.argv
Bare.argv[0]is the Bare binary,Bare.argv[1]is the worker script path, andBare.argv[2]is the first argument you passed. Anything beyond that lands atBare.argv[3],[4], and so on.
The IPC stream carries bytes, not objects
There's no built-in JSON or length-framing. Either send one message per write() (Bare's IPC preserves write boundaries on the receiving side when the message is small enough not to be split), or pick a framing format like newline-delimited JSON or compact-encoding and stick to it on both ends.
pear.storage is the recommended first argument
It points at a per-app directory the host has already prepared (see Storage and distribution). Worker code stays portable as long as it treats Bare.argv[2] as "wherever the host says my storage is" rather than hard-coding a path.
Structured RPC over IPC
Raw Inter-Process Communication (IPC) carries bytes, not typed messages. That's fine for a one-off signal or two, but manually pairing requests and responses stops scaling almost immediately—HRPC is the recommended default for host↔worker communication beyond that.
HRPC generates typed client and server stubs from a schema. Define request and response types (typically via hyperschema), register methods, run the code generator, and import the result—both sides get real method names instead of numeric command ids to keep straight by hand:
import HRPC from './spec/hrpc/index.js'
const rpc = new HRPC(Bare.IPC)
rpc.onHello(({ world }) => ({
message: `Hello ${world}, from worker`
}))
await rpc.hello({ world: 'host' })The host side constructs new HRPC(IPC) with the same generated module. Method names and encodings stay in sync because both sides compile from one schema definition—see the HRPC reference for the full schema-to-codegen walkthrough.
Two lighter alternatives, for when a schema and build step are more machinery than the protocol needs:
tiny-buffer-rpc gives request/response pairing without any schema or codegen—you register a small integer id with a compact-encoding codec directly at the call site:
import RPC from 'tiny-buffer-rpc'
import c from 'compact-encoding'
const rpc = new RPC((data) => Bare.IPC.write(data))
Bare.IPC.on('data', (data) => rpc.recv(data))
rpc.register(0, {
request: c.string,
response: c.string,
onrequest: (world) => `Hello ${world}, from worker`
})It has no notion of a stream—send is a plain function—so it also works over transports that aren't a duplex, like a WebSocket message handler.
bare-rpc is the thinner layer hrpc itself is built on: a numeric command id and a payload, with handlers dispatching on req.command by hand. Reach for it directly only when hrpc's codegen doesn't fit—for example bridging to a native (Swift/Kotlin) shell, where Type a native RPC bridge uses it with hyperschema for cross-language codecs instead:
import RPC from 'bare-rpc'
export const RPC_MESSAGE = 1
const rpc = new RPC(Bare.IPC, (req) => {
if (req.command === RPC_MESSAGE) {
console.log(req.data.toString())
}
})
const req = rpc.request(RPC_MESSAGE)
req.send(Buffer.from('Hello from worker'))| Approach | Best for |
|---|---|
| hrpc | The default. Typed stubs, schema keeps both sides in sync as the protocol grows. |
| tiny-buffer-rpc | A handful of methods, no interest in a schema/build step. |
| bare-rpc | The command-framing layer itself—cross-language bridges, or building your own codegen on top. |
| Raw IPC | A quick prototype, or genuinely one-shot signals. |
Start with hrpc unless you have a specific reason not to: the schema pays for itself as soon as a method's shape needs to change without breaking the other side mid-protocol.
Schema-first design
Larger apps push this further: instead of hand-writing encoders, they declare every data shape once and generate the byte-level machinery from that single definition. In a peer-to-peer app this is not just ergonomics—it's a correctness requirement, for two reasons a client–server app doesn't face:
No server normalizes the wire format
Peers replicate raw bytes directly to each other, and any two peers may be running different builds. If one peer encodes a message differently than another decodes it, replication produces garbage—there's no central authority to reconcile them. Every peer has to agree on the format ahead of time.
Append-only logs are immutable and permanent
Blocks written to a Hypercore or Autobase are signed and replicated forever; you can't migrate them later. Today's encoding must still decode in next year's build, so the format has to evolve in a backward-compatible way (add optional fields, never renumber existing ones).
The schema-first toolchain solves both by deriving everything from one declaration. A single schema file (run via a build step like npm run build:db) typically generates:
- hyperschema—the canonical field definitions every other generator consumes, using stable field numbering so additions stay compatible.
- hyperdb—typed, compactly-encoded collections for what's stored on disk.
- hyperdispatch—typed encoders for the payloads appended to an Autobase.
- hrpc—the typed worker↔UI stubs from the section above.
Because storage, replication, and the IPC contract are all generated from the same source, they can't drift out of sync, and the generated code is the part you don't edit by hand. This is the role a database schema and API contract play in a client–server stack—but pushed down to the wire and disk format so every peer and every version shares it. Reshape into a production app walks a concrete schema.js and its generated spec/ directory.
Where the boundary should sit
A pragmatic rule of thumb:
- Anything that touches peers, storage, or cryptography belongs on the worker side;
- Anything that touches a screen, a keyboard, or a window belongs on the UI side.
When in doubt, ask "could this code run unchanged if I swapped Electron for a terminal UI?"—if yes, it's worker code; if not, it's UI code.
The renderer in a typical Pear Electron app, then, owns very little: layout, event listeners, and a thin transport that posts user actions to the worker and renders whatever streams back. The worker is where the application actually lives.
Lifecycle and multiple workers
A host can run more than one worker. Common patterns:
- One main worker that owns the application's primary append-only logs and swarm membership. This is the usual shape and what Reshape into a production app (part 2 of the getting started path) builds.
- Additional ephemeral workers for one-off jobs—a heavy import, a video transcode, a synchronous CPU task—that exit when their work is done. These keep the main worker's event loop free.
Each worker is its own Bare process, has its own IPC stream on the host side—the duplex returned by PearRuntime.run—and can be torn down independently. The host is responsible for cleaning up on Electron's before-quit event; see how getWorker() registers an app.on('before-quit', …) listener in the pear-chat tutorial for one way to wire that up.
See also
- Running workers in the runtime reference—covers
pear.run, theIPCduplex stream, andBare.IPCon the worker side. - Runtime and languages—the broader "Pear-end / UI" pattern, Bare's role, and how non-JavaScript code joins in via native addons.
- Pear desktop application architecture—how workers, the preload bridge, and the OTA updater fit together in a real Electron app.
- Storage and distribution—where
pear.storageactually points on each operating system. - Reshape into a production app—a tutorial that wires the hello-pear-electron-shaped
pear-chatworker behind awindow.bridgeAPI. - Corestore reference—the storage factory typically opened on
Bare.argv[2]inside a worker. - Compact encoding reference—binary framing for IPC messages crossing the host/worker boundary.
- The upstream source:
hello-pear-electron's Workers.