Bare Docs

Structured RPC and schema-first design

Layering typed request/response calls on top of a raw IPC duplex stream with HRPC (and, below it, bare-rpc), then generating the wire format from one schema with hyperschema, hyperdb, and hyperdispatch.

Raw Inter-Process Communication (IPC) carries bytes, not typed messages. For a handful of one-off signals, length-preserving writes or newline-delimited JSON may be enough (see Compact encoding for binary framing). As a protocol between two processes grows, manually pairing requests and responses becomes error-prone.

This page assumes two Bare (or Bare-and-host) processes talking over an IPC duplex stream—for example a Pear worker and its host. The same code applies to any process pair that shares a duplex stream.

Structured RPC over IPC

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 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 other 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 shell written in a language HRPC has no generator for yet, such as Kotlin, where Type a native RPC bridge uses it with hyperschema for cross-language codecs instead. (Swift and C do have HRPC generators—hrpc-swift and hrpc-c.)

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'))

Share command constants between both sides (a shared commands.mjs module works well).

ApproachBest for
HRPCThe default. Typed stubs, schema keeps both sides in sync as the protocol grows.
tiny-buffer-rpcA handful of methods, no interest in a schema/build step.
bare-rpcThe command-framing layer itself—cross-language bridges, or building your own codegen on top.
Raw IPCA 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 RPC 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. A production Autobase-backed chat room walks a concrete schema.js and its generated spec/ directory.

See also

On this page