Dotex

About

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:

  1. send accepts Uint8Array and gets it to the other side.
  2. receive(cb) calls cb with each incoming Uint8Array. It returns an unsubscribe function.
  3. 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.