Dotex

About

API reference

Reference for every exported symbol. End-to-end walkthrough lives in Getting Started, threat model and crypto in Security, wire format in Protocol.

Import paths

// Root entry: everything
import {
  chain, server, client,
  RPCError, RemoteRPCError,
  deriveSessionSecret,
} from "@dotex/erpc";

// Subpaths for tree-shaking
import { ... } from "@dotex/erpc/common";
import { ... } from "@dotex/erpc/server";
import { ... } from "@dotex/erpc/client";

chain()

function chain(): Chain;

Returns a procedure builder. Every method is immutable and chainable. .handler() terminates the chain and returns a frozen Procedure.

interface Chain<TCtx = {}, TIn = unknown, TOut = unknown> {
  use<E>(fn: (opts: {
    ctx: TCtx;
    input: TIn;
    next: (extra?: E) => Promise<unknown>;
  }) => Promise<unknown>): Chain<TCtx & E, TIn, TOut>;

  input<S extends ZodType>(schema: S): Chain<TCtx, z.output<S>, TOut>;
  output<S extends ZodType>(schema: S): Chain<TCtx, TIn, z.output<S>>;

  handler(fn: (opts: { ctx: TCtx; input: TIn }) => Promise<TOut>): Procedure;
}

Method semantics

Method Effect Notes
.use(fn) Adds middleware that can extend context fn must call next() exactly once. next(extra) merges extra into ctx.
.input(schema) Validates incoming input with Zod On failure throws RPCError("INPUT_VALIDATION", ...).
.output(schema) Validates handler output with Zod On failure throws RPCError("OUTPUT_VALIDATION", ...). Runs after handler.
.handler(fn) Terminates the chain Returns a frozen Procedure.

schema is anything with a .safeParse() method (a Zod schema in practice).

Procedure

interface Procedure {
  readonly _steps: ReadonlyArray<Step>;
  readonly _handler: HandlerFn;
}

type Router = Record<string, Procedure>;

Treat Procedure as opaque. The fields are exposed only so server() can introspect them.


server(router, channel, options)

function server<T extends Router>(
  router: T,
  channel: Channel,
  options: ServerOptions,
): { destroy: () => void };

Subscribes to channel and serves the router. Returns synchronously.

ServerOptions

Field Type Default Required
auth AuthOptions β€” βœ…
context (ctx: { auth?: Ctx }) => Ctx | Promise<Ctx> β€” β€”
handshakeTimeout number (ms) 5000 β€”
maxMessageBytes number 1_048_576 β€”
onError (err: unknown) => void β€” β€”

context runs per request. The auth argument carries whatever auth.verify returned for the current session. When context is omitted, the request context falls back to the verified auth data (or {} if none).

onError fires on handshake failures and non-fatal internal errors. The server does not destroy itself on a failed handshake β€” it resets and accepts the next hello.

AuthOptions

interface AuthOptions {
  secret?: () => Uint8Array | Promise<Uint8Array>;
  sign?: (transcript: Uint8Array) => Uint8Array | Promise<Uint8Array>;
  verify?: (
    proof: Uint8Array,
    transcript: Uint8Array,
  ) => VerifyResult | Promise<VerifyResult>;
}

type VerifyResult = { auth?: Ctx } | void;

Set at least one of secret or asymmetric (sign / verify). Configuring neither throws a TypeError at construction.

Field Called Notes
secret Per handshake attempt Returned bytes must be β‰₯ 32. Empty secret used when secret is omitted but asymmetric auth is configured.
sign Per handshake attempt, if set Signature payload, ≀ 32 KiB.
verify Per handshake attempt, if set Throw to reject. Returned auth is bound to the resulting session.

Returned auth data is sanitized (poison keys stripped) before reaching context.

Server lifecycle

waiting β†’ pending β†’ ready
   ↑         |        |
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜   (timeout / new hello / explicit destroy)
  • waiting: accepting hellos, no session.
  • pending: hello processed, reply sent. Transitions to ready only on successful decrypt of the first TAG_MSG.
  • ready: session confirmed. Routes RPCs.
  • A new hello in any state resets the server and starts over.
  • destroy() is permanent: zeros all keys, unsubscribes from the channel, drops references.

client<T>(channel, options)

function client<T extends Router>(
  channel: Channel,
  options: ClientOptions,
): { api: Client<T>; destroy: () => void };

Returns synchronously. The handshake stays lazy: it starts on the first api call.

ClientOptions

