Integrations
eRPC asks one thing of the transport: it must move Uint8Array in both directions. That is the whole contract.
interface Channel {
send(data: Uint8Array): void | Promise<void>;
receive(cb: (data: Uint8Array) => void): () => void; // returns unsubscribe
}
Everything below is a one-screen adapter that satisfies that interface. Each one is a few lines of glue around a native transport, and none of them need to know what eRPC does.
Duplex socket transports
Bidirectional byte streams. Each connection maps to one eRPC session.
WebSocket
The most common case: browser or service talking to a server over WS.
function wsChannel(ws: WebSocket): Channel {
return {
send(data) {
ws.send(data);
},
receive(cb) {
const handler = (e: MessageEvent) => {
if (e.data instanceof ArrayBuffer) cb(new Uint8Array(e.data));
};
ws.addEventListener("message", handler);
return () => ws.removeEventListener("message", handler);
},
};
}
Make sure ws.binaryType = "arraybuffer" on the browser side.
// Server (Node.js, ws package)
import { WebSocketServer } from "ws";
import { server } from "@dotex/erpc";
const serverSecret = crypto.getRandomValues(new Uint8Array(32));
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
const { destroy } = server(router, wsChannel(ws), {
auth: { secret: () => serverSecret },
onError: console.error,
});
ws.on("close", destroy);
});
// Client (browser)
const ws = new WebSocket("ws://localhost:8080");
ws.binaryType = "arraybuffer";
await new Promise((r) => (ws.onopen = r));
const { api } = client<typeof router>(wsChannel(ws), {
auth: { secret: () => serverSecret },
});
const user = await api.getUser({ id: "123" });
A WebSocket carries one logical eRPC session per connection. Reconnect = new handshake.
TCP socket (Node.js)
Raw TCP does not preserve message boundaries, so the adapter frames every payload with a 4-byte length prefix.
import net from "net";
function tcpChannel(socket: net.Socket): Channel {
let buffer = new Uint8Array(0);
return {
send(data) {
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, data.length, false);
socket.write(Buffer.concat([len, data]));
},
receive(cb) {
const handler = (chunk: Buffer) => {
buffer = new Uint8Array([...buffer, ...chunk]);
while (buffer.length >= 4) {
const length = new DataView(
buffer.buffer,
buffer.byteOffset,
4,
).getUint32(0, false);
if (buffer.length < 4 + length) break;
const message = buffer.slice(4, 4 + length);
buffer = buffer.slice(4 + length);
cb(message);
}
};
socket.on("data", handler);
return () => socket.off("data", handler);
},
};
}
const tcpServer = net.createServer((socket) => {
const { destroy } = server(router, tcpChannel(socket), {
auth: { secret: () => sharedSecret },
onError: console.error,
});
socket.on("close", destroy);
});
tcpServer.listen(8080);
const socket = net.connect({ port: 8080, host: "localhost" });
const { api } = client<typeof router>(tcpChannel(socket), {
auth: { secret: () => sharedSecret },
});
Message-based transports
Fire-and-forget messaging with reliable delivery semantics.
postMessage (window / iframe)
Two windows on the same machine. Cross-origin if you want.
function postMessageChannel(target: Window, origin: string): Channel {
return {
send(data) {
target.postMessage(data, origin);
},
receive(cb) {
const handler = (e: MessageEvent) => {
if (e.origin !== origin) return; // critical
if (e.data instanceof Uint8Array) cb(e.data);
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
},
};
}
Always check origin. Skipping it is how cross-window attacks happen. The wildcard "*" is fine in development and dangerous in production.
// Parent (server)
const iframe = document.querySelector("iframe") as HTMLIFrameElement;
const { destroy } = server(
router,
postMessageChannel(iframe.contentWindow!, "https://widget.example.com"),
{ auth: { secret: () => sharedSecret } },
);
// iframe (client)
const { api } = client<typeof router>(
postMessageChannel(parent, "https://app.example.com"),
{ auth: { secret: () => sharedSecret } },
);
MessagePort (Worker / SharedWorker / MessageChannel)
Web Workers, SharedWorkers, and any code path that hands you a MessagePort.
function portChannel(port: MessagePort): Channel {
return {
send(data) {
port.postMessage(data, [data.buffer]); // transferable: zero-copy
},
receive(cb) {
const handler = (e: MessageEvent) => cb(new Uint8Array(e.data));
port.addEventListener("message", handler);
port.start();
return () => port.removeEventListener("message", handler);
},
};
}
// Main thread
const worker = new Worker("worker.js");
const { port1, port2 } = new MessageChannel();
worker.postMessage({ port: port2 }, [port2]);
const { api } = client<typeof router>(portChannel(port1), {
auth: { secret: () => sharedSecret },
});
// worker.js
self.onmessage = (e) => {
const port = e.data.port as MessagePort;
server(router, portChannel(port), { auth: { secret: () => sharedSecret } });
};
SharedWorker is the same shape, except self.onconnect gives you the port and you can serve multiple tabs from one worker.
Chrome extension port
Content scripts ↔ background service worker ↔ popup. Native messaging is untyped JSON.
function extensionPortChannel(port: chrome.runtime.Port): Channel {
return {
send(data) {
port.postMessage(Array.from(data));
},
receive(cb) {
const handler = (msg: number[]) => cb(new Uint8Array(msg));
port.onMessage.addListener(handler);
return () => port.onMessage.removeListener(handler);
},
};
}
The Array.from round-trip is the price of chrome.runtime. High-throughput extensions should pin a chrome.runtime.connect between a content script and an offscreen document, then switch to MessagePort there.
// background.js (service worker)
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "erpc") return;
const { destroy } = server(router, extensionPortChannel(port), {
auth: { secret: () => getExtensionPSK() },
context: () => ({
tabId: port.sender?.tab?.id,
frameId: port.sender?.frameId,
}),
});
port.onDisconnect.addListener(destroy);
});
// content-script.js
const port = chrome.runtime.connect({ name: "erpc" });
const { api } = client<typeof router>(extensionPortChannel(port), {
auth: { secret: () => getExtensionPSK() },
});
getExtensionPSK() is whatever your extension uses to derive a secret both sides agree on. Extension ID + version + a stored secret, for example.
BroadcastChannel
Tabs of the same origin talking to each other. One channel, many participants.
function broadcastChannel(name: string): Channel {
const bc = new BroadcastChannel(name);
return {
send(data) {
bc.postMessage(data);
},
receive(cb) {
const handler = (e: MessageEvent) => {
if (e.data instanceof Uint8Array) cb(e.data);
};
bc.addEventListener("message", handler);
return () => bc.removeEventListener("message", handler);
},
};
}
eRPC is a 1:1 protocol. To use BroadcastChannel, elect a single server tab (leader) and let other tabs become clients. The leader holds the session state; clients re-handshake when leadership moves.
const isLeader = await electLeader();
if (isLeader) {
server(router, broadcastChannel("tab-sync"), {
auth: { secret: () => getLeaderPSK() },
});
}
const { api } = client<typeof router>(broadcastChannel("tab-sync"), {
auth: { secret: () => getLeaderPSK() },
});
Peer-to-peer transports
Direct connection between peers without a central relay.
WebRTC DataChannel
Peer-to-peer, no central relay. Usually paired with mutual signature auth because there is no shared infrastructure to put a PSK on.
function webRTCChannel(dc: RTCDataChannel): Channel {
return {
send(data) {
dc.send(data);
},
receive(cb) {
const handler = (e: MessageEvent) => {
if (e.data instanceof ArrayBuffer) cb(new Uint8Array(e.data));
};
dc.addEventListener("message", handler);
return () => dc.removeEventListener("message", handler);
},
};
}
const { api } = client<typeof router>(webRTCChannel(dataChannel), {
auth: {
sign: async (transcript) => signWithMyDeviceKey(transcript),
verify: async (proof, transcript) => verifyPeerKey(proof, transcript),
},
});
Split-channel transports
Asymmetric transports work too. You only need a send and a receive, not a single duplex socket.
Server-Sent Events + fetch
The client sends over fetch and receives over SSE.
function sseChannel(url: string): Channel {
let cb: ((data: Uint8Array) => void) | null = null;
let es: EventSource | null = null;
return {
async send(data) {
await fetch(`${url}/send`, {
method: "POST",
body: data,
headers: { "Content-Type": "application/octet-stream" },
});
},
receive(handler) {
cb = handler;
es = new EventSource(url);
es.onmessage = (e) => {
if (!cb) return;
cb(new Uint8Array(JSON.parse(e.data)));
};
return () => {
cb = null;
es?.close();
es = null;
};
},
};
}
The server side needs an in-memory map from session to SSE stream so it knows where to send replies. The adapter is more involved than the duplex transports, but the eRPC code on top stays identical.
Custom transports
The rules are the same as everywhere else:
sendacceptsUint8Arrayand gets it to the other side.receive(cb)callscbwith each incomingUint8Array. It returns an unsubscribe function.- The transport is allowed to drop, duplicate, or reorder messages. eRPC will time out and retry. It will not behave correctly if your transport silently corrupts bytes. Wrap it in something that fails noisily if you cannot trust it.
That is the whole API surface. Encryption, framing, retry, key management: all on the eRPC side. Your adapter does not need to care.