Docs / Packages / @zudojs/rpc
v1.4.0

@zudojs/rpc

Call a function that runs on another machine as if it were local — typed procedures, a transport you choose, middleware, validation, timeouts, retries and errors that survive the trip.

RPC REMOTE PROCEDURE CLIENT SERVER

OVERVIEW

A remote procedure call (RPC) is a function call that runs somewhere else. Your code writes await client.call("users.getUser", { id: "123" }) and gets an answer back. The work happens in another process, on another machine, or behind a network connection.

Two things make that work. A procedure is the function on the far side: a name, a handler, and some options. A transport is the pipe that carries a request over and a response back.

This package gives you both halves plus everything in between — validation, middleware, timeouts, retries, typed errors. Two transports ship with it: an in-memory one that hands the request to a server in the same process, and an HTTP one built on the web-standard Fetch API (a client transport plus a server handler). The server does not care which one carried a call, so the same procedures run in memory in your tests and over HTTP in production without touching a handler. Anything else — a message broker, a socket — is a transport you write yourself. There is no WebSocket transport.

When you need it
  • One service calls another service's functions.
  • The same call must work over HTTP in production and in memory in tests.
  • Auth, tracing or rate limits belong on every call, in one place.
  • Failures must arrive as typed errors, not strings.
When you don't
  • Both sides are in one process — use @zudojs/api.
  • You are building a public REST API — use @zudojs/http.
  • The work should happen later — use @zudojs/queue.
  • You need a stream of results. Streaming cannot be dispatched yet.

INSTALLATION

$ npm install @zudojs/rpc

It pulls in @zudojs/errors, @zudojs/schema, @zudojs/constants, @zudojs/types and @zudojs/serialization (the frame codec) on its own. Install @zudojs/schema yourself only if you write schemas in your own code.

These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
Node 24+. The client uses node:crypto for request ids, so it runs on a server, not in a browser.

QUICK START

The smallest complete setup is a server holding one procedure, a transport, and a client. createRPCMemoryTransport hands the request straight to the server in the same process, which is how the package's own tests run.

import { RPCServer, RPCClient, createRPCProcedure, createRPCMemoryTransport, } from "@zudojs/rpc"; // 1. The far side: a server holding one named procedure. const server = new RPCServer(); server.register( createRPCProcedure<{ id: string }, { id: string; name: string }>( "users.getUser", async (input) => { return { id: input.id, name: "Alice" }; }, ), ); // 2. The pipe. This one stays in the process; the HTTP one crosses a network. const transport = createRPCMemoryTransport(server); // 3. The near side: call it like a local function. const client = new RPCClient(transport); const user = await client.call<{ id: string }, { id: string; name: string }>( "users.getUser", { id: "123" }, ); console.log(user); // { id: "123", name: "Alice" }

What you should see: { id: "123", name: "Alice" }. The client built a request with a fresh id, the transport carried it, the server found the procedure, ran the handler, and the client unwrapped the result.

Tip: swap step 2 for createRPCHttpTransport({ url }) and the same call goes over HTTP. Nothing else changes. See Over HTTP.

PROCEDURES

A procedure is one callable function on the server, wrapped with a name and some settings. createRPCProcedure(name, handler, options?) builds one and freezes it.

Names are checked the moment you create the procedure, not when a call arrives. A name must be dot-separated segments that each start with a lowercase letter: "users.getUser" is fine, "getUser" and "Users.get" are not. A typo becomes a startup crash instead of a call that can never be routed.

A registry is the lookup table of procedures. RPCServer makes one for you, or you can build your own and pass it in.

import { createRPCProcedure, RPCProcedureRegistry } from "@zudojs/rpc"; const getUser = createRPCProcedure<{ id: string }, { id: string; name: string }>( "users.getUser", async (input) => { return { id: input.id, name: "Alice" }; }, { description: "Look up one user.", idempotent: true, timeout: 5000 }, ); const registry = new RPCProcedureRegistry(); registry.register(getUser); console.log(registry.describe()); // [ { name: "users.getUser", description: "Look up one user.", // idempotent: true, timeout: 5000 } ]

The five options, in plain terms:

  • description — a human-readable line, readable only through describe().
  • idempotent — says calling twice is safe. Advisory: nothing retries or de-duplicates for you, but your retry policy can read it from describe().
  • timeout — milliseconds the handler may run. Enforced: the context signal is aborted and the caller gets RPC_TIMEOUT. Must be positive.
  • input — a schema the payload is parsed against before the handler runs.
  • output — a schema the result is checked against before it is sent.
Watch out: registering the same name twice throws RPCDuplicateProcedureError. Registration is not an upsert — call registry.unregister(name) first if you mean to replace one.

TRANSPORTS

A transport is an object with one required method: send(request, options?). It takes an RPCRequest, gets it to the server however it likes, and resolves with the RPCResponse that comes back. options.signal aborts the call and options.timeout is the deadline in milliseconds. An optional close() releases the connection.

You rarely write one. The package ships three pieces, and the client and server never know which one is in use:

