Encrypted, typed RPC over any bidirectional channel. Two peers, one shared secret (or one keypair). Every call is end-to-end encrypted with XSalsa20-Poly1305 AEAD. WebSocket, postMessage, MessagePort, chrome.runtime, BroadcastChannel, WebRTC — if a channel can carry bytes, Safe RPC encrypts and types what flows through it.
Think tRPC, but transport-agnostic and encrypted by default.
npm install @dotex/saferpc- Full docs and rationale: https://dotex.org/epic/saferpc
- Quickstart · API · Wire Protocol · Security · Assessment · Transports
- Typed procedures with Zod input/output validation
- End-to-end encryption. X25519 ECDH, XSalsa20-Poly1305 AEAD, HKDF-SHA-256, with forward secrecy by design
- Lazy handshake on the first call. Lazy re-handshake on the next call when the session drops — failures surface with a typed code, never silently resent
- Three auth modes: pre-shared secret, asymmetric (Ed25519 / ECDSA / JWT), or both for defense-in-depth
- Synchronous
client()andserver(). Runs in Node.js, browsers, Service Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy - Tiny surface.
@noble/*crypto,@msgpack/msgpack,zod, and nothing else - Pure ESM + CJS dual build, side-effect-free, tree-shakeable
import { saferpc, server, client } from "@dotex/saferpc";
import { z } from "zod";
// Initialise once, binding your handler context type (à la tRPC).
interface Context {
user: { id: string } | null;
}
const rpc = saferpc<Context>(); // rpc IS the procedure builder
// `rpc` carries `Context`, so you can author procedures in any file and
// `ctx` stays fully typed — no manual casts. `rpc.router` / `rpc.middleware`
// hang off the same instance.
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}!`,
}));
const appRouter = rpc.router({ greet });
export type AppRouter = typeof appRouter;
const secret = crypto.getRandomValues(new Uint8Array(32));
const auth = { secret: () => secret };
const { destroy: stopServer } = server(appRouter, serverChannel, {
auth,
context: () => ({ user: null }),
});
// Pass the router type to `client` for a fully-inferred API.
const { api, destroy: stopClient } = client<AppRouter>(clientChannel, { auth });
const { message } = await api.greet({ name: "World" }); // input & output typedclient() and server() are synchronous. No top-level await. The handshake runs lazily on the first procedure call. If the session drops, the first sent call to hit its reply-timeout surfaces RPCAbortedError("TIMEOUT") and resets the session — the next call re-handshakes lazily. A call that provably never left surfaces a plain RPCError and resets nothing: never-left is a transport event, the keys are fine, retrying is safe. The client never silently resends either way (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see @dotex/saferpc/channels) lets in-flight calls complete across a socket blip. Per-call AbortSignal (api.thing(input, { signal })) cancels waiting without tearing anything down.
Because rpc carries Context, procedures can be authored in any file with a fully-typed ctx, and server() requires a context() that returns the type your procedures expect.
interface Channel {
send(data: Uint8Array): void | Promise<void>;
receive(cb: (data: Uint8Array) => void): () => void; // returns unsubscribe
}Anything that satisfies this can host a Safe RPC session. Ready-made adapters for WebSocket, postMessage, MessagePort, Chrome extension ports, BroadcastChannel, WebRTC, TCP, and SSE live in spec/integrations.md.
Three modes. The auth block is the same shape in all three.
// Secret only. Simple, fast, controlled environments.
auth: { secret: () => sharedSecret }
// Asymmetric only. Public clients, no shared secrets.
auth: {
sign: (transcript) => signWithDeviceKey(transcript),
verify: (proof, transcript) => verifyPeerSignature(proof, transcript),
}
// Both. Session binding plus identity proof.
auth: {
secret: () => deriveSessionSecret(sessionId, deploymentSecret),
sign: (transcript) => signWithDeviceKey(transcript),
verify: (proof, transcript) => verifyPeerSignature(proof, transcript),
}Built-in helpers cover Ed25519, ECDSA P-256, and JWT auth. All bind their proof to the handshake transcript, so a captured payload cannot be replayed into a new session. Certificate-based and multi-factor schemes are composed from sign/verify directly — recipes and the full threat model live in spec/security.md.
import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc";
try {
await api.greet({ name: "World" });
} catch (err) {
if (err instanceof RemoteRPCError) {
// The remote peer threw. err.code / err.message / err.data come from there.
} else if (err instanceof RPCAbortedError) {
// The request WAS sent but the outcome is unknown (TIMEOUT, ABORTED,
// SESSION) — it may have executed. Reconcile state before retrying.
} else if (err instanceof RPCError) {
// Local failure, the request never left the process: CHANNEL, ABORTED,
// SESSION, HANDSHAKE, CLIENT, ... — safe to retry as-is.
// (validation errors like INPUT_VALIDATION run server-side and arrive as RemoteRPCError)
} else {
throw err;
}
}src/
common.ts : shared types, crypto, msgpack, procedure builder
server.ts : resilient handshake server
client.ts : lazy handshake client (no auto-retry)
auth/
index.ts : combined re-exports (deriveSessionSecret + client + server)
client.ts : Ed25519, ECDSA, JWT client helpers
server.ts : Ed25519, ECDSA, JWT server helpers
index.ts : public entry point
import { saferpc, server, client, RPCError } from "@dotex/saferpc";
// Subpaths are also available for tree-shaking:
import { server } from "@dotex/saferpc/server";
import { client } from "@dotex/saferpc/client";
import { saferpc, RPCError } from "@dotex/saferpc/common";
// Auth helpers: combined or split per side
import {
createEd25519ClientAuth,
createEd25519ServerAuth,
} from "@dotex/saferpc/auth";
import { createEd25519ClientAuth } from "@dotex/saferpc/auth/client";
import { createEd25519ServerAuth } from "@dotex/saferpc/auth/server";Node.js 20.19+ (the @noble/* dependencies require >=20.19.0; older 18.x / 20.18 fail to load the ESM-only crypto packages under CommonJS), modern browsers, Service / Web / Shared Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy. WebCrypto is required only for the ECDSA helpers.
0.x with a stable wire protocol (saferpc-v1 HKDF info, saferpc-hs-{hello,reply}-v1 transcript prefixes). Test coverage for handshake attacks, replay, tampering, type confusion, prototype pollution, middleware misuse, and DoS limits lives in test/security/. An internal line-by-line security review — including the honest list of residual risks and open issues — is published in spec/assessment.md. A 1.0 release will lock the public API surface.
Two commands: bump the version, then publish (which also pushes the tag):
npm version patch # or: minor / major / 1.2.3-beta.0
npm publishThe version hook syncs and stages jsr.json into the version commit. prepublishOnly runs lint, tests, and build before publishing; postpublish runs git push --follow-tags. The pushed vX.Y.Z tag triggers .github/workflows/release.yml, which verifies the version is live on npm and creates a GitHub Release with auto-generated changelog notes since the previous tag.
If npm publish fails, the tag exists locally but is not pushed. Fix the issue and re-run npm publish && git push --follow-tags. To abort, run git tag -d vX.Y.Z && git reset --hard HEAD~1.
MIT © Dotex