Field Type Default Required
auth AuthOptions β€” βœ…
timeout number (ms) 10_000 β€”
maxPending number 256 β€”
handshakeTimeout number (ms) 5000 β€”
maxMessageBytes number 1_048_576 β€”

maxPending caps concurrent in-flight calls. Past the cap, calls reject with RPCError("CLIENT", "Too many pending requests").

timeout is per call. On timeout the client throws RPCError("TIMEOUT", "Timed out: <procedure>") and triggers an auto-retry.

Client<T>

type Client<T extends Router> = {
  [K in keyof T & string]: (input: unknown) => Promise<unknown>;
};

The proxy types check against T. Use typeof router from the server side as the type argument to get full procedure inference.

Client lifecycle

idle β†’ handshaking β†’ ready
  ↑         |          |
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   (hs timeout / call timeout / destroy)
  • idle: no session. Next call triggers startHandshake().
  • handshaking: hello sent. All concurrent calls await the same handshake promise.
  • ready: session key established. Calls go through.
  • closed: destroy() was called. All calls reject; no further work.

Auto-retry

A call that fails on a ready session with a local TIMEOUT or send error triggers a single retry: the client zeros its session key, returns to idle, runs a fresh handshake, and resends the request exactly once. RemoteRPCError (server returned an error) is not retried β€” the server is alive and answered. Concurrent failures share one re-handshake via an epoch counter, so there are no retry storms. Full state-machine and wire-level semantics in Protocol Β§ Auto-retry semantics.

Replay within a session

Per-message AEAD nonces are random, not counter-derived. An attacker who can inject into a live channel can replay a captured ciphertext and the receiver will execute it again. For non-idempotent procedures, add an idempotency key inside input, or keep a request-ID set on the server keyed by the verified principal. Full discussion in Security Β§ Replay within a session.


Channel

interface Channel {
  send(data: Uint8Array): void | Promise<void>;
  receive(cb: (data: Uint8Array) => void): () => void;
}

The only transport contract. receive returns an unsubscribe function. The channel must:

  • Transmit bytes intact (no silent corruption)
  • Deliver each call to cb once, in any order
  • Allow send and receive to run concurrently

Dropping, duplicating, or reordering messages is allowed β€” eRPC will time out and retry. Ready-made adapters live in Integrations.

Within a single session the protocol assumes the TAG_HELLO reply arrives before any TAG_MSG sent under the resulting session key. Transports that can reorder across the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. TAG_MSG-to-TAG_MSG reordering stays safe: every encrypted frame is independently authenticated and the protocol imposes no ordering on application messages.


Errors

class RPCError extends Error {
  readonly code: string;
  readonly data: unknown;
  constructor(code: string, message: string, data?: unknown);
}

class RemoteRPCError extends RPCError {}
  • RPCError is thrown for local failures: timeout, session destroyed, handshake failure, validation failure, channel error.
  • RemoteRPCError is thrown when the remote peer’s handler returned an error. The code, message, and data come from the remote side and are untrusted strings β€” sanitize before logging at warn/error level, or before showing them to a user.

Standard local error codes

Code Thrown when
TIMEOUT RPC call exceeded timeout ms
SESSION destroy() called or session closed
CLIENT Client-side guardrail tripped (e.g., maxPending exceeded)
HANDSHAKE Handshake failed or timed out, auth payload malformed
INPUT_VALIDATION .input(schema) rejected the input
OUTPUT_VALIDATION .output(schema) rejected the handler output
INVALID_DATA Wire-level data rejected by sanitize()
INTERNAL Defensive: should not be reachable
MIDDLEWARE Middleware misuse (next() called twice, bad extra arg)

Handlers may throw RPCError(...) with any code; those codes surface as RemoteRPCError.code on the client.

Pattern

try {
  await api.getProfile({ id: "u_1" });
} catch (err) {
  if (err instanceof RemoteRPCError) {
    // handler threw on the other side: err.code, err.message, err.data
  } else if (err instanceof RPCError) {
    // local failure: TIMEOUT, SESSION, etc.
  } else {
    throw err;
  }
}

Middleware and context

Middleware extends the context. Signature is ({ ctx, input, next }), and next(extra?) must be called exactly once.

const d = chain();

const authed = d.use(async ({ ctx, input, next }) => {
  const user = await getUser(ctx.token);
  if (!user) throw new RPCError("UNAUTHORIZED", "Bad token");
  return next({ user }); // merges into ctx
});

const router = {
  getProfile: authed
    .input(z.object({ id: z.string() }))
    .handler(async ({ ctx, input }) => db.getProfile(input.id)),
};

server(router, channel, {
  auth,
  context: ({ auth: verified }) => ({
    token: getCurrentToken(),
    userId: verified?.userId,
  }),
});

Calling next() twice in the same middleware throws RPCError("MIDDLEWARE", ...). So does passing a non-object extra.

The context factory runs per request, after auth verification, and receives { auth } carrying the data returned by auth.verify for that session.


deriveSessionSecret

function deriveSessionSecret(sessionId: string, secret: Uint8Array): Uint8Array;

HKDF-SHA-256 over secret with sessionId as salt and the fixed info string "erpc-session-v1". Returns 32 bytes.

Throws TypeError if sessionId is empty or secret is shorter than 32 bytes.

Use it to bind each handshake to a session identifier instead of relying on a single static secret.


Built-in auth helpers

Every helper returns a partial AuthOptions you spread into the auth block. Each one binds its proof to the canonical handshake transcript, so a captured payload cannot be replayed into a new handshake.

Client-side

import {
  createJWTClientAuth,
  createEd25519ClientAuth,
  createECDSAClientAuth,
  generateEd25519Keypair,
  generateECDSAKeypair,
} from "@dotex/erpc";
Helper Returns Notes
createJWTClientAuth({ getToken }) { sign } Embeds { jwt, ts, th } where th is SHA-256(transcript).
createEd25519ClientAuth({ privateKey, deviceId }) { sign } Signs the transcript via @noble/curves (no WebCrypto needed).
createECDSAClientAuth({ privateKey, identifier }) { sign } WebCrypto P-256, privateKey is a CryptoKey.
generateEd25519Keypair() { privateKey: Uint8Array, publicKey: Uint8Array } Pure JS, works everywhere.
generateECDSAKeypair() { privateKey: CryptoKey, publicKey: CryptoKey } Non-extractable.

Server-side

import {
  createJWTServerAuth,
  createEd25519ServerAuth,
  createECDSAServerAuth,
  createCertificateServerAuth,
  createMultifactorServerAuth,
} from "@dotex/erpc";
Helper Use
createJWTServerAuth({ verifyToken, maxAge? }) Verifies JWT + timestamp (symmetric skew check) + transcript digest. Returns the verifyToken result as auth.
createEd25519ServerAuth({ getPublicKey, validateDevice? }) Verifies Ed25519 signature against a device’s 32-byte public key.
createECDSAServerAuth({ getPublicKey, validateEntity? }) Verifies ECDSA P-256 signature via WebCrypto.
createCertificateServerAuth({ verifyCertificate, validateSubject? }) Verifies a presented certificate chain + ECDSA P-256 signature.
createMultifactorServerAuth({ primary, secondary, combineAuth? }) Composes two verifiers; both must pass.

Auth payloads decode through the hardened msgpack codec: extension types rejected, prototype-pollution keys stripped, recursion depth capped. Returned auth data is sanitized before reaching context.


Constants

import {
  NONCE_LEN,        // 24: XSalsa20-Poly1305 message nonce
  KEY_LEN,          // 32: symmetric key / X25519 key / hello nonce
  TAG_HELLO,        // 0x00
  TAG_MSG,          // 0x01
  MAX_MSG_BYTES,    // 1_048_576
  MAX_HELLO_BYTES,  // 65_536
  MAX_AUTH_BYTES,   // 32_768
  MAX_DEPTH,        // 32: max `sanitize()` recursion depth
  HANDSHAKE_TIMEOUT,// 5000
  EMPTY_SECRET,     // Uint8Array(32) of zeros: internal "no secret" sentinel
  // Type guards
  isPlainBytes,     // exact-prototype Uint8Array check for wire data
  isEmptySecret,    // constant-time check for the 32-zero secret sentinel
} from "@dotex/erpc";

Exported for adapter authors. Application code rarely needs them.


Cleanup

Call destroy() when you are done with a session.

const { destroy: destroyServer } = server(router, channel, { auth });
const { api, destroy: destroyClient } = client<typeof router>(channel, { auth });

// later
destroyClient(); // rejects pending calls, zeros keys, unsubscribes
destroyServer(); // zeros keys, unsubscribes

After destroy():

  • Further api.foo() calls reject with RPCError("SESSION", "Session destroyed").
  • Incoming messages are ignored.
  • Calling destroy() again is a no-op.

Edge runtime compatibility

Both server() and client() return synchronously, with no top-level await. Dependencies are pure JavaScript. Compatible with:

  • Node.js 18+
  • Modern browsers
  • Service Workers
  • React Native
  • Vercel Edge Functions
  • Cloudflare Workers / Durable Objects
  • Deno Deploy