NPRPC is a high-performance, multi-transport RPC framework for distributed systems. It features a compact binary protocol with flat-buffer serialization, a type-safe IDL with code generation for C++, TypeScript, and Swift, and first-class streaming support (server, client, and bidirectional streams) over every transport.
- Multiple transports — WebSocket (WS/WSS), HTTP/HTTPS, HTTP/3, TCP, Shared Memory, QUIC, WebTransport
- Streaming RPC — server (
stream<T>), client (client_stream<T>), and bidi (bidi_stream<In,Out>) streams with C++20 coroutines - Type-safe IDL —
.npidl→ C++/TypeScript/Swift stubs withnpidl - Cross-language — seamless C++ ↔ TypeScript/JavaScript ↔ Swift interop
- Browser-first — WebSocket, HTTP, and WebTransport endpoints;
host.jsonbootstrap for static deployments - SSR support — built-in SvelteKit SSR via shared memory IPC (see docs/SSR_ARCHITECTURE.md)
- Cookie auth — httpOnly cookie-based auth for HTTP/WebSocket (see docs/HTTP_AUTH.md)
- POA — Portable Object Adapter for lifecycle, transports, and dispatch placement (see docs/POA.md)
- Nameserver — service discovery and named object binding
| Transport | Use Case | Notes |
|---|---|---|
| TCP | Native IPC, microservices | Lowest overhead, no browser support; optional io_uring backend (experimental); -DNPRPC_ENABLE_TCP=OFF to disable |
| WebSocket | Real-time, bidirectional | Persistent connection, streams supported; TLS via WSS; -DNPRPC_ENABLE_WEBSOCKET=OFF to disable |
| HTTP | Stateless web APIs | Browser-compatible, SSR-capable; TLS via HTTPS; -DNPRPC_ENABLE_HTTP=OFF to disable |
| HTTP/3 | Modern web | QUIC-based, SSR-capable; requires -DNPRPC_ENABLE_HTTP3=ON |
| WebTransport | Browser streaming | Multiplexed streams over HTTP/3; native stream mapping for stream<T> |
| QUIC | Native next-gen | Multiplexed, encrypted; requires -DNPRPC_ENABLE_QUIC=ON |
| Shared Memory | Same-machine IPC | Zero-copy in some cases; extremely low latency. Always compiled in. |
TLS for HTTPS/WSS is optional (-DNPRPC_ENABLE_SSL=OFF). A shared-memory-only build (TCP/HTTP/WebSocket/SSL all off) does not need OpenSSL or liburing. See docs/BUILD.md.
// calculator.npidl
module example;
exception CalculationError {
message: string;
code: i32;
}
interface Calculator {
f64 Add(a: in f64, b: in f64);
f64 Subtract(a: in f64, b: in f64);
f64 Multiply(a: in f64, b: in f64);
f64 Divide(a: in f64, b: in f64) raises(CalculationError);
}
npidl calculator.npidl --cpp --ts # C++ + TypeScript
npidl calculator.npidl --cpp --ts --swift # add SwiftGenerates calculator.hpp / calculator.cpp (C++) and calculator.ts (TypeScript).
#include <nprpc/nprpc.hpp>
#include "calculator.hpp"
class CalculatorImpl : public example::ICalculator_Servant {
public:
double Add(double a, double b) override { return a + b; }
double Subtract(double a, double b) override { return a - b; }
double Multiply(double a, double b) override { return a * b; }
double Divide(double a, double b) override {
if (b == 0.0) throw example::CalculationError{"Division by zero", 1};
return a / b;
}
};
int main() {
// Build RPC — chain transport builders, then call build()
auto* rpc = nprpc::RpcBuilder()
.set_log_level(nprpc::LogLevel::info)
.with_hostname("localhost")
.with_tcp(15000)
.with_http(8080)
.root_dir("./public")
.build();
// Create a POA
auto* poa = nprpc::PoaBuilder(rpc)
.with_max_objects(10)
.with_lifespan(nprpc::PoaPolicy::Lifespan::Persistent)
.build();
// Activate object — specify which transports it accepts
CalculatorImpl calc;
auto oid = poa->activate_object(
&calc,
nprpc::ObjectActivationFlags::tcp |
nprpc::ObjectActivationFlags::ws |
nprpc::ObjectActivationFlags::http
);
// Publish for browser clients (writes <root_dir>/host.json)
rpc->add_to_host_json("calculator", oid);
rpc->produce_host_json();
// Or register with the nameserver
auto ns = rpc->get_nameserver("localhost:15001");
ns->Bind(oid, "calculator");
rpc->run(); // blocks; use rpc->start_thread_pool(n) for async
return 0;
}import * as NPRPC from 'nprpc';
import * as example from './gen/calculator';
const rpc = await NPRPC.init();
const ns = NPRPC.get_nameserver('localhost:15001');
const ref = NPRPC.make_ref<NPRPC.ObjectProxy>();
await ns.Resolve('calculator', ref);
const calc = NPRPC.narrow(ref.value, example.Calculator);
console.log(await calc.Add(10, 20)); // 30
try {
await calc.Divide(10, 0);
} catch (e) {
if (e instanceof example.CalculationError)
console.error(`${e.code}: ${e.message}`);
}// host.json is served by the C++ server at /host.json
const host = await fetch('/host.json').then(r => r.json());
const calc = NPRPC.narrow(host.objects.calculator, example.Calculator);
// .http sub-proxy returns values directly
console.log(await calc.http.Add(10, 20)); // 30All three stream directions are supported at the IDL level and map to C++20 coroutines on the server side and range-based iteration / AsyncThrowingStream on clients.
interface FileServer {
// Server → Client (stream<T> is an alias for server_stream<T>)
stream<vector<u8>> DownloadFile(filename: in string);
// Client → Server (void reply after stream closes)
void UploadFile(filename: in string, data: client_stream<vector<u8>>);
// Bidirectional
bidi_stream<string, string> Chat(room: in string);
}
Optional in parameters before the stream keyword are sent in the handshake phase; the server can raise exceptions there before any data flows.
// server_stream — return a StreamWriter<T> coroutine
nprpc::StreamWriter<uint8_t>
FileServerImpl::DownloadFile(std::string_view filename) {
auto data = read_file(filename);
for (uint8_t byte : data)
co_yield byte;
}
// client_stream — StreamReader<T> delivered as a parameter
void FileServerImpl::UploadFile(
std::string_view filename,
nprpc::StreamReader<std::vector<uint8_t>>& data) {
std::ofstream out(std::string(filename), std::ios::binary);
for (auto& chunk : data) // blocking range-based for
out.write((char*)chunk.data(), chunk.size());
}// Server stream — range-based for loop
auto reader = file_server->DownloadFile("large.bin");
for (auto& chunk : reader)
process(chunk);
// Client stream — write chunks then close
auto writer = file_server->UploadFile("upload.bin");
while (has_data())
writer.send(next_chunk());
writer.close();// Server stream
const stream = await fileServer.DownloadFile('large.bin');
for await (const chunk of stream) {
process(chunk);
}
// Bidirectional
const chat = await chatService.Chat('lobby');
chat.send('Hello!');
for await (const msg of chat) {
console.log(msg);
}// Server stream — AsyncThrowingStream
let stream = try client.downloadFile(filename: "large.bin")
for try await chunk in stream {
process(chunk)
}WebTransport is the browser-native streaming transport built on HTTP/3. When an object is advertised with a secured HTTPS endpoint and the server has enable_http3() active, browsers can open a WebTransport session to https://host:port/wt and use it as the NPRPC transport.
- Unary RPC uses a single reliable bidirectional control stream.
stream<T>methods map to server-opened unidirectional WebTransport streams.client_stream<T>maps to client-opened unidirectional streams.bidi_stream<In,Out>maps to a dedicated bidirectional stream.
Enable it server-side by activating objects with ObjectActivationFlags::https and calling enable_http3() on the HTTP builder:
auto* rpc = nprpc::RpcBuilder()
.with_hostname("example.com")
.with_http(443)
.ssl("cert.crt", "key.key")
.enable_http3()
.build();
auto oid = poa->activate_object(&servant,
nprpc::ObjectActivationFlags::https);The TypeScript runtime automatically prefers WebTransport when available (globalThis.WebTransport present) and the object carries a secured endpoint.
Chain .ssl() on the HTTP or QUIC builder. The dhparams file is optional.
auto* rpc = nprpc::RpcBuilder()
.with_hostname("example.com")
.with_http(443)
.ssl("cert.crt", "key.key", "dhparam.pem")
.enable_http3()
.with_tcp(15000)
.build();
// Activate for secure WebSocket only
poa->activate_object(&obj, nprpc::ObjectActivationFlags::wss);// TypeScript client automatically uses wss:// when served over HTTPS
const rpc = await NPRPC.init();When HTTP/3 is enabled with multiple workers, NPRPC uses an eBPF SO_REUSEPORT
selector with a reuseport sockarray to keep QUIC packets pinned to the correct
worker. On Linux this is not an unprivileged operation.
If your application links against libnprpc and starts an HTTP/3 server with
more than one worker, grant capabilities to your application executable after
each build:
sudo setcap cap_net_admin,cap_bpf+ep /path/to/your_server_binary
getcap /path/to/your_server_binaryCapabilities must be applied to the final executable that starts the NPRPC
runtime, not to libnprpc.so.
Notes:
- Rebuilding the binaries may clear file capabilities, so scripts that launch HTTP/3 servers should re-apply them after each rebuild.
- If capabilities are unavailable, the safe fallback is to run HTTP/3 with a single worker; multi-worker HTTP/3 requires the reuseport BPF path.
- In Docker, grant the container the matching capabilities, for example
--cap-add=NET_ADMIN --cap-add=BPF.
// Server: bind by name
auto ns = rpc->get_nameserver("localhost:15001");
ns->Bind(calc_oid, "calculator");
ns->Bind(auth_oid, "authorizator");// Client: resolve by name
const ref = NPRPC.make_ref<NPRPC.ObjectProxy>();
if (await nameserver.Resolve('calculator', ref))
const calc = NPRPC.narrow(ref.value, example.Calculator);Use ObjectIdPolicy::UserSupplied when you need stable IDs baked into a web bundle:
auto* poa = nprpc::PoaBuilder(rpc)
.with_lifespan(nprpc::PoaPolicy::Lifespan::Persistent)
.with_object_id_policy(nprpc::PoaPolicy::ObjectIdPolicy::UserSupplied)
.with_max_objects(16)
.build();
constexpr nprpc::oid_t kCalcId = 0;
poa->activate_object_with_id(kCalcId, &calc,
nprpc::ObjectActivationFlags::tcp | nprpc::ObjectActivationFlags::http);IDs must be in [0, max_objects). With UserSupplied, activate_object (auto-ID) is disabled to prevent mismatches.
// Server
auto* rpc = nprpc::RpcBuilder()
.with_hostname("localhost")
.build();
poa->activate_object(&obj, nprpc::ObjectActivationFlags::shm);
rpc->run();// Client (same machine)
auto* rpc = nprpc::RpcBuilder().build();
auto ns = rpc->get_nameserver("localhost:15001");
Object* obj;
ns->Resolve("my_object", obj);
auto* svc = nprpc::narrow<MyInterface>(obj);
svc->MyMethod(data);Every accepted client gets two rings — one each way — created by the server and resident from the moment they exist, not faulted in as they fill. The default is 1 MiB each, so 2 MiB per connected client whether it is busy or idle, and the largest single message is half a ring:
auto* rpc = nprpc::RpcBuilder()
.shm_channel_sizes(4 * 1024 * 1024, 3 * 1024 * 1024) // ring, max message
.build();Only the server says this. It writes both numbers into the ring header at creation and whoever opens the ring adopts them, so clients need no matching configuration and two differently-configured builds cannot disagree about the same ring.
Raise it when replies are large — a message the ring cannot hold is refused rather than queued, with an error naming this call. But before raising it for everybody, consider that one big payload is usually better carried in a segment of its own than paid for on every connection, idle ones included.
The server removes a client's rings when the channel closes, which covers
every orderly ending and none of the others: killed, crashed, or exit()
without unwinding, it runs no destructor and its rings stay in /dev/shm
holding real memory until the machine reboots. A day of restarts during
development is measured in gigabytes.
Nothing in the dying process can fix that, so the next server to start does
it: SharedMemoryListener sweeps stale segments before creating its own. A
segment goes only when a process named in it is provably gone — by pid and
start time, so a recycled pid cannot condemn a live channel — and no other
end of the same channel is still alive. Anything unreadable, or not yet
claimed by a writer, is left where it is.
Pass the current session context to restrict an object to the caller's connection:
nprpc::ObjectId CreateProcessor() override {
auto proc = std::make_unique<MyProcessor>();
// Only the calling client can reach this object
return poa_->activate_object(proc.release(),
nprpc::ObjectActivationFlags::all,
&nprpc::get_context());
}// IDL — use `object` for interface-typed parameters
interface ObjectManager {
object CreateProcessor(type: in string);
void RegisterProcessor(proc: in object);
}
void RegisterProcessor(nprpc::Object* proc) override {
auto* typed = nprpc::narrow<IDataProcessor>(proc);
if (!typed) throw nprpc::Exception("wrong type");
processors_.emplace_back(typed);
}TypeScript servants can be passed as parameters too — the server can call back on them over a bidirectional transport (WebSocket/WebTransport):
class MyProcessor extends example.IDataProcessor_Servant {
ProcessData(data: Uint8Array): void { /* ... */ }
}
const proc = new MyProcessor();
await manager.RegisterProcessor(proc);NPRPC can serve SvelteKit apps with full SSR over HTTP/3. See docs/SSR_ARCHITECTURE.md for setup and architecture details.
auto* rpc = nprpc::RpcBuilder()
.with_hostname("mysite.com")
.with_http(443)
.ssl("cert.crt", "key.key")
.root_dir("/srv/www")
.enable_http3()
.enable_ssr("/srv/www") // path to SvelteKit build containing index.js
.build();
rpc->run();See docs/HTTP_AUTH.md for the full API reference. Quick example:
// Inside any servant method — read / write httpOnly cookies
auto token = nprpc::get_cookie("session");
nprpc::set_cookie("session", new_token, {
.http_only = true, .secure = true, .same_site = "Strict", .max_age = 86400
});| Category | Tokens |
|---|---|
| Booleans | boolean |
| Integers | i8 i16 i32 i64 u8 u16 u32 u64 |
| Floats | f32 f64 |
| String | string |
| Object ref | object |
| Dynamic array | T[] or vector<T> |
| Fixed array | T[N] |
| Alias | alias Foo = vector<Bar> |
| Discriminated union | alias Foo = one of { arm1: T1; arm2: T2; } |
The one of construct defines a tagged union of named struct arms. It generates a std::variant-backed C++ type with a Kind enum, a TypeScript discriminated union, and a Swift enum with associated values.
// IDL
message MsgA { id: u32; label: string; }
message MsgB { code: u32; detail: string; }
alias MyVariant = one of {
msgA: MsgA;
msgB: MsgB;
};
message Envelope {
seq: u32;
payload: MyVariant;
}
interface EventService {
void Send(event: MyVariant, echo: out MyVariant);
}
C++ usage:
// Construct
MyVariant v{ MyVariant::Kind::msgA, MsgA{42u, "hello"} };
// Dispatch
std::visit([](auto&& arm) {
using T = std::decay_t<decltype(arm)>;
if constexpr (std::is_same_v<T, MsgA>)
std::cout << "A: " << arm.label;
else if constexpr (std::is_same_v<T, MsgB>)
std::cout << "B: " << arm.code;
}, v.value);TypeScript usage:
// Construct
const v: MyVariant = { kind: 'msgA', value: { id: 42, label: 'hello' } };
// Narrow
if (v.kind === 'msgA') {
console.log(v.value.label);
}Swift usage:
// Construct
let v = MyVariant.msgA(MsgA(id: 42, label: "hello"))
// Switch
switch v {
case .msgA(let a): print("A: \(a.label)")
case .msgB(let b): print("B: \(b.code)")
}?— nullable/optional field or parameterin— input parameter (by value)out— output parameterraises(E1, E2)— exception specificationasync— fire-and-forget (no reply)[unreliable]— fire-and-forget RPC (must bevoid, onlyinargs, noraises) or best-effort streams (QUIC DATAGRAM when available; other transports may still deliver the bytes)[force_helpers=1]— emit helperfrom_flat/to_flatfunctions for amessage[trusted=true]— disable strict bounds checking for untrusted input
interface DataService {
// Server → Client
stream<vector<u8>> Download(id: in u32) raises(NotFound);
// server_stream<T> is the canonical spelling; stream<T> is an alias
// Client → Server
void Upload(name: in string, data: client_stream<vector<u8>>);
// Bidirectional
bidi_stream<string, string> Chat(room: in string);
bidi_stream<AAA, CCC> Transform(suffix: in string);
}
module blog;
exception NotFound { id: u32; }
message Post { id: u32; title: string; body: string; }
interface BlogService {
Post GetPost(id: in u32) raises(NotFound);
vector<Post> ListPosts(page: in u32, size: in u32);
async DeletePost(id: in u32);
// Stream all posts matching a query
stream<Post> Search(query: in string) raises(NotFound);
// Live feed — bidi (client sends ack, server sends posts)
bidi_stream<u32, Post> LiveFeed(channel: in string);
}
NPRPC provides native Swift bindings via Swift 6.2+ C++ interop. The full feature set is supported: servants, client proxies, exceptions, object references, async methods, and all three stream directions.
Swift must be built inside a dedicated Docker container because it requires NPRPC and Boost to be compiled with Swift's bundled Clang toolchain.
# Step 1 — build the Docker image (once)
just build-dev-image # builds nprpc-dev:latest used by CMake examples
# OR build the Swift-specific image directly
cd nprpc_swift
docker build -f Dockerfile \
--build-arg USER_ID=$(id -u) \
--build-arg GROUP_ID=$(id -g) \
--build-arg USERNAME=$(id -un) \
-t nprpc-swift-ubuntu ..
# Step 2 — build Boost + OpenSSL inside the container (first time only, ~10 min)
./docker-build-boost.sh
# Step 3 — build libnprpc.so with Swift's Clang
./docker-build-nprpc.sh
# Step 4 — generate Swift stubs from IDL (requires npidl built in Step 3)
just gen-swift-stubs # from repo root
# Step 5 — build and optionally test the Swift package
./docker-build-swift.sh # build only
./docker-build-swift.sh --test # build + run tests (timeout 15 s)
# Or all-in-one (stubs + docker build + test):
# just run-swift-testsTo rebuild the Docker image (after Dockerfile changes):
./docker-build-nprpc.sh --rebuild# Run from repo root; npidl must be built first
npidl myservice.npidl --swift --output-dir nprpc_swift/Sources/NPRPC/Generatedimport NPRPC
class CalculatorImpl: CalculatorServant, @unchecked Sendable {
override func add(a: Float64, b: Float64) throws -> Float64 { a + b }
override func divide(a: Float64, b: Float64) throws -> Float64 {
guard b != 0 else { throw CalculationError(message: "div/0", code: 1) }
return a / b
}
}let rpc = try RpcBuilder()
.setLogLevel(.info)
.setHostname("localhost")
.withTcp(15000)
.withHttp(15001)
.ssl(certFile: "cert.crt", keyFile: "key.key")
.build()
let poa = try rpc.createPoa(maxObjects: 100)
let servant = CalculatorImpl()
let oid = try poa.activateObject(servant, flags: [.tcp, .ws])
let obj = NPRPCObject.fromObjectId(oid)!
let client = narrow(obj, to: Calculator.self)!
let result = try client.add(a: 10, b: 20) // 30.0// Server stream
let stream = try client.downloadFile(filename: "data.bin")
for try await chunk in stream {
process(chunk)
}
// Async fire-and-forget
await client.playerMoved(x: 1.0, y: 2.0, z: 0.0)
// Async with out value
let reply = try await client.method2(arg1: 42)See nprpc_swift/README.md and nprpc_swift/EXAMPLES.md for more.
# Standard dev build (Ninja, RelWithDebInfo, all features) — see `.env` for BUILD_DIR
just configure
just build
# Or a single target
just bt nprpc_test
# Run tests
just run-cpp-tests # C++ (ctest)
just run-js-tests # TypeScript / Mocha
just run-swift-tests # Swift in Docker (rebuilds nprpc)
just run-swift-tests-host # Swift on host (reuses CMake build; sudo setcap)
just test-all --swift-host # C++ + JS + host Swift ← pre-merge gate
just test-all # C++ + JS + Docker Swift
just run-cpp-tests -R NprpcTest.TestBasic # filtered
# Pre-merge: always run the full suite (C++ / JS / Swift) before merging to main.
# See docs/BUILD.md "Pre-merge workflow".
# Minimal build (library only)
cmake -S . -B build
cmake --build build -j$(nproc)See docs/BUILD.md for all CMake options, the JS/TS build, and install instructions. Run just for the full task list.
NPRPC is benchmarked against gRPC and Cap'n Proto RPC.
just run-benchmarks # all suites
just run-benchmarks --benchmark_filter=EmptyCall # latency
just run-benchmarks --benchmark_filter=LargeData1MB # throughputSee benchmark/README.md for methodology and results.
| Topic | Document |
|---|---|
| Full build options | docs/BUILD.md |
| SSR architecture | docs/SSR_ARCHITECTURE.md |
| Cookie auth API | docs/HTTP_AUTH.md |
| HTTP/3 + WebTransport debugging | .github/skills/http3-webtransport-debugging/SKILL.md |
| Nameserver source | npnameserver/npnameserver.cpp |
| Swift integration tests | nprpc_swift/Tests/NPRPCTests/IntegrationTest.swift |
| C++ test suite | test/src/ |
| JS/TS test suite | test/js/ |
| Live Blog example | examples/live-blog/README.md |
See LICENSE.