Function Side Use it for
createRPCMemoryTransport(server, options?)ClientTests, and modular monoliths where caller and server share a process.
createRPCHttpTransport({ url, … })ClientCalling a server in another process over HTTP, with the global fetch.
createRPCFetchHandler(server, options?)ServerAnswering those HTTP calls. It is a web-standard (request: Request) => Promise<Response>.

In memory

The memory transport calls server.handle() directly. It still behaves like a network in one important way: by default every request and response is round-tripped through JSON. The server never shares objects with the caller, and a value that could not cross a real network (a BigInt result, a circular object) fails here too, so your tests catch it before production does.

import { RPCClient, createRPCMemoryTransport } from "@zudojs/rpc"; const client = new RPCClient( createRPCMemoryTransport(server, { // Handed to the server as the trusted context.auth. auth: { userId: "u1" }, }), { timeout: 5_000 }, ); const user = await client.call("users.getUser", { id: "123" });
  • auth — the identity the server sees as context.auth: a fixed value, or a function of the request. In memory, your own code is the one vouching for it.
  • serializer — how frames are copied. Pass another serializer to match a remote transport, or false to hand frames over by reference.
  • When the caller aborts (its signal fires, or the call times out), the transport passes that signal to the server, so the handler's context.signal aborts and the server stops the work too.
  • After client.close() every send fails with RPCUnavailableError.

Writing your own

For any other carrier, implement send yourself. This one hands each frame to a message broker (the broker object stands in for your client library). On the far side, pass the frame to server.handle(frame) and send its response back.

import type { RPCTransport, RPCResponse } from "@zudojs/rpc"; const transport: RPCTransport = { async send(request, options): Promise<RPCResponse> { // Honouring the signal is what frees the connection when // the caller cancels or the call times out. return broker.request("rpc", request, { signal: options?.signal }); }, };
In plain words: the client still protects itself if your transport ignores options.signal — it races the signal on its own. But the connection stays open until the transport lets it go. mapRPCError(error) is the mapping the server uses to turn a thrown error into a wire payload, if your transport needs to answer one itself.

OVER HTTP

Serving procedures over HTTP takes two pieces. On the server, createRPCFetchHandler(server) turns your RPCServer into a fetch handler: a function that takes a web-standard Request and returns a Response. On the caller, createRPCHttpTransport({ url }) POSTs each call to it as JSON.

Because the handler speaks the Fetch API, anything that serves RequestResponse can host it: @zudojs/http, Bun.serve, Deno.serve, or an edge runtime. On @zudojs/http, mount it with mountFetchHandler and no glue code:

// server.ts import { RPCServer, createRPCProcedure, createRPCFetchHandler } from "@zudojs/rpc"; import { createRouter, mountFetchHandler } from "@zudojs/http"; const server = new RPCServer(); server.register( createRPCProcedure("math.sum", async (input: { a: number; b: number }) => input.a + input.b), ); const handle = createRPCFetchHandler(server, { // Verify the caller here; the result becomes context.auth. auth: async (request) => verifyBearer(request.headers.get("authorization")), onInternalError: (error, requestId) => console.error(requestId, error), }); const router = createRouter(); mountFetchHandler(router, "/rpc", handle); // Or elsewhere: Bun.serve({ port: 3000, fetch: handle });
// client.ts import { RPCClient, createRPCHttpTransport } from "@zudojs/rpc"; const client = new RPCClient( createRPCHttpTransport({ url: "http://127.0.0.1:3000/rpc", // A function runs per call, so a refreshed token is always used. headers: () => ({ authorization: `Bearer ${currentToken()}` }), }), { timeout: 5_000 }, ); const total = await client.call<{ a: number; b: number }, number>("math.sum", { a: 1, b: 2 }); console.log(total); // 3

What you should see: 3. verifyBearer and currentToken are yours to write. The handler answers every path it is mounted on, so the prefix mountFetchHandler strips does not matter.

What the handler does for you

  • It accepts only POST with a JSON content type, and reads at most maxBodyBytes (default DEFAULT_RPC_HTTP_MAX_BODY_BYTES, 1 MiB plus 64 KiB for the envelope) without buffering more.
  • Every reply is an RPC frame, even for a bad HTTP request (wrong method, wrong content type, oversized or invalid body). A caller never gets an HTML error page.
  • An auth hook that throws RPCAuthenticationError refuses the call. Anything else it throws is answered as an internal error.
  • The call runs under the request's signal. If the client disconnects, the procedure is cancelled (context.signal aborts and the dispatch ends with RPC_CANCELLED) instead of running on to its timeout for nobody.
  • A frame whose payload or metadata holds a __proto__, constructor or prototype key is refused with RPC_INVALID_REQUEST. See Refusing unsafe keys.
  • The HTTP status follows the wire code, as below. The status is advisory, for proxies and dashboards; error.code in the body is what counts.
Wire codeHTTP status
success200
RPC_INVALID_REQUEST, RPC_DESERIALIZATION_ERROR400
RPC_UNAUTHENTICATED401
RPC_FORBIDDEN403
RPC_PROCEDURE_NOT_FOUND, RPC_NOT_FOUND404
RPC_CONFLICT409
RPC_VALIDATION_ERROR422
RPC_RATE_LIMITED429
RPC_CANCELLED499
RPC_UNAVAILABLE503
RPC_TIMEOUT, RPC_DEADLINE_EXCEEDED504
RPC_INTERNAL_ERROR, RPC_SERIALIZATION_ERROR, any custom code500

What the client transport does for you

It aborts the underlying fetch when the call's signal or deadline fires, so a timed-out call frees its connection. Its failures are typed: a network failure, or a reply that is not an RPC frame for this request (a proxy's HTML page, a truncated or oversized body), is an RPCTransportError; an expired deadline is an RPCTimeoutError; a caller abort is an RPCCancelledError. The deadline is clamped to the range Node's timers accept (MAX_TIMER_DELAY, about 24.8 days): a larger timeout, or Infinity, used to overflow and fail every call at once, and now simply means "wait a very long time". Other options: fetch (your own fetch function), serializer and maxResponseBytes.

Watch out: use the same serializer on both ends. The default is plain JSON. createRPCJsonSerializer({ preserveTypes: true }) also carries Date, BigInt, Map and Set, but only if the server and the client both use it.

CONTEXT

Every handler gets two arguments: the parsed input, and a context. The context is the per-call scratchpad. It holds the original request, the metadata (small labelled values like requestId, userId or traceId that travel alongside the payload), an AbortSignal, and a key/value store. Metadata is written by the caller and is untrusted. The context also carries auth (identity your transport verified, passed as server.handle(request, { auth })) and input (the payload as the input schema parsed it).

The store is how middleware talks to a handler: middleware writes with context.set(), the handler reads with context.get().

The signal is how a call is cancelled. It aborts when the procedure's timeout or the caller's deadline passes, and also when the caller goes away: the second argument of server.handle(request, { auth, signal }) is an RPCContextOptions, and its signal is the caller's. The built-in HTTP handler and memory transport pass it for you; a custom transport should pass the signal of its connection. Nothing forces a handler to stop, so check the signal wherever giving up makes sense.

import { createRPCProcedure, throwIfCancelled } from "@zudojs/rpc"; const report = createRPCProcedure<{ rows: number }, number>( "reports.build", async (input, context) => { console.log(context.metadata.requestId); console.log(context.get<string>("actor")); let total = 0; for (let row = 0; row < input.rows; row += 1) { // Throws RPCCancelledError once the call is cancelled. throwIfCancelled(context.signal, "reports.build"); total += row; } return total; }, { timeout: 2000 }, );

What happens: finish inside two seconds and the caller gets the number. Otherwise the dispatcher aborts the signal, the next throwIfCancelled throws, and the caller receives RPC_TIMEOUT rather than a half-built result.

MIDDLEWARE

Middleware is a function that wraps every call. It receives the context and a next() function. Work before next() happens on the way in, work after it on the way out, and not calling it at all short-circuits the call.

Middleware lives in an RPCMiddlewareStack, which is immutable — stack.with(...) returns a new stack. A stack holds at most 32 entries.

import { RPCServer, RPCMiddlewareStack, createRPCProcedure, createRPCRequest, RPCAuthenticationError, } from "@zudojs/rpc"; import type { RPCMiddleware } from "@zudojs/rpc"; const requireUser: RPCMiddleware = async (context, next) => { if (typeof context.auth?.userId !== "string") { throw new RPCAuthenticationError("Sign in first."); } context.set("actor", context.auth.userId); return next(); }; const server = new RPCServer( undefined, new RPCMiddlewareStack([requireUser]), ); server.register( createRPCProcedure("users.list", async () => [{ id: "1" }]), ); const response = await server.handle( createRPCRequest({ id: "req-1", procedure: "users.list", payload: {} }), ); console.log(response.success, response.error?.code); // false "RPC_UNAUTHENTICATED"

Pass verified identity as the second argument, server.handle(request, { auth: { userId: "u1" } }), and the same call prints true undefined. Never authorise on metadata: any caller can set metadata.userId. Order matters too: the first middleware in the array is the outermost, so it also sees calls the ones below it reject.

Danger: call next() exactly once. A second call throws, because re-entering the chain would run the handler — and every middleware below it — twice.

An interceptor is the same idea with an object shape, { intercept(context, next) }, registered through the server's dispatch.interceptors option. Interceptors wrap the middleware stack and input validation, so they observe the whole dispatch — the right place for tracing spans and metrics. createNoopRPCInterceptor() gives you one that only calls next().

VALIDATING INPUT

A request arrives from somewhere you do not control, so its payload is untrusted data — not the TypeScript type you wrote. Types disappear at runtime; a caller can send anything.

Give a procedure an input schema and the dispatcher parses the payload before your handler sees it. A bad payload never reaches your code.

import { RPCServer, createRPCProcedure, createRPCRequest, } from "@zudojs/rpc"; import { objectSchema, stringSchema } from "@zudojs/schema"; const server = new RPCServer(); server.register( createRPCProcedure( "users.getUser", async (input: { id: string }) => ({ id: input.id, name: "Alice" }), { input: objectSchema({ id: stringSchema() }) }, ), ); const response = await server.handle( createRPCRequest({ id: "req-1", procedure: "users.getUser", payload: { id: 42 }, }), ); console.log(response.success, response.error?.code); // false "RPC_VALIDATION_ERROR"

response.error.details holds one entry per problem, each with path, code and message. The value the caller sent is left out on purpose, so a validation reply never echoes their data back. An output schema failure is treated as your bug: the caller gets RPC_INTERNAL_ERROR with the fixed internal-error message, and the failing paths go to onInternalError. The client also passes its effective deadline to the transport as options.timeout.

Before any of that, server.handle() checks the envelope — the request must be an object, id a non-empty string of at most 128 characters, procedure a valid name, metadata an object when present (it may be omitted), and payload and metadata together no larger than 1 MB encoded. Anything else is RPC_INVALID_REQUEST.

Two of those bounds are new in 1.3.0. The size cap used to measure payload alone, so an unbounded metadata object reached middleware and handlers as context.metadata however large it was; the two are now measured together against limits.maxPayloadBytes, and a frame whose payload and metadata together exceed it is rejected where it used to be accepted. Budget for both when you size a frame. The id was unbounded and echoed verbatim into both the success and the error response; it is now capped by limits.maxRequestIdLength, defaulting to MAX_RPC_REQUEST_ID_LENGTH (128, enough for a UUID, a ULID or a W3C trace id). The id is checked before a response exists to carry it, so an over-long one is never reflected back.

const server = new RPCServer(undefined, undefined, { limits: { // payload + metadata combined, encoded bytes. Default 1 MB. maxPayloadBytes: 256 * 1024, // request.id characters. Default 128. maxRequestIdLength: 128, }, });

Set either limit to 0 to skip that check when the transport already enforces a frame limit.

Refusing unsafe keys

JSON.parse keeps a key named __proto__, constructor or prototype as an ordinary property. A handler that later merges its input into another object ({ ...defaults, ...input } is safe, Object.assign into a shared object or a hand-written deep merge often is not) can have that object's prototype replaced: an attack called prototype pollution. So the server refuses a frame whose payload or metadata holds one of those keys at any depth, before any middleware or handler runs.

const response = await server.handle( createRPCRequest({ id: "req-2", procedure: "users.getUser", payload: JSON.parse('{"id":"1","__proto__":{"isAdmin":true}}'), }), ); console.log(response.error?.code, "|", response.error?.message); // RPC_INVALID_REQUEST | Request contains the forbidden key "__proto__".

If a procedure genuinely receives such keys (a JSON document store, say) and never merges them, turn the check off with new RPCServer(undefined, undefined, { limits: { allowUnsafeKeys: true } }). The check is findUnsafeKey(value) from @zudojs/security; call it yourself on anything else you decode.

ERRORS ACROSS THE BOUNDARY

A thrown error cannot travel over a network. Only data can. So the boundary works in three steps: your handler throws a typed error; the server turns it into a small JSON payload with a code, a message and sometimes details; the client reads the code and throws a matching error on your side.

The code is the contract. It is why a caller can tell a rejected session from a timeout without matching on message text.

You do not have to throw RPC classes. Domain code usually throws the ready-made errors from @zudojs/errors, and those that are built with expose: true (the 4xx ones are, by default) reach the caller with their own message under the code that matches their status. A ValidationError sends its issues as details (without the values the caller sent), and a RateLimitError built with retryAfterSeconds sends { retryAfter }. Before v1.4.0 they all arrived as RPC_INTERNAL_ERROR, and RPC_NOT_FOUND (404) and RPC_CONFLICT (409) did not exist.

Thrown on the server Wire code
RPCProcedureNotFoundErrorRPC_PROCEDURE_NOT_FOUND
RPCValidationErrorRPC_VALIDATION_ERROR
RPCInvalidRequestErrorRPC_INVALID_REQUEST
RPCAuthenticationErrorRPC_UNAUTHENTICATED
RPCForbiddenErrorRPC_FORBIDDEN
RPCRateLimitedErrorRPC_RATE_LIMITED
RPCDeadlineExceededErrorRPC_DEADLINE_EXCEEDED
RPCTimeoutErrorRPC_TIMEOUT
RPCCancelledErrorRPC_CANCELLED
RPCUnavailableErrorRPC_UNAVAILABLE
RPCSerializationErrorRPC_SERIALIZATION_ERROR
RPCDeserializationErrorRPC_DESERIALIZATION_ERROR
NotFoundError (404)RPC_NOT_FOUND
ConflictError (409)RPC_CONFLICT
ValidationError (400/422)RPC_VALIDATION_ERROR
AuthenticationError (401)RPC_UNAUTHENTICATED
AuthorizationError (403)RPC_FORBIDDEN
RateLimitError (429)RPC_RATE_LIMITED
any other @zudojs/errors error with expose: trueby status (408/504 → RPC_TIMEOUT, 503 → RPC_UNAVAILABLE), otherwise its own code
anything else, and any error with expose: falseRPC_INTERNAL_ERROR
Danger: an unrecognised or non-exposed error never sends its own message. Exception text can name hosts, paths, credentials or queries, and the caller is a stranger. They get one fixed sentence plus the request id. The same applies to any RPCError thrown with expose: falseRPCInternalError, RPCSerializationError, or a plain new RPCError(...) — its code is sent but its message is replaced. A @zudojs/errors error with expose: false (a DatabaseError, for example) goes out as RPC_INTERNAL_ERROR with the fixed message. Pass the server's onInternalError hook if you want the real error — that hook is the only place it is recorded.

On the caller's side, RPCClient rebuilds a typed error from the wire code (the same mapping is exported as rpcErrorFromWire), so you can branch with instanceof instead of comparing strings. The codes above come back as RPCProcedureNotFoundError, RPCValidationError (with its issues), RPCInvalidRequestError, RPCAuthenticationError, RPCForbiddenError, RPCRateLimitedError, RPCTimeoutError, RPCCancelledError or RPCUnavailableError. Any other code, including RPC_NOT_FOUND and RPC_CONFLICT, becomes a plain RPCError. Either way, error.code is the wire code and error.details holds the server's details, so error.code === "RPC_TIMEOUT" and error instanceof RPCTimeoutError both work. That includes errors the client raises itself (its own deadline, a cancelled signal, a closed client), and "RPC_TRANSPORT_ERROR" for a network failure. The class codes such as "ERR_RPC_TIMEOUT" are never what a client error carries; before v1.4.0 timeouts, cancellations and unavailability came back with them, so code comparing error.code with "RPC_TIMEOUT" silently never matched.

import { RPCServer, RPCClient, createRPCProcedure, createRPCMemoryTransport, RPCForbiddenError, } from "@zudojs/rpc"; const server = new RPCServer(undefined, undefined, { onInternalError(error, requestId) { console.error("internal failure", requestId, error); }, }); server.register( createRPCProcedure("admin.purge", async () => { throw new RPCForbiddenError("Admins only."); }), ); const client = new RPCClient(createRPCMemoryTransport(server)); try { await client.call("admin.purge", {}); } catch (error) { if (error instanceof RPCForbiddenError) { console.log(error.code, "|", error.message); } }

What you should see: RPC_FORBIDDEN | Admins only. The error object itself did not cross the boundary — only its code and message did — but the client rebuilt an RPCForbiddenError from that code, so the instanceof check passes. The same works over HTTP.

This one throws plain @zudojs/errors errors from the handler and branches on the code on the caller's side:

import { RPCServer, RPCClient, createRPCProcedure, createRPCMemoryTransport, RPCError } from "@zudojs/rpc"; import { NotFoundError, databaseQueryError } from "@zudojs/errors"; const server = new RPCServer(); server.register( createRPCProcedure("users.get", async (input: { id: string }) => { throw new NotFoundError(`User ${input.id} was not found.`); }), ); server.register( createRPCProcedure("reports.run", async () => { throw databaseQueryError('relation "users" does not exist'); }), ); const client = new RPCClient(createRPCMemoryTransport(server)); for (const [name, input] of [["users.get", { id: "42" }], ["reports.run", {}]] as const) { try { await client.call(name, input); } catch (error) { if (error instanceof RPCError) console.log(error.code, "|", error.message); } } // RPC_NOT_FOUND | User 42 was not found. // RPC_INTERNAL_ERROR | The server encountered an internal error while handling this request.

The NotFoundError is exposed, so its message travels. The DatabaseError is not, so the caller sees only the fixed sentence, and the SQL detail stays on the server, where onInternalError can log it.

TIMEOUTS, RETRIES & CANCELLATION

Networks fail in ways local calls do not: a call can hang forever, or fail once and work on the next try. A timeout is a duration in milliseconds, and both sides have one. On the server it is the procedure's timeout option, falling back to 30 seconds. On the client it is RPCCallOptions.timeout, falling back to the client's default and then to 30 seconds.

A deadline is different: a wall-clock instant in epoch milliseconds, placed in metadata.deadline. It can only shorten a timeout, never extend it, and a deadline already in the past is rejected before any work runs.

retry() runs an operation again after a failure. attempts counts total tries, so attempts: 3 means one call and up to two retries. Jitter — deliberate randomness in each wait — is on by default, because otherwise every client that failed at the same instant retries at the same instant and knocks the dependency over again.

import { retry, RPCUnavailableError } from "@zudojs/rpc"; let tries = 0; const value = await retry( async () => { tries += 1; if (tries < 3) { throw new RPCUnavailableError("Service warming up."); } return "ready"; }, { attempts: 3, delay: 100, backoff: "exponential", maxDelay: 10_000, // Only retry failures a retry could plausibly fix. retryIf: (error) => error instanceof RPCUnavailableError, onRetry: (error, attempt, wait) => { console.log("retry", attempt, "in", wait, "ms"); }, }, ); console.log(value, tries); // ready 3

What you should see: two retry lines with randomised waits, then ready 3.

To cancel a call yourself, pass a signal in RPCCallOptions.signal. An AbortSignal alone cannot be aborted — only its controller can — so createCancellableSignal() hands you both as { signal, cancel }. Whatever reason the abort carries, the client always throws an RPCCancelledError, keeping the original as cause. The cancel reaches the server as well: the handler's context.signal aborts, so a cancelled or timed-out call stops the work on both sides.

The client's deadline timer and the retry() backoff timer keep the Node process alive while they wait, and are cleared the moment the call settles. So a plain script that awaits a call really does wait for its timeout. Before v1.4.0 those timers did not hold the process open, and a script whose only pending work was an unanswered call exited early with code 13 ("unsettled top-level await") instead of reporting RPC_TIMEOUT.

// hang.mjs import { RPCClient, RPCTimeoutError } from "@zudojs/rpc"; // A transport that never answers, standing in for a server that hangs. const client = new RPCClient({ send: () => new Promise(() => {}) }, { timeout: 200 }); try { await client.call("users.get", {}); } catch (error) { console.log(error instanceof RPCTimeoutError, error.code, "|", error.message); }
$ node hang.mjs; echo "exit code $?" true RPC_TIMEOUT | RPC operation timed out after 200ms. exit code 0

The client raised this timeout itself, and its code is still the wire code RPC_TIMEOUT, the same one a server-side timeout arrives with.

Watch out: a client allows 1024 calls in flight at once (maxPending). Past that, call() fails immediately with RPCUnavailableError rather than queueing without bound. client.pendingCount and client.inspectPending() show what is running.

RPC vs API vs HTTP

Three Zudo packages run "something the caller asked for". They differ in where the caller is and what the wire looks like.

Package Caller is You work with
@zudojs/api In the same process Operations run by APIExecutor. Its bindings can also serve the same operations over HTTP, RPC, a queue or the CLI.
@zudojs/rpc Another process or machine Procedures, a request/response envelope, and a transport: in memory, HTTP, or your own.
@zudojs/http Any HTTP client Methods, paths, status codes, headers, CORS — the protocol itself.

They stack rather than compete. A typical service mounts createRPCFetchHandler(server) on an @zudojs/http router with mountFetchHandler(router, "/rpc", handle), and fills the server with @zudojs/api operations through that package's registerApiRpcProcedures(server, registry, { executor }), so one operation definition becomes a procedure without a hand-written wrapper.

In plain words: define the work with api, use rpc when your own services call each other, and use http when the caller is a browser or a third party expecting REST.

API REFERENCE

Classes

NameWhat it doesNotes
RPCServer Validates a request, dispatches it, maps errors to wire codes. (registry?, middleware?, options?); register, handle, getRegistry.
RPCClient Builds requests, sends them through a transport, unwraps results. (transport, options?); call, close, inspectPending, pendingCount.
RPCDispatcher Runs one request: schemas, middleware, interceptors, timeout, deadline. (registry, middleware, options?). RPCServer builds one for you.
RPCProcedureRegistry Name-to-procedure table with uniqueness enforced. register, get, has, require, list, describe, unregister, clear.
RPCProcedureRouter Groups procedures before you hand them to a registry. Same methods; register chains, and list() returns procedures, not names.
RPCMiddlewareStack Immutable ordered list of middleware. with(...) returns a new stack; size, execute.

Functions

NameWhat it doesNotes
createRPCProcedureBuilds a frozen procedure.(name, handler, options?); validates the name immediately.
createRPCRequestBuilds a request envelope.Defaults metadata to {}, timestamp to now.
createRPCResponse
createRPCErrorResponse
Build success and failure responses.(id, result | errorPayload, metadata?).
createRPCMetadata
createRPCContext
Build a frozen metadata object, and a context from a request plus a signal.Both are conveniences; the dispatcher makes contexts for you.
createNoopRPCInterceptorAn interceptor that only calls next().Placeholder or default.
retry
calculateRetryDelay
Re-run an operation after failure; compute one wait.Jitter defaults to "full" in retry, "none" in the calculation.
createTimeout
withTimeout
runWithTimeout
Time-box a promise or an operation.createTimeout returns { promise, cancel } — always cancel(). runWithTimeout also hands the operation a signal.
createCancellableSignal
cancelSignal
combineSignals
throwIfCancelled
Make, trigger, merge and check abort signals.The first returns { signal, cancel }; combineSignals returns { signal, dispose } and you must dispose().
getRemainingTime
isDeadlineExceeded
throwIfDeadlineExceeded
readDeadline
Work with a metadata.deadline.readDeadline returns undefined for a malformed value rather than treating it as expired.
assertValidProcedureName
assertValidRequest
measurePayloadBytes
parseInput
parseOutput
toValidationIssues
The validation the server and dispatcher already run for you.Call them directly only in a custom pipeline.
createRPCMemoryTransportClient transport to a server in the same process.(server, { auth?, serializer? }). Round-trips frames through JSON by default.
createRPCHttpTransportClient transport that POSTs frames with fetch.({ url, headers?, fetch?, serializer?, maxResponseBytes? }).
createRPCFetchHandlerServes an RPCServer as (request: Request) => Promise<Response>.(server, { auth?, serializer?, maxBodyBytes?, onInternalError? }).
createRPCJsonSerializerThe default frame serializer, size- and depth-limited.{ preserveTypes: true } carries Date, BigInt, Map, Set. Use the same one on both ends.
rpcHttpStatus
readBoundedBody
isRPCResponseFrame
Building blocks of the HTTP transport.Status for a response frame; read a body with a byte cap; check a decoded value is a response frame.
mapRPCError
rpcErrorFromWire
Error to wire payload, and wire payload back to a typed error.What the server and RPCClient use. Call them only in a custom transport.
isRPCError
createRPCError
Type guard and factory for RPC errors.Re-exported from @zudojs/errors.

Types

NameWhat it doesNotes
RPCProcedure
RPCHandler
RPCProcedureOptions
A procedure and its handler.Generic over input and output.
RPCRequest
RPCResponse
RPCErrorPayload
RPCMetadata
The messages on the wire.Check success before reading result. Each has an Options variant for its factory.
RPCContextPer-call request, metadata, signal and state.Second argument to every handler and middleware.
RPCContextOptionsSecond argument of server.handle(request, options).auth (verified identity) and signal (the caller's; aborting it cancels the call with RPC_CANCELLED).
RPCTransport
RPCMiddleware
RPCInterceptor
The pieces you plug in.Middleware is a function; a transport and an interceptor are objects. Use a built-in transport or implement RPCTransport yourself.
RPCMemoryTransportOptions
RPCHttpTransportOptions
RPCFetchHandlerOptions
RPCFrameSerializer
Options for the built-in transports.Memory: auth, serializer (false = by reference). HTTP client: url, headers, fetch, serializer, maxResponseBytes. Fetch handler: auth, serializer, maxBodyBytes, onInternalError.
RPCServerOptions
RPCDispatcherOptions
RPCClientOptions
RPCCallOptions
RPCRequestLimits
Everything you can configure.Server: limits, dispatch, onInternalError. Limits: maxPayloadBytes (1 MB), maxRequestIdLength (128), enforceProcedureNamePattern (true), allowUnsafeKeys (false). Dispatch: defaultTimeout, honourDeadline, interceptors. Client and call: timeout, maxPending, signal, metadata.
RPCSchema
RPCRetryOptions
RPCBackoff
RPCJitter
CancellableSignal
Validation and reliability settings.RPCSchema needs only safeParse. Backoff: fixed, linear, exponential. Jitter: none, full, equal.

Constants

NameValueNotes
DEFAULT_RPC_TIMEOUT30000When neither procedure nor call sets one.
MAX_RPC_PAYLOAD_SIZE10485761 MB encoded, covering payload and metadata together; see limits.maxPayloadBytes.
MAX_RPC_REQUEST_ID_LENGTH128Characters in request.id, which every response echoes; see limits.maxRequestIdLength.
MAX_PENDING_REQUESTS1024Default client concurrency cap.
MAX_MIDDLEWARE32Per stack.
MAX_PROCEDURES4096Per registry.
MAX_PROCEDURE_NAME_LENGTH256Checked before the pattern, so a huge name cannot stall the regex.
DEFAULT_RPC_HTTP_MAX_BODY_BYTES1114112Largest body createRPCFetchHandler reads: MAX_RPC_PAYLOAD_SIZE plus 64 KiB for the envelope.
MAX_RPC_FRAME_DEPTH128Nesting depth the default serializer decodes.
RPC_HTTP_STATUSCode → status mapThe table in Over HTTP. Unlisted codes are sent as 500.
MAX_TIMER_DELAY2147483647Retry delays, the client deadline and the HTTP transport's request timeout clamp to it, so a huge value cannot overflow into an instant retry or an instant timeout.
PROCEDURE_NAME_PATTERN/^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)+$/At least two dot-separated segments.
INTERNAL_ERROR_MESSAGEFixed sentenceWhat a caller sees instead of an unexpected exception.

Errors

Sixteen classes are defined in @zudojs/errors and re-exported here, so either import works: RPCError, RPCProcedureNotFoundError, RPCInvalidRequestError, RPCValidationError, RPCAuthenticationError, RPCForbiddenError, RPCTimeoutError, RPCCancelledError, RPCInternalError, RPCTransportError, RPCSerializationError, RPCDeserializationError, RPCUnavailableError, RPCRateLimitedError, RPCDeadlineExceededError and RPCDuplicateProcedureError. The type RPCErrorOptions comes with them.

Every RPCError has a readonly details, which you can also pass as an option: new RPCError(message, { code, details }). On the client it holds what the server sent. The server only ever sends two kinds of details: validation issues (RPC_VALIDATION_ERROR) and { retryAfter } (RPC_RATE_LIMITED). Details you attach to your own RPCError on the server stay there, so they can never leak internal data to a caller.

Not implemented yet: streaming. RPCStreamingHandler, RPCStreamingProcedure and createRPCStreamingProcedure exist, but a registry accepts only RPCProcedure and RPCTransport.send resolves a single response. A streaming procedure cannot be registered or called today.

COMMON MISTAKES

  • Naming a procedure "getUser". It throws RPCInvalidRequestError the moment you create it, because the pattern needs at least two dot-separated segments. Use "users.getUser".
  • Trusting the handler's input type. Writing (input: { id: string }) proves nothing at runtime, and your handler will happily run on { id: 42 }. Give the procedure an input schema.
  • Reading response.result without checking response.success. On a failure it is absent, so you get undefined instead of an error. Branch on success, or use RPCClient, which throws for you.
  • Running a server with no onInternalError. Unexpected failures answer with a fixed sentence and are recorded nowhere else, so the bug is invisible. Pass the hook and log the error with its request id.
  • Retrying everything. Replaying a forbidden or invalid call burns attempts and can duplicate side effects. Use retryIf, and check idempotent from registry.describe().
  • Comparing error.code with ErrorCode.RPC_TIMEOUT. That is the class code, "ERR_RPC_TIMEOUT". A client error carries the wire code, so compare with "RPC_TIMEOUT", or use instanceof RPCTimeoutError.
  • Ignoring options.signal in a custom transport. The client still rejects on time, but the socket stays open. Pass the signal to fetch or your socket library. The built-in transports already do this.
  • Using different serializers on the two ends. A server built with createRPCJsonSerializer({ preserveTypes: true }) sends tagged values a plain-JSON client does not decode, so a Date arrives as something else. Pass the same serializer to createRPCFetchHandler and createRPCHttpTransport.

COMPLETE EXPORT INDEX

Every name @zudojs/rpc exports from its package root at v1.4.0 — 114 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 114 exports
Classes (22)
RPCAuthenticationError RPCCancelledError RPCClient RPCDeadlineExceededError RPCDeserializationError RPCDispatcher RPCDuplicateProcedureError RPCError RPCForbiddenError RPCInternalError RPCInvalidRequestError RPCMiddlewareStack RPCProcedureNotFoundError RPCProcedureRegistry RPCProcedureRouter RPCRateLimitedError RPCSerializationError RPCServer RPCTimeoutError RPCTransportError RPCUnavailableError RPCValidationError
Functions (38)
assertValidProcedureName assertValidRequest calculateRetryDelay cancelSignal combineSignals createCancellableSignal createNoopRPCInterceptor createRPCContext createRPCError createRPCErrorResponse createRPCFetchHandler createRPCHttpTransport createRPCJsonSerializer createRPCMemoryTransport createRPCMetadata createRPCProcedure createRPCRequest createRPCResponse createRPCStreamingProcedure createTimeout getRemainingTime isDeadlineExceeded isRPCError isRPCResponseFrame mapRPCError measurePayloadBytes parseInput parseOutput readBoundedBody readDeadline retry rpcErrorFromWire rpcHttpStatus runWithTimeout throwIfCancelled throwIfDeadlineExceeded toValidationIssues withTimeout
Interfaces (29)
CancellableSignal RPCBodySource RPCCallOptions RPCClientOptions RPCContext RPCContextOptions RPCDispatcherOptions RPCErrorOptions RPCErrorPayload RPCFetchHandlerOptions RPCFrameHandler RPCHttpTransportOptions RPCInterceptor RPCJsonSerializerOptions RPCMappedError RPCMemoryTransportOptions RPCMetadata RPCMetadataOptions RPCProcedure RPCProcedureOptions RPCRequest RPCRequestLimits RPCRequestOptions RPCResponse RPCRetryOptions RPCServerOptions RPCStreamingProcedure RPCTransport RPCTransportRequestOptions
Type aliases (11)
RPCAuthContext RPCBackoff RPCBodyReadResult RPCFrameSerializer RPCHandler RPCHttpHeaders RPCJitter RPCMiddleware RPCProcedureName RPCSchema RPCStreamingHandler
Constants (14)
DEFAULT_RETRY_OPTIONS DEFAULT_RPC_HTTP_MAX_BODY_BYTES DEFAULT_RPC_TIMEOUT INTERNAL_ERROR_MESSAGE MAX_MIDDLEWARE MAX_PENDING_REQUESTS MAX_PROCEDURE_NAME_LENGTH MAX_PROCEDURES MAX_RPC_FRAME_DEPTH MAX_RPC_PAYLOAD_SIZE MAX_RPC_REQUEST_ID_LENGTH MAX_TIMER_DELAY PROCEDURE_NAME_PATTERN RPC_HTTP_STATUS