This project is created for building extension system for a Tauri app (https://github.com/kunkunsh/kunkun).
It's potential can be used in other types of apps, so I open sourced it as a standalone package.
A TypeScript-first RPC library that enables seamless bi-directional communication between processes. Call remote functions as if they were local, with full TypeScript type safety and autocompletion support.
- stdio: RPC over stdio between any combinations of Node.js, Deno, Bun processes
- web: RPC over
postMessage
API and message channel between browser main thread and web workers, or main thread and iframe- Web Worker API (web standard) is also supported in Deno and Bun, the main thread can call functions in worker and vice versa.
- http: RPC over HTTP like tRPC
- supports any HTTP server (e.g. hono, bun, nodejs http, express, fastify, deno, etc.)
- WebSocket: RPC over WebSocket
The core of kkrpc design is in RPCChannel
and IoInterface
.
RPCChannel
is the bidirectional RPC channelLocalAPI
is the APIs to be exposed to the other side of the channelRemoteAPI
is the APIs exposed by the other side of the channel, and callable on the local siderpc.getAPI()
returns an object that isRemoteAPI
typed, and is callable on the local side like a normal local function call.IoInterface
is the interface for implementing the IO for different environments. The implementations are called adapters.- For example, for a Node process to communicate with a Deno process, we need
NodeIo
andDenoIo
adapters which implementsIoInterface
. They share the same stdio pipe (stdin/stdout
). - In web, we have
WorkerChildIO
andWorkerParentIO
adapters for web worker,IframeParentIO
andIframeChildIO
adapters for iframe.
- For example, for a Node process to communicate with a Deno process, we need
In browser, import from
kkrpc/browser
instead ofkkrpc
, Deno adapter uses node:buffer which doesn't work in browser.
interface IoInterface {
name: string
read(): Promise<Buffer | Uint8Array | string | null> // Reads input
write(data: string): Promise<void> // Writes output
}
class RPCChannel<
LocalAPI extends Record<string, any>,
RemoteAPI extends Record<string, any>,
Io extends IoInterface = IoInterface
> {}
kkrpc supports two serialization formats for message transmission:
json
: Standard JSON serializationsuperjson
: Enhanced JSON serialization with support for more data types like Date, Map, Set, BigInt, and Uint8Array (default since v0.2.0)
You can specify the serialization format when creating a new RPCChannel:
// Using default serialization (superjson)
const rpc = new RPCChannel(io, { expose: apiImplementation })
// Explicitly using superjson serialization (recommended for clarity)
const rpc = new RPCChannel(io, {
expose: apiImplementation,
serialization: { version: "superjson" }
})
// Using standard JSON serialization (for backward compatibility)
const rpc = new RPCChannel(io, {
expose: apiImplementation,
serialization: { version: "json" }
})
For backward compatibility, the receiving side will automatically detect the serialization format so older clients can communicate with newer servers and vice versa.
Below are simple examples.
import { NodeIo, RPCChannel } from "kkrpc"
import { apiMethods } from "./api.ts"
const stdio = new NodeIo(process.stdin, process.stdout)
const child = new RPCChannel(stdio, { expose: apiMethods })
import { spawn } from "child_process"
const worker = spawn("bun", ["scripts/node-api.ts"])
const io = new NodeIo(worker.stdout, worker.stdin)
const parent = new RPCChannel<{}, API>(io)
const api = parent.getAPI()
expect(await api.add(1, 2)).toBe(3)
import { RPCChannel, WorkerChildIO, type DestroyableIoInterface } from "kkrpc"
const worker = new Worker(new URL("./scripts/worker.ts", import.meta.url).href, { type: "module" })
const io = new WorkerChildIO(worker)
const rpc = new RPCChannel<API, API, DestroyableIoInterface>(io, { expose: apiMethods })
const api = rpc.getAPI()
expect(await api.add(1, 2)).toBe(3)
import { RPCChannel, WorkerParentIO, type DestroyableIoInterface } from "kkrpc"
const io: DestroyableIoInterface = new WorkerChildIO()
const rpc = new RPCChannel<API, API, DestroyableIoInterface>(io, { expose: apiMethods })
const api = rpc.getAPI()
const sum = await api.add(1, 2)
expect(sum).toBe(3)
Codesandbox: https://codesandbox.io/p/live/4a349334-0b04-4352-89f9-cf1955553ae7
Define API type and implementation.
export type API = {
echo: (message: string) => Promise<string>
add: (a: number, b: number) => Promise<number>
}
export const api: API = {
echo: (message) => {
return Promise.resolve(message)
},
add: (a, b) => {
return Promise.resolve(a + b)
}
}
Server only requires a one-time setup, then it won't need to be touched again.
All the API implementation is in api.ts
.
import { HTTPServerIO, RPCChannel } from "kkrpc"
import { api, type API } from "./api"
const serverIO = new HTTPServerIO()
const serverRPC = new RPCChannel<API, API>(serverIO, { expose: api })
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/rpc") {
const res = await serverIO.handleRequest(await req.text())
return new Response(res, {
headers: { "Content-Type": "application/json" }
})
}
return new Response("Not found", { status: 404 })
}
})
console.log(`Start server on port: ${server.port}`)
import { HTTPClientIO, RPCChannel } from "kkrpc"
import { api, type API } from "./api"
const clientIO = new HTTPClientIO({
url: "http://localhost:3000/rpc"
})
const clientRPC = new RPCChannel<{}, API>(clientIO, { expose: api })
const clientAPI = clientRPC.getAPI()
const echoResponse = await clientAPI.echo("hello")
console.log("echoResponse", echoResponse)
const sum = await clientAPI.add(2, 3)
console.log("Sum: ", sum)
import { ChromeBackgroundIO, RPCChannel } from "kkrpc"
import type { API } from "./api"
// Store RPC channels for each tab
const rpcChannels = new Map<number, RPCChannel<API, {}>>()
// Listen for tab connections
chrome.runtime.onConnect.addListener((port) => {
if (port.sender?.tab?.id) {
const tabId = port.sender.tab.id
const io = new ChromeBackgroundIO(tabId)
const rpc = new RPCChannel(io, { expose: backgroundAPI })
rpcChannels.set(tabId, rpc)
port.onDisconnect.addListener(() => {
rpcChannels.delete(tabId)
})
}
})
import { ChromeContentIO, RPCChannel } from "kkrpc"
import type { API } from "./api"
const io = new ChromeContentIO()
const rpc = new RPCChannel<API, API>(io, {
expose: {
updateUI: async (data) => {
document.body.innerHTML = data.message
return true
}
}
})
// Get API from background script
const api = rpc.getAPI()
const data = await api.getData()
console.log(data) // { message: "Hello from background!" }
Call functions in bun/node/deno processes from Tauri app with JS/TS.
It allows you to call any JS/TS code in Deno/Bun/Node processes from Tauri app, just like using Electron.
Seamless integration with Tauri's official shell plugin and unlocked shellx plugin.
import { RPCChannel, TauriShellStdio } from "kkrpc/browser"
import { Child, Command } from "@tauri-apps/plugin-shell"
const localAPIImplementation = {
add: (a: number, b: number) => Promise.resolve(a + b)
}
async function spawnCmd(runtime: "deno" | "bun" | "node") {
let cmd: Command<string>
let process = Child | null = null
if (runtime === "deno") {
cmd = Command.create("deno", ["run", "-A", scriptPath])
process = await cmd.spawn()
} else if (runtime === "bun") {
cmd = Command.create("bun", [scriptPath])
process = await cmd.spawn()
} else if (runtime === "node") {
cmd = Command.create("node", [scriptPath])
process = await cmd.spawn()
} else {
throw new Error(`Invalid runtime: ${runtime}, pick either deno or bun`)
}
// monitor stdout/stderr/close/error for debugging and error handling
cmd.stdout.on("data", (data) => {
console.log("stdout", data)
})
cmd.stderr.on("data", (data) => {
console.warn("stderr", data)
})
cmd.on("close", (code) => {
console.log("close", code)
})
cmd.on("error", (err) => {
console.error("error", err)
})
const stdio = new TauriShellStdio(cmd.stdout, process)
const stdioRPC = new RPCChannel<typeof localAPIImplementation, RemoteAPI>(stdio, {
expose: localAPIImplementation
})
const api = stdioRPC.getAPI();
await api
.add(1, 2)
.then((result) => {
console.log("result", result)
})
.catch((err) => {
console.error(err)
})
process?.kill()
}
I provided a sample tauri app in examples/tauri-demo
.