# Structured RPC and schema-first design



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](/p2p/reference/helpers/compact-encoding) for binary framing). As a protocol between two processes grows, manually pairing requests and responses becomes error-prone.

<Callout type="info">
  This page assumes two Bare (or Bare-and-host) processes talking over an IPC duplex stream—for example a [Pear worker](/pear/explanation/workers#the-ipc-contract) and its host. The same code applies to any process pair that shares a duplex stream.
</Callout>

## Structured RPC over IPC [#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](/p2p/reference/helpers/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](/p2p/reference/helpers/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:

```js
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](/p2p/reference/helpers/hrpc) 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](https://www.npmjs.com/package/tiny-buffer-rpc)** gives request/response pairing without any schema or codegen—you register a small integer id with a [compact-encoding](/p2p/reference/helpers/compact-encoding) codec directly at the call site:

```js
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](/bare/reference/bare/modules)** 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](/bare/how-to/run-on-native/type-a-native-rpc-bridge) uses it with hyperschema for cross-language codecs instead. (Swift and C do have HRPC generators—[`hrpc-swift`](https://github.com/holepunchto/hrpc-swift) and [`hrpc-c`](https://github.com/holepunchto/hrpc-c).)

```js
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).

| Approach                                | Best for                                                                                      |
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
| **[HRPC](/p2p/reference/helpers/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 [#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 [#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 [#append-only-logs-are-immutable-and-permanent]

Blocks written to a [Hypercore](/p2p/reference/building-blocks/hypercore) or [Autobase](/p2p/reference/building-blocks/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](/p2p/reference/helpers/hyperschema)**—the canonical field definitions every other generator consumes, using stable field numbering so additions stay compatible.
* **[hyperdb](https://www.npmjs.com/package/hyperdb)**—typed, compactly-encoded collections for what's stored on disk.
* **[hyperdispatch](https://www.npmjs.com/package/hyperdispatch)**—typed encoders for the payloads appended to an Autobase.
* **[HRPC](/p2p/reference/helpers/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](/p2p/explanation/autobase-backed-chat-room#understand-spec) walks a concrete `schema.js` and its generated `spec/` directory.

## See also [#see-also]

* [Workers](/pear/explanation/workers)—the IPC contract this page builds on, in a Pear app.
* [Add typed RPC to a Pear app](/p2p/how-to/add-typed-rpc-to-a-pear-app)—the worked how-to for the HRPC pattern described above.
* [HRPC reference](/p2p/reference/helpers/hrpc)—full API for the recommended RPC layer.
* [Compact encoding reference](/p2p/reference/helpers/compact-encoding)—binary framing for raw IPC messages.
* [Autobase reference](/p2p/reference/building-blocks/autobase)—the multi-writer log `hyperdispatch`-encoded payloads are appended to.
* [A production Autobase-backed chat room](/p2p/explanation/autobase-backed-chat-room)—a worked example with a generated `spec/` directory.
* [Hyperschema reference](/p2p/reference/helpers/hyperschema)—the builder, the type definitions and the generated module.
* [P2P Reference](/p2p/reference)—`hyperdb` and `hyperdispatch` sit alongside the building blocks this schema-first toolchain generates code for; neither has a dedicated reference page here yet, so their own repos are the source of truth.
