@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.
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.
- 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.
- 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
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.
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.
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.
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.
The five options, in plain terms:
- →
description— a human-readable line, readable only throughdescribe(). - →
idempotent— says calling twice is safe. Advisory: nothing retries or de-duplicates for you, but your retry policy can read it fromdescribe(). - →
timeout— milliseconds the handler may run. Enforced: the context signal is aborted and the caller getsRPC_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.
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?) | Client | Tests, and modular monoliths where caller and server share a process. |
| createRPCHttpTransport({ url, … }) | Client | Calling a server in another process over HTTP, with the global fetch. |
| createRPCFetchHandler(server, options?) | Server | Answering 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.
- →
auth— the identity the server sees ascontext.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, orfalseto 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.signalaborts and the server stops the work too. - → After
client.close()every send fails withRPCUnavailableError.
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.
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 Request → Response can host it: @zudojs/http, Bun.serve, Deno.serve, or an edge runtime. On @zudojs/http, mount it with mountFetchHandler and no glue code:
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
POSTwith a JSON content type, and reads at mostmaxBodyBytes(defaultDEFAULT_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
authhook that throwsRPCAuthenticationErrorrefuses 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.signalaborts and the dispatch ends withRPC_CANCELLED) instead of running on to its timeout for nobody. - → A frame whose payload or metadata holds a
__proto__,constructororprototypekey is refused withRPC_INVALID_REQUEST. See Refusing unsafe keys. - → The HTTP status follows the wire code, as below. The status is advisory, for proxies and dashboards;
error.codein the body is what counts.
| Wire code | HTTP status |
|---|---|
| success | 200 |
| RPC_INVALID_REQUEST, RPC_DESERIALIZATION_ERROR | 400 |
| RPC_UNAUTHENTICATED | 401 |
| RPC_FORBIDDEN | 403 |
| RPC_PROCEDURE_NOT_FOUND, RPC_NOT_FOUND | 404 |
| RPC_CONFLICT | 409 |
| RPC_VALIDATION_ERROR | 422 |
| RPC_RATE_LIMITED | 429 |
| RPC_CANCELLED | 499 |
| RPC_UNAVAILABLE | 503 |
| RPC_TIMEOUT, RPC_DEADLINE_EXCEEDED | 504 |
| RPC_INTERNAL_ERROR, RPC_SERIALIZATION_ERROR, any custom code | 500 |
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.
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.
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.
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.
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.
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.
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.
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 |
|---|---|
| RPCProcedureNotFoundError | RPC_PROCEDURE_NOT_FOUND |
| RPCValidationError | RPC_VALIDATION_ERROR |
| RPCInvalidRequestError | RPC_INVALID_REQUEST |
| RPCAuthenticationError | RPC_UNAUTHENTICATED |
| RPCForbiddenError | RPC_FORBIDDEN |
| RPCRateLimitedError | RPC_RATE_LIMITED |
| RPCDeadlineExceededError | RPC_DEADLINE_EXCEEDED |
| RPCTimeoutError | RPC_TIMEOUT |
| RPCCancelledError | RPC_CANCELLED |
| RPCUnavailableError | RPC_UNAVAILABLE |
| RPCSerializationError | RPC_SERIALIZATION_ERROR |
| RPCDeserializationError | RPC_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: true | by status (408/504 → RPC_TIMEOUT, 503 → RPC_UNAVAILABLE), otherwise its own code |
anything else, and any error with expose: false | RPC_INTERNAL_ERROR |
RPCError thrown with expose: false — RPCInternalError, 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.
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:
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.
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.
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.
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.
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
| Name | What it does | Notes |
|---|---|---|
| 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
| Name | What it does | Notes |
|---|---|---|
| createRPCProcedure | Builds a frozen procedure. | (name, handler, options?); validates the name immediately. |
| createRPCRequest | Builds 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. |
| createNoopRPCInterceptor | An 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. |
| createRPCMemoryTransport | Client transport to a server in the same process. | (server, { auth?, serializer? }). Round-trips frames through JSON by default. |
| createRPCHttpTransport | Client transport that POSTs frames with fetch. | ({ url, headers?, fetch?, serializer?, maxResponseBytes? }). |
| createRPCFetchHandler | Serves an RPCServer as (request: Request) => Promise<Response>. | (server, { auth?, serializer?, maxBodyBytes?, onInternalError? }). |
| createRPCJsonSerializer | The 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
| Name | What it does | Notes |
|---|---|---|
| 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. |
| RPCContext | Per-call request, metadata, signal and state. | Second argument to every handler and middleware. |
| RPCContextOptions | Second 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
| Name | Value | Notes |
|---|---|---|
| DEFAULT_RPC_TIMEOUT | 30000 | When neither procedure nor call sets one. |
| MAX_RPC_PAYLOAD_SIZE | 1048576 | 1 MB encoded, covering payload and metadata together; see limits.maxPayloadBytes. |
| MAX_RPC_REQUEST_ID_LENGTH | 128 | Characters in request.id, which every response echoes; see limits.maxRequestIdLength. |
| MAX_PENDING_REQUESTS | 1024 | Default client concurrency cap. |
| MAX_MIDDLEWARE | 32 | Per stack. |
| MAX_PROCEDURES | 4096 | Per registry. |
| MAX_PROCEDURE_NAME_LENGTH | 256 | Checked before the pattern, so a huge name cannot stall the regex. |
| DEFAULT_RPC_HTTP_MAX_BODY_BYTES | 1114112 | Largest body createRPCFetchHandler reads: MAX_RPC_PAYLOAD_SIZE plus 64 KiB for the envelope. |
| MAX_RPC_FRAME_DEPTH | 128 | Nesting depth the default serializer decodes. |
| RPC_HTTP_STATUS | Code → status map | The table in Over HTTP. Unlisted codes are sent as 500. |
| MAX_TIMER_DELAY | 2147483647 | Retry 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_MESSAGE | Fixed sentence | What 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.
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 throwsRPCInvalidRequestErrorthe 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 aninputschema. -
Reading
response.resultwithout checkingresponse.success. On a failure it is absent, so you getundefinedinstead of an error. Branch onsuccess, or useRPCClient, 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 checkidempotentfromregistry.describe(). -
Comparing
error.codewithErrorCode.RPC_TIMEOUT. That is the class code,"ERR_RPC_TIMEOUT". A client error carries the wire code, so compare with"RPC_TIMEOUT", or useinstanceof RPCTimeoutError. -
Ignoring
options.signalin a custom transport. The client still rejects on time, but the socket stays open. Pass the signal tofetchor 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 aDatearrives as something else. Pass the same serializer tocreateRPCFetchHandlerandcreateRPCHttpTransport.
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
RPCAuthenticationError RPCCancelledError RPCClient RPCDeadlineExceededError RPCDeserializationError RPCDispatcher RPCDuplicateProcedureError RPCError RPCForbiddenError RPCInternalError RPCInvalidRequestError RPCMiddlewareStack RPCProcedureNotFoundError RPCProcedureRegistry RPCProcedureRouter RPCRateLimitedError RPCSerializationError RPCServer RPCTimeoutError RPCTransportError RPCUnavailableError RPCValidationErrorassertValidProcedureName 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 withTimeoutCancellableSignal 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 RPCTransportRequestOptionsRPCAuthContext RPCBackoff RPCBodyReadResult RPCFrameSerializer RPCHandler RPCHttpHeaders RPCJitter RPCMiddleware RPCProcedureName RPCSchema RPCStreamingHandlerDEFAULT_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