Swift peers in the bare RPC ecosystem
HRPC is a typed, schema-driven RPC protocol in the Holepunch ecosystem — you define the interface once, and the generator produces a peer class with typed methods and binary codecs for every handler. If you're a Swift developer new to the stack: it's conceptually similar to gRPC, but built on compact peer-to-peer primitives instead of HTTP/2.
If you're building on hrpc today, your JavaScript peers are generated from the spec. Your Swift peers are written by hand. Every type, every codec, every command ID — maintained separately, drifting a little further from the canonical schema with each change. Update the spec and your JS peer updates automatically. Your Swift peer needs a manual edit, in the right place, for every field, every handler, every time.
Four packages close that gap. The same spec that generates your JS code now generates Swift.
- compact-encoding-swift — the codec layer, wire-compatible with the JS
compact-encodingmodule - bare-rpc-swift — the bare-rpc framing protocol in Swift, wire-compatible with the JS module
- hyperschema-swift — codegen: schema definitions → Swift structs with binary encode/decode
- hrpc-swift — codegen: schema + handler definitions → a typed Swift
HRPCclass
All four are available now — the codegen tools on npm, the Swift runtime packages via Swift Package Manager.
Your spec already knows what to generate
Add two packages:
npm install hyperschema-swift hrpc-swift
Point them at your existing spec:
const SwiftHyperschema = require('hyperschema-swift')
const SwiftHRPC = require('hrpc-swift')
const schema = SwiftHyperschema.from('./spec/hyperschema')
const hrpc = SwiftHRPC.from(schema)
SwiftHyperschema.toDisk(schema, './swift/schema')
SwiftHRPC.toDisk(hrpc, './swift/hrpc', {
schemaPackagePath: '../schema',
schemaPackageName: 'Schema',
schemaPackageId: 'schema'
})
node generate.js
Two Swift packages appear on disk — schema types and a typed HRPC class, one method per handler. Drop them into any Xcode project or Swift package. Nothing else to write.
A Swift peer is symmetric
The generated HRPC class is the same on every peer. Any instance can register handlers, make calls, or do both at once. There is no client type and no server type — just peers.
Here's a chat example. Both peers register handlers and both initiate:
let peerA = HRPC(delegate: transportA)
let peerB = HRPC(delegate: transportB)
// peerB handles incoming messages
peerB.onSendMessage { req in
guard let req else { return SendMessageResponse(id: 0, timestamp: 0) }
let msg = Message(id: nextId, text: req.text, timestamp: UInt(Date().timeIntervalSince1970))
// peerB pushes a notification back to peerA
try await peerB.newMessage(msg)
return SendMessageResponse(id: msg.id, timestamp: msg.timestamp)
}
// peerA listens for incoming events
peerA.onNewMessage { msg in
guard let msg else { return }
print("New message: \(msg.text)")
}
// peerA initiates a call
let reply = try await peerA.sendMessage(SendMessageRequest(text: "Hello"))
print("Acknowledged, id: \(reply.id)")
Typed arguments. Typed return values. async/await throughout. No command IDs, no manual encoding, no Data wrangling.
All five patterns, including streaming
bare-rpc has five communication patterns. All five generate idiomatic Swift.
Unary — one peer asks, the other answers.
Send-only events — fire and forget, no reply expected. Either peer can push these at any time.
Response-stream — one peer opens a stream and the other feeds chunks into it. The handler receives the stream as a parameter; the other side gets an AsyncSequence to iterate:
// Handler peer writes into the stream
peerB.onSync { req, stream in
for entry in localLog {
await stream.write(entry)
}
await stream.end()
}
// Other peer iterates the incoming chunks
let stream = try await peerA.sync(SyncRequest(since: lastSeen))
for try await entry in stream {
apply(entry)
}
Request-stream — one peer streams data into a closure; the handler peer accumulates and replies once:
// Handler peer reads the incoming stream
peerB.onPush { stream in
for try await chunk in stream {
accumulate(chunk)
}
return PushResponse(accepted: true)
}
// Other peer writes into the stream; it closes when the closure returns
let response = try await peerA.push { stream in
for chunk in pendingWrites {
await stream.write(chunk)
}
}
Duplex — both peers stream concurrently. Both sides get a read stream and a write stream at the same time:
// One peer echoes everything back
peerB.onPipe { incoming, outgoing in
for try await chunk in incoming {
await outgoing.write(chunk)
}
await outgoing.end()
}
// The other peer sends and receives simultaneously
try await peerA.pipe { outgoing, incoming in
await outgoing.write(chunkA)
await outgoing.write(chunkB)
await outgoing.end()
for try await chunk in incoming {
handle(chunk)
}
}
Swift's structured concurrency maps onto all five patterns without friction. No callbacks, no manual stream state.
Wiring to a transport
bare-rpc-swift is transport-agnostic. Implement one delegate protocol, feed it bytes:
class MyTransport: RPCDelegate {
func rpc(_ rpc: RPC, send data: Data) {
connection.send(data)
}
}
let peer = HRPC(delegate: MyTransport())
// when bytes arrive:
await peer.receive(data)
That's the entire integration surface. TCP, WebSocket, Unix socket, in-process pipe — the protocol doesn't care. The wire format is identical to the JS module, so a Swift peer talks directly to any bare-rpc peer in the network.
What this opens up
Clean separation of concerns in iOS and macOS apps. Run your p2p networking stack as a bare-kit worklet — the entire Holepunch ecosystem in JS, where it lives — and write your UI and business logic in native Swift. hrpc-swift bridges the two: the same spec that defines your worklet's interface generates the Swift types and RPC methods automatically. The worklet owns the network; Swift owns the experience. The separation matters because the Holepunch networking stack is JavaScript-native — keeping it there means every protocol update and ecosystem improvement flows in without rewriting native extensions. Your Swift code stays clean: it makes typed RPC calls to the worklet instead of touching raw sockets, DHT internals, or binary framing.
Swift devices as full peers. An iPhone or Mac is no longer bolted on the side — it's a node. It can register handlers, open streams, push events, and participate in duplex exchanges with any other peer in the network.
One spec, every platform. Hyperschema already targets JS. It now targets Swift too. Add a handler, regenerate, and both sides update. Rename a field and the Swift compiler tells you everywhere that needs fixing. The spec is the single source of truth for the entire network. Schema evolution follows compact-encoding's additive rules — new fields appended to a struct decode transparently on older peers, which ignore fields they don't recognise. Renaming or reordering fields is a breaking change; bump the schema version and codegen regenerates a consistent type set for JS and Swift in one pass.
Try it
All four packages are on GitHub under holepunchto. A complete runnable example — schema definition, codegen, two Swift peers over an in-memory pipe — lives in hrpc-swift/example.
If you're building something on top of this, we'd like to hear about it. Open an issue or send a PR on hrpc-swift — or do the same on any of the four packages. We're watching.