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 toreadyonly on successful decrypt of the firstTAG_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 triggersstartHandshake().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
cbonce, in any order - Allow
sendandreceiveto 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_HELLOreply arrives before anyTAG_MSGsent 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_MSGreordering 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 {}
RPCErroris thrown for local failures: timeout, session destroyed, handshake failure, validation failure, channel error.RemoteRPCErroris thrown when the remote peerβs handler returned an error. Thecode,message, anddatacome 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 withRPCError("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