Build bidirectional RPC systems in TypeScript with kkrpc. Create RPC channels, expose APIs, use multiple transports (stdio, WebSocket, HTTP), handle callbacks, property access, and errors across Node.js, Deno, Bun, and browsers.
Build bidirectional RPC systems in TypeScript with full type safety and multiple transport options.
# npm
npm install kkrpc
# pnpm
pnpm add kkrpc
# Deno
import { RPCChannel } from "jsr:@kunkun/kkrpc"
Server (expose API):
import { NodeIo, RPCChannel } from "kkrpc"
const api = {
greet: (name: string) => `Hello, ${name}!`,
add: (a: number, b: number) => a + b,
counter: 42
}
const rpc = new RPCChannel(new NodeIo(process.stdin, process.stdout), { expose: api })
Client (consume API):
import { spawn } from "child_process"
import { NodeIo, RPCChannel } from "kkrpc"
const worker = spawn("bun", ["server.ts"])
const rpc = new RPCChannel(new NodeIo(worker.stdout, worker.stdin))
const api = rpc.getAPI<typeof api>()
console.log(await api.greet("World")) // "Hello, World!"
console.log(await api.add(5, 3)) // 8
console.log(await api.counter) // 42
The main class that manages bidirectional communication:
class RPCChannel<LocalAPI extends Record<string, any>, RemoteAPI extends Record<string, any>> {
constructor(
io: IoInterface,
options?: {
expose?: LocalAPI
serialization?: { version: "json" | "superjson" }
validators?: RPCValidators<LocalAPI>
}
)
getAPI(): RemoteAPI // Get proxy to remote API
expose(api: LocalAPI): void // Expose local API
}
Transport adapters for different environments:
| Transport | Class | Environment |
|---|---|---|
| stdio | NodeIo, DenoIo, BunIo | Process-to-process |
| WebSocket | WebSocketClientIO, WebSocketServerIO | Network |
| HTTP | HTTPClientIO, HTTPServerIO | Web APIs |
| Worker | WorkerParentIO, WorkerChildIO | Web Workers |
| postMessage | IframeParentIO, IframeChildIO | iframes |
| Chrome Extension | ChromePortIO | Chrome extensions |
| Electron | ElectronIpcMainIO, ElectronIpcRendererIO | Electron |
| Message Queues | RabbitMQIO, KafkaIO, RedisStreamsIO, NatsIO | Distributed |
const api = {
greet: (name: string) => `Hello, ${name}!`,
add: (a: number, b: number) => a + b
}
type API = typeof api
const rpc = new RPCChannel<API, API>(io, { expose: api })
const remote = rpc.getAPI()
interface MathAPI {
add(a: number, b: number): Promise<number>
multiply(a: number, b: number): Promise<number>
}
interface MyAPI {
math: MathAPI
greet(name: string): Promise<string>
}
const api: MyAPI = {
math: {
add: async (a, b) => a + b,
multiply: async (a, b) => a * b
},
greet: async (name) => `Hello, ${name}!`
}
const rpc = new RPCChannel<MyAPI, MyAPI>(io, { expose: api })
interface API {
math: {
basic: {
add(a: number, b: number): Promise<number>
subtract(a: number, b: number): Promise<number>
}
advanced: {
pow(base: number, exp: number): Promise<number>
sqrt(n: number): Promise<number>
}
}
}
// Usage
const result = await api.math.advanced.pow(2, 10) // 1024
import { spawn } from "child_process"
import { NodeIo, RPCChannel } from "kkrpc"
// Spawn child process
const child = spawn("bun", ["worker.ts"])
// Create channel
const io = new NodeIo(child.stdout, child.stdin)
const rpc = new RPCChannel<LocalAPI, RemoteAPI>(io, { expose: localApi })
// Get remote API
const api = rpc.getAPI()
import { RPCChannel, WebSocketClientIO, WebSocketServerIO } from "kkrpc"
// Server
wss.on("connection", (ws) => {
const io = new WebSocketServerIO(ws)
const rpc = new RPCChannel<API, API>(io, { expose: api })
})
// Client
const ws = new WebSocket("ws://localhost:3000")
const io = new WebSocketClientIO({ ws })
const rpc = new RPCChannel<{}, API>(io)
const api = rpc.getAPI()
import { WorkerParentIO, WorkerChildIO, RPCChannel } from "kkrpc/browser"
// Main thread
const worker = new Worker("./worker.ts", { type: "module" })
const io = new WorkerParentIO(worker)
const rpc = new RPCChannel<LocalAPI, RemoteAPI>(io, { expose: localApi })
// Worker thread
const io = new WorkerChildIO()
const rpc = new RPCChannel<RemoteAPI, LocalAPI>(io, { expose: api })
import { HTTPClientIO, HTTPServerIO, RPCChannel } from "kkrpc"
// Server
const serverIO = new HTTPServerIO()
const serverRPC = new RPCChannel<API, API>(serverIO, { expose: api })
Bun.serve({
async fetch(req) {
if (new URL(req.url).pathname === "/rpc") {
const response = await serverIO.handleRequest(await req.text())
return new Response(response, {
headers: { "Content-Type": "application/json" }
})
}
return new Response("Not found", { status: 404 })
}
})
// Client
const clientIO = new HTTPClientIO({ url: "http://localhost:3000/rpc" })
const clientRPC = new RPCChannel<{}, API>(clientIO)
Send functions as arguments that can be invoked remotely:
interface API {
processData(data: string, onProgress: (percent: number) => void): Promise<string>
}
// Server
const api: API = {
processData: async (data, onProgress) => {
for (let i = 0; i <= 100; i += 10) {
onProgress(i)
await sleep(100)
}
return `Processed: ${data}`
}
}
// Client
const result = await api.processData("my-data", (progress) => {
console.log(`Progress: ${progress}%`)
})
Access and mutate remote properties:
interface API {
counter: number
settings: {
theme: string
notifications: { enabled: boolean }
}
}
// Get values
const count = await api.counter
const theme = await api.settings.theme
// Set values
api.counter = 100
api.settings.theme = "dark"
Errors preserve name, message, stack, and custom properties:
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public code: number
) {
super(message)
this.name = "ValidationError"
}
}
// Thrown on server
type API = {
validateUser(data: unknown): Promise<void>
}
// Caught on client
try {
await api.validateUser({})
} catch (error) {
console.log(error.name) // "ValidationError"
console.log(error.message) // "Name is required"
console.log(error.field) // "name"
console.log(error.code) // 400
}
Use Standard Schema (Zod, Valibot, ArkType) for runtime validation:
import { RPCChannel, type RPCValidators } from "kkrpc"
import { z } from "zod"
type MathAPI = {
add(a: number, b: number): Promise<number>
}
const api: MathAPI = {
add: async (a, b) => a + b
}
const validators: RPCValidators<MathAPI> = {
add: {
input: z.tuple([z.number(), z.number()]),
output: z.number()
}
}
const rpc = new RPCChannel(io, { expose: api, validators })
Zero-copy transfer of large binary data:
import { RPCChannel, transfer, WorkerParentIO } from "kkrpc/browser"
interface API {
processBuffer(buffer: ArrayBuffer): Promise<number>
}
const worker = new Worker("worker.js")
const io = new WorkerParentIO(worker)
const rpc = new RPCChannel<{}, API>(io)
const api = rpc.getAPI()
// Create large buffer
const buffer = new ArrayBuffer(10 * 1024 * 1024)
// Transfer (zero-copy) to worker
const result = await api.processBuffer(transfer(buffer, [buffer]))
// Note: buffer is now detached (length 0)
const rpc = new RPCChannel(io, {
expose: api,
serialization: { version: "json" }
})
const rpc = new RPCChannel(io, {
expose: api,
serialization: { version: "superjson" }
})
Both sides expose APIs:
// Side A
interface API_A {
compute(data: number[]): Promise<number>
}
interface API_B {
notify(message: string): Promise<void>
}
const apiA: API_A = {
compute: async (data) => data.reduce((a, b) => a + b, 0)
}
const rpc = new RPCChannel<API_A, API_B>(io, { expose: apiA })
const apiB = rpc.getAPI()
// Call B from A
await apiB.notify("Computation complete")
Change exposed API at runtime:
const rpc = new RPCChannel(io)
// Later...
rpc.expose(newApi)
Destroy connections when done:
// For transports that support it
io.destroy()
| Environment | Import Path |
|---|---|
| Node.js | kkrpc |
| Deno | kkrpc/deno or jsr:@kunkun/kkrpc |
| Bun | kkrpc |
| Browser | kkrpc/browser |
| Chrome Extension | kkrpc/chrome-extension |
// Browser (excludes stdio)
import { RPCChannel, WorkerParentIO } from "kkrpc/browser"
// Deno
import { RPCChannel, DenoIo } from "kkrpc/deno"
// Chrome Extension
import { RPCChannel, ChromePortIO } from "kkrpc/chrome-extension"
| Error | Cause | Solution |
|---|---|---|
RPCValidationError | Input/output validation failed | Check validation schema |
TimeoutError | Request timed out | Increase timeout or check connection |
TransportClosed | Connection closed unexpectedly | Check transport health |
// interop/node/server.ts provides a test server
const api = {
math: { add: (a: number, b: number) => a + b },
echo: <T>(v: T) => v,
counter: 42
}
import { expect, test } from "bun:test"
import { NodeIo, RPCChannel } from "kkrpc"
test("basic RPC call", async () => {
const api = { add: (a: number, b: number) => a + b }
// Create connected pair
const { port1, port2 } = new MessageChannel()
const serverIO = new NodeIo(port1)
const clientIO = new NodeIo(port2)
new RPCChannel<typeof api, {}>(serverIO, { expose: api })
const client = new RPCChannel<{}, typeof api>(clientIO)
const result = await client.getAPI().add(2, 3)
expect(result).toBe(5)
})
transfer() for ArrayBuffers in browserspackages/kkrpc/packages/kkrpc/src/packages/kkrpc/src/serialization.tspackages/kkrpc/src/adapters/skills/interop/SKILL.md