Dotex

About

Getting started

Install, define a router, configure auth, attach a channel, call functions.

Install

npm install @dotex/saferpc

zod ships as a bundled dependency (along with @noble/* and @msgpack/msgpack) β€” nothing else to install. Schema objects passed to .input()/.output() only need a .safeParse() method, so any zod-compatible library works there, but the zod dependency ships regardless.

Define a router

One file, shared between server and client as a type. The client never imports the handler code.

// router.ts
import { saferpc } from "@dotex/saferpc";
import { z } from "zod";

// Bind the handler context type once. `rpc` IS the procedure builder;
// `rpc.router` / `rpc.middleware` hang off the same instance.
export interface Context {
  user: { id: string } | null;
}
export const rpc = saferpc<Context>();

const greet = rpc
  .input(z.object({ name: z.string() }))
  .output(z.object({ message: z.string() }))
  .handler(async ({ ctx, input }) => ({
    message: `Hello, ${ctx.user?.id ?? input.name}!`,
  }));

export const appRouter = rpc.router({ greet });
export type AppRouter = typeof appRouter;

Because rpc carries Context, you can move procedures into their own files (import { rpc } and keep chaining) and ctx stays fully typed.

Quick start: Node server, browser client over WebSocket

Generate a 32-byte secret once and paste the same bytes on both sides:

crypto.getRandomValues(new Uint8Array(32)); // run once, store the result

Server (Node.js, ws package)

// server.ts
import { server } from "@dotex/saferpc";
import { socketChannel } from "@dotex/saferpc/channels";
import { WebSocketServer } from "ws";
import { appRouter } from "./router.js";

const secret = new Uint8Array([
  /* 32 bytes from your generator */
]);

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws) => {
  const { destroy } = server(appRouter, socketChannel(ws), {
    auth: { secret: () => secret },
    context: () => ({ user: null }),
    onError: console.error,
  });
  ws.on("close", destroy);
});

socketChannel is the shipped single-socket adapter: it delivers inbound bytes and throws on send when the socket is not open. The server does not reconnect a client’s socket, so connection death is session death β€” ws.on("close", destroy) handles that.

Client (browser)

// app.ts
import { client } from "@dotex/saferpc";
import { wsChannel } from "@dotex/saferpc/channels";
import type { AppRouter } from "./router";

const secret = new Uint8Array([
  /* same 32 bytes as the server */
]);

const { api } = client<AppRouter>(wsChannel("ws://localhost:8080"), {
  auth: { secret: () => secret },
});

const { message } = await api.greet({ name: "World" });
console.log(message); // "Hello, World!"

wsChannel is the shipped reconnecting adapter: it owns the socket lifecycle (reconnects with backoff, forever, until close()) and throws on send while the transport is down β€” the client core keeps undelivered frames in its own outbound queue and retries them until sendTimeout. That ordering matters: an adapter must never buffer frames itself β€” see Integrations Β§ adapter contract before writing your own.

That is the whole loop. The handshake runs on the first call, every payload is XSalsa20-Poly1305 AEAD over the WS, and if the session drops the client re-handshakes on the next call β€” the failed call surfaces a typed error (it is never silently resent).

What just happened

  1. client() and server() returned synchronously. No top-level await for the library itself.
  2. On api.greet(...), the client sent a TAG_HELLO frame and the server replied with its own.
  3. Both sides derived the same session key from the secret + a fresh ECDH exchange.
  4. The actual call payload went encrypted, with schema validation on both ends.
  5. If the WS reconnects later, the next call re-handshakes transparently.

Error handling

Three error classes:

  • RPCError: local failure where the request provably never left (frame expired in the outbound queue, validation error, handshake failure, session destroyed before send). Safe to retry.
  • RPCAbortedError (subclass of RPCError): the request was sent and the outcome is unknown β€” it may have executed on the server. Retry only idempotent procedures.
  • RemoteRPCError: error returned from the remote peer (code, message, data).
import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc";

try {
  await api.greet({ name: "World" });
} catch (err) {
  if (err instanceof RemoteRPCError) {
    if (err.code === "UNAUTHORIZED") await refreshCredentials();
  } else if (err instanceof RPCAbortedError) {
    // Sent, no reply β€” may have executed. Retry only if idempotent.
  } else if (err instanceof RPCError) {
    // Provably never left β€” retrying cannot double-execute.
    if (err.code === "HANDSHAKE") console.warn("auth mismatch?");
  } else {
    throw err;
  }
}

Middleware and context

Middleware runs before the handler and extends the context. Whatever you pass to next({ ... }) is merged into ctx and its type is inferred for every downstream step. Author reusable middleware with middleware(...) or inline it with .use():

import { RPCError } from "@dotex/saferpc";
import { rpc } from "./router.js";
import { z } from "zod";

// Reusable, and typed against the app's Context. Narrows `user` to non-null.
const authed = rpc.middleware(async ({ ctx, next }) => {
  if (ctx.user === null) throw new RPCError("UNAUTHORIZED", "Login required");
  return next({ user: ctx.user }); // downstream ctx.user is now non-null
});

const getProfile = rpc
  .use(authed)
  .input(z.object({ id: z.string() }))
  .handler(async ({ ctx, input }) => {
    // ctx.user is { id: string } here β€” no null check, no cast
    return db.getProfile(ctx.user.id, input.id);
  });

export const appRouter = rpc.router({ getProfile });

The base context comes from the server. The factory runs per request, so the context is always fresh:

server(appRouter, channel, {
  auth,
  context: ({ auth: verified }) => ({
    user: verified ? { id: verified.userId } : null,
  }),
});

Advanced auth

A pre-shared secret is enough for the fast start. For public clients, per-device identity, or defense-in-depth, Safe RPC ships three more configurations.

Derived session secret

Bind the secret to a per-session identifier instead of a single static key:

import { deriveSessionSecret } from "@dotex/saferpc";

const auth = {
  secret: async () => {
    const sessionToken = await getCurrentSessionToken();
    const deviceSecret = await getDeviceSecret(); // 32+ bytes
    return deriveSessionSecret(sessionToken, deviceSecret);
  },
};

Asymmetric signatures

For public clients or device-level identity. The signer proves identity over the handshake transcript. The verifier rejects bad signatures.

const auth = {
  sign: async (transcript) => signWithDeviceKey(transcript),
  verify: async (proof, transcript) => {
    await verifyPeerSignature(proof, transcript);
    return { auth: { deviceId: "device-123" } };
  },
};

Or use the built-in Ed25519 helpers:

import {
  createEd25519ClientAuth,
  createEd25519ServerAuth,
} from "@dotex/saferpc";

// Client
const auth = createEd25519ClientAuth({ privateKey, deviceId: "device-123" });

// Server
const auth = createEd25519ServerAuth({
  getPublicKey: async (deviceId) => loadDevicePub(deviceId),
});

All built-in helpers (Ed25519, ECDSA, JWT) bind their proof to the canonical handshake transcript. See Security β†’ Built-in signature helpers.

Authentication is directional. Client sign + server verify proves the client’s identity to the server β€” it does not prove the server’s identity to the client. For mutual authentication both sides need sign and verify, or a shared secret (PSK). See Security β†’ Authentication is directional.

Both (defense-in-depth)

Combine a pre-shared secret and asymmetric when you need session binding and individual revocation.

const auth = {
  secret: () => deriveSessionSecret(sessionId, deploymentSecret),
  sign: (transcript) => signWithDeviceKey(transcript),
  verify: (proof, transcript) => verifyPeerSignature(proof, transcript),
};

Choosing an auth mode

Secret when you control both endpoints: server-to-server, internal services, parent ↔ iframe of the same origin. No signature ops on the hot path.

Asymmetric when one side is untrusted or there is no shared secret: public web clients, mobile apps, IoT devices. Per-device revocation.

Both when you want session binding and per-device identity: regulated environments, high-value systems.

The full trade-off breakdown lives in Security.

Next steps

  • Security: threat model, handshake details, what each auth mode protects against
  • Integrations: adapters for WebSocket, postMessage, MessagePort, Chrome extensions, BroadcastChannel, WebRTC, TCP, SSE
  • API: full reference for saferpc(), server(), client(), and every option
  • Protocol: wire format and key derivation, enough to port Safe RPC to another language