@zudojs/api
Define your application's operations once, then run them over HTTP, RPC, a queue, the CLI, or a test with the same validation, timeouts, and interceptors.
OVERVIEW
Most apps end up with business logic buried inside HTTP route handlers. When you later want to call the same logic from a command-line tool, a background job, or a test, you have to copy it or fake an HTTP request.
@zudojs/api fixes this by giving you an operation: a named function with a declared input, a declared output, and a handler. An executor runs operations for you and always returns a result object instead of throwing. A registry stores operations by name so a transport can look them up.
The executor itself knows nothing about HTTP. It never sends a response or reads a header; whatever calls it gets back a plain { ok, data } or { ok, error } object. Bindings connect that core to real transports: one operation can be served over HTTP (createApiFetchHandler), RPC (registerApiRpcProcedures), a queue (bindApiQueue) and the command line (runApiCli), with the same errors everywhere. See Bindings.
- The same logic must run from more than one entry point (HTTP and CLI, or HTTP and a queue).
- You want input and output checked by a schema without writing that check in every handler.
- You want one place to add logging, auth, or caching around every operation.
- You have a handful of HTTP routes and nothing else will ever call them.
- You need HTTP routing itself. That lives in @zudojs/http.
- You need declarative authorization rules. Write them as an interceptor, or see @zudojs/permissions.
INSTALLATION
Install the package. Its error classes come from @zudojs/errors, and the bindings use @zudojs/rpc, @zudojs/queue, @zudojs/openapi and @zudojs/serialization; all are pulled in automatically as regular dependencies. @zudojs/http is not one of them: it mounts this package, not the other way round.
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
Requires Node.js 24 or newer. The package is ESM only, so use import, not require.
QUICK START
This example defines one operation, registers it, and runs it. The two type arguments on defineOperation tell TypeScript what the input and output look like. (When the operation has an input schema you can leave them out; see Letting the schema type the handler.)
Run it and you see { id: 'u_1', name: 'Alice' } printed. The rest of this page explains each of the three pieces and what happens when something goes wrong.
OPERATIONS
An operation is one thing your app can do, such as "get a user" or "create an order". You describe it with defineOperation(), which checks the description and returns a frozen object. A handler is the async function that does the work; it receives the input and a context object.
defineOperation checks your definition at startup so a bad name or timeout fails immediately, not on the first request. The full set of options:
| Option | What it does | Notes |
|---|---|---|
| name | Unique identifier, e.g. "users.get". | Required. 1–128 characters from A-Z a-z 0-9 . _ : / -. |
| handler | Async function (input, context) => Promise<output>. | Required. |
| input | Schema used to validate input before the handler runs. | Optional. Must be a @zudojs/schema schema (or any safeParse schema) or a Standard Schema; anything else throws in defineOperation. See Validation. |
| output | Schema used to validate what the handler returns. | Optional. Same rule as input. |
| timeout | Deadline in milliseconds. | Optional. Default 30 000. Must be a positive integer up to 3 600 000. |
| metadata | Descriptive extras: description, tags, version, deprecated, idempotent, timeout, and http. | Optional. Frozen once registered. tags powers registry.findByTag(). http: { method, path } sets the HTTP route, e.g. { method: "GET", path: "/users/:id" }; the default is POST /<name>. It is checked by defineOperation. |
This operation uses a short timeout and tags. It is the shape used by the package's own README test.
There is no way to switch the deadline off. timeout: 0, a negative number, or NaN throws a RangeError at definition time. If you need longer than an hour, the work belongs in a queue, not an operation.
Stopping work when the deadline passes
JavaScript cannot forcibly stop a running function. What it can do is ask it to stop, through an AbortSignal: an object that "fires" once, and that fetch, most database drivers and Node's timers all listen to. Every handler receives one as context.signal, even when the caller never supplied a signal. It fires in two cases:
- The operation runs past its
timeout. The signal'sreasonis theAPITimeoutError(504) the call fails with. - The caller's own signal aborts, for example because an HTTP client disconnected. The
reasonis theErrorCode.OPERATION_CANCELLEDerror the call fails with.
It does not fire when the handler finishes normally. In this example the handler waits one second, but the operation only allows 100 ms:
Before v1.2.0 the executor stopped waiting at the deadline but never fired the signal, so a handler kept running after the caller had been told it failed. If the caller then retried, the work (sending an email, charging a card) happened twice. A handler that ignores context.signal still behaves that way: its result is thrown away, but its code runs to the end. So pass the signal on to anything slow.
VALIDATION
A schema is an object that can check whether a value has the right shape. The executor accepts a @zudojs/schema schema (or any schema with a safeParse method), or any schema that follows the Standard Schema spec, which Zod, Valibot, and ArkType all implement. You never import a validation library from @zudojs/api itself.
When input is a schema, the executor validates immediately before calling the handler (after every interceptor has run) and hands the handler the schema's cleaned-up value. When output is a schema, the handler's return value is validated too, and the cleaned value becomes result.data. Any other value in those fields is rejected: defineOperation and register throw a TypeError, and the executor fails closed with a 500 without running the handler.
The example below writes a tiny schema by hand so you can see the whole thing. In a real app you would pass a Zod schema in the same spot with no other changes.
Notice the issue says "id: invalid", not "id must be a string". By default the executor reports where validation failed but never repeats what was sent, because schema messages often quote the submitted value. The list is also capped at 20 issues.
If you know every schema in your app writes value-free messages, opt in with new APIExecutor({ exposeValidationMessages: true }). Lower the cap with maxValidationIssues (1–20).
Output failures work differently. A handler returning the wrong shape is a bug on your side, not the client's, so it becomes an APIInternalError (status 500, expose: false) whose message names only the failing paths.
Letting the schema type the handler
When an operation has an input schema, you do not need type arguments. defineOperation reads the handler's input type from the schema: the value a @zudojs/schema or Zod schema produces on success, or a Standard Schema's declared output type. That works even when you write the definition inline, straight inside register() or an operation list:
Before v1.2.0 this inline form did not compile: TypeScript inferred input as never, so every property access was an error. Explicit type arguments, defineOperation<TInput, TOutput>(…), still work exactly as before and win over the schema. The return type is never inferred from the schema; it is whatever the handler returns.
EXECUTION CONTEXT
The context is a small object that travels with one request through interceptors and into the handler. It carries a request id, an optional cancellation signal, a state object you choose, and typed key/value slots.
The handler gets a slightly different object from the one you pass to execute(): a view of it whose signal is always present and also fires on timeout (see Stopping work when the deadline passes). Everything else, requestId, state, get/set and metadata, is the caller's context itself, so a value an interceptor sets is the value the handler reads.
A context key is a named, typed slot. You create one with createContextKey<T>(name), then context.set(key, value) and context.get(key). Each key has its own hidden identity, so two keys with the same name never clash.
This example stores a list of feature flags on the context and reads it back. It is copied from the package's README test.
Five keys are built in and exported, so you do not need to create them: RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey (all strings) and StartTimeContextKey (a number). Use them so different packages agree on where "the current user" lives.
Request ids from the outside world
createAPIContext throws a TypeError unless the request id is 1–128 characters from A-Z a-z 0-9 . _ : -. That keeps newlines and other junk out of your logs. Values that come from a header must go through normalizeRequestId() first.
context.set(RequestIdContextKey, value) throws. The request id is fixed when the context is created so context.requestId and context.get(RequestIdContextKey) can never disagree.
EXECUTOR AND RESULTS
APIExecutor.execute(operation, input, context) runs one operation and always resolves to an APIResult. In order, it runs the interceptors, validates the input they pass on, runs the handler under the timeout and abort signal, validates the output, and converts anything thrown into a failure result.
A result is one of two frozen shapes. Check result.ok and TypeScript narrows the type for you:
{ ok: true, data }— the handler's (validated) return value.{ ok: false, error }— anAPIErrorwithstatusCode,code,message, andexpose.
This example throws a plain Error from a handler and shows how the executor hides its message from the client while keeping it on error.cause for your logs. Note that BaseError.toJSON() (used by JSON.stringify) serialises cause with its message and stack, so never pass result.error to res.json() as-is: send code, statusCode and (when expose is true) message, or use ErrorSerializer from @zudojs/errors.
To send a specific error to the client, throw an APIError instead. Those pass through untouched. createAPIError(message, { statusCode, expose }) builds one quickly, and classes such as APIAuthenticationError (401) and APIAuthorizationError (403) take just a message.
The transport then maps the result onto its protocol. The bindings do this for you; if you call the executor from your own code, this is the pattern:
Two other failures the executor produces on its own: a handler slower than its timeout fails with APITimeoutError (504), and a context whose signal is aborted fails with code ErrorCode.OPERATION_CANCELLED (status 499). In both cases the handler's context.signal fires too, so a handler that listens to it stops. Branch on the code, not the status, for cancellation.
expose: true means "safe to show the user". expose: false means "log it, but send a generic message". The executor sets this for you; your transport only has to respect it.
INTERCEPTORS
An interceptor is code that runs around every operation: something before the handler, then await next(), then something after. You give the executor a list of them, and the first one in the list is the outermost. At most 32 are allowed.
Each interceptor receives an APIExecutionContext with four fields: operation, input (writable, and not yet validated: see below), context (the request context), and result (set after next() resolves). It can replace the input, short-circuit by returning a result without calling next(), or observe the result afterwards.
This example has a timing interceptor and an auth interceptor. The auth one returns a failure early when no user id is on the context, so the handler never runs.
Each run also prints a line like users.me 1 ms false from the timing interceptor, because it wraps the auth interceptor and sees its result.
Call next() at most once. A second call fails with an APIInternalError rather than silently running the handler twice. new APIExecutor([timing]) (a bare array) also works when you have no other options to set.
createNoopInterceptor() returns an interceptor that just calls next(). It is handy as a placeholder in tests.
Interceptors run before validation
Since v1.2.0 the interceptors run first and input validation runs last, immediately before the handler. Earlier versions validated first. That order was a security problem in three ways:
- It told strangers about your schema. An anonymous caller who sent bad input got a 422 listing which fields were wrong, instead of the 401 your authentication interceptor would have sent. Someone with no account could map out your API's inputs.
- Invalid calls were invisible. Logging, metrics and rate-limit interceptors never ran for them, so a flood of malformed requests was neither logged nor limited.
- The schema could be skipped. An interceptor that replaced
ctx.inputhanded the handler a value no schema had checked.
Now every interceptor sees every call, and whatever input the last interceptor passes on is what gets validated. This example uses a @zudojs/schema schema that requires a title of at least 3 characters:
If you wrote interceptors for an earlier version: ctx.input is now the input exactly as the caller sent it, not yet validated, coerced or transformed. Treat it as untrusted, and do not rely on a schema default or coercion having been applied. Anything you put back into ctx.input is validated before the handler sees it. The bindings answer exactly as before (HTTP status and APIWireError, RPC error classes, queue failures, CLI exit codes), and they still refuse __proto__ / constructor / prototype keys before any interceptor runs (see Unsafe input).
REGISTRY
The registry is a map from operation name to operation. A transport uses it to turn "users.get" from a URL or message into the operation to run. Every error it throws is an APIError with a status code, so the transport can forward it without special cases.
Register everything at startup, then freeze() so nothing can be added or removed while requests are flowing.
| Method | What it does | Notes |
|---|---|---|
| register(op) | Adds an operation and freezes its metadata. | Throws APIDuplicateOperationError (409) on a repeated name; APIError (500) if frozen. |
| get(name) | Returns the operation or undefined. | Use when a miss is normal. |
| require(name) | Returns the operation or throws. | Throws APIOperationNotFoundError (404). |
| has(name) | Boolean existence check. | |
| getAll() | Every registered operation. | Returns a new array each call. |
| findByTag(tag) | Operations whose metadata.tags includes tag. | Case-sensitive. |
| unregister(name) | Removes one; returns true if it existed. | Throws if frozen. |
| freeze() / isFrozen() | Locks the registry / reports the lock. | Cannot be undone. |
BINDINGS: ONE OPERATION, MANY TRANSPORTS
A binding connects your operations to one way of calling them. The package ships four, and you can use any mix of them on the same registry:
| Transport | Function | What the caller does |
|---|---|---|
| HTTP | createApiFetchHandler(registry, options) | Sends POST /users.create (or the route you chose) with a JSON body. |
| RPC | registerApiRpcProcedures(server, registry, options) | Calls client.call("users.create", input) through @zudojs/rpc. |
| Queue | bindApiQueue(queue, registry, options) | Adds a job named after the operation to a @zudojs/queue queue. |
| CLI | runApiCli(registry, argv, options) | Runs app users.create --name Ann in a terminal. |
Every binding behaves the same way. It runs the call through the APIExecutor you pass as executor, so your interceptors apply on every transport. It validates input with the operation's schema, after the interceptors. It sets TransportContextKey on the context to "http", "rpc", "queue" or "cli", so a handler can tell where a call came from. And it hands every failure the caller was not allowed to see to onInternalError(error, requestId), which is where you log it.
The state option builds context.state from whatever the transport hands over: the Request for HTTP, the RPCContext for RPC, the Job for a queue, the parsed invocation for the CLI. Throw an APIError there, such as APIAuthenticationError, to refuse the call.
The examples below share this setup. metadata.http is optional: it picks the HTTP method and path. Without it, an operation is served at POST /<operation name>.
HTTP
createApiFetchHandler returns a fetch handler: a function that takes a web-standard Request and returns a Response. Any server that speaks the Fetch API can run it, including @zudojs/http, Bun.serve and Deno.serve. This package never imports @zudojs/http; @zudojs/http mounts it.
Do not set basePath when you mount on @zudojs/http. mountFetchHandler already removes the mount path from the URL, so a basePath: "/api" as well would make the handler look for /api/api/users/u1 and answer 404. Use basePath only when the server hands the handler the full URL, as Bun.serve({ fetch: handle }) does.
Where input comes from: GET and DELETE routes read the query string, so every value arrives as a string (use a coercing schema such as coerceNumberSchema() for numbers). POST, PUT and PATCH read a JSON body of at most maxBodyBytes (1 MiB by default), and refuse any other declared content type with 415 (see Unsafe input). Path parameters are merged over either one and win. The request id comes from a safe x-request-id header and is echoed back, and the call runs under request.signal, so a client that disconnects cancels it.
What a failure looks like
Every binding reports a failure in the same client-safe shape, APIWireError: { code, message, statusCode, requestId, issues? }. Over HTTP it is the body { "ok": false, "error": { … } }, sent with the error's status. The message is the error's own only when the error is expose: true; otherwise it is the fixed "An internal error occurred.". Stack traces, causes and metadata never leave the process.
| What went wrong | HTTP status | error.code and message |
|---|---|---|
| Input failed the schema | 422 | ERR_API_VALIDATION, with issues such as ["a: invalid"]. |
| The handler threw a domain error, e.g. APIConflictError("Seat already taken.") | Its own, e.g. 409 | Its own code and message: ERR_API_CONFLICT, "Seat already taken." |
| The handler threw anything else | 500 | ERR_API_INTERNAL, "An internal error occurred." The real error goes to onInternalError. |
| No operation at that method and path | 404 | ERR_API_NOT_FOUND. A known path with the wrong method gets 405 and an Allow header. |
| The handler ran past its timeout | 504 | ERR_API_TIMEOUT, "An internal error occurred." |
| The body or query holds a __proto__, constructor or prototype key | 400 | ERR_API_VALIDATION, e.g. 'Request body key "__proto__" is not allowed.' |
| A body route was called with a non-JSON content type | 415 | ERR_HTTP_UNSUPPORTED_BODY_TYPE, "Request body must be application/json.", even when the body is empty. |
A body that is too large, not JSON, or unreadable gets 413, 415 or 400. The same failures reach the other transports as their own kind of error: an RPC caller gets a typed RPC error, a queue job fails, and the CLI exits with a non-zero code.
RPC
registerApiRpcProcedures turns each operation into an @zudojs/rpc procedure on a server (or procedure registry). Build state from rpc.auth, the identity the transport verified, never from frame metadata, which the caller writes.
Procedure names default to the operation name, which must then be a valid RPC name such as "users.get"; pass procedureName to map other names. API errors become their RPC equivalents (RPC_VALIDATION_ERROR with issues, RPC_UNAUTHENTICATED, RPC_FORBIDDEN, RPC_TIMEOUT, …), and a domain error keeps its own code, such as ERR_API_CONFLICT. The message follows the same rule as over HTTP: an error built with expose: false keeps its code but travels with the generic "An internal error occurred.", even when that code has an RPC equivalent. So createAPIError("Stripe rejected key sk_live_…", { code: ErrorCode.API_UNAVAILABLE }) reaches an RPC caller as RPC_UNAVAILABLE with "An internal error occurred." in place of the text; before v1.2.0 that message was sent. To serve the same server over HTTP, mount createRPCFetchHandler(server) with mountFetchHandler(router, "/rpc", …).
Queues
bindApiQueue makes a @zudojs/queue queue run operations as background jobs. The job name is the operation name and job.data is the input.
Success completes the job with the operation's output. A failure throws an APIError carrying the client-safe message, so the queue retries and dead-letters the job as usual. Validation failures are retried too, which cannot help, so enqueue input you have not checked with attempts: 1.
CLI
runApiCli turns a command line into an operation call, prints the output as JSON, and returns an exit code. Put it in a small executable file:
The operation name comes first (or pass operation for a single-purpose program). --field value and --field=value set fields; kebab-case becomes camelCase, dots nest, repeated flags collect into an array, --flag is true and --no-flag is false. Values that look like JSON (numbers, true, null, {…}, […]) are parsed; anything else stays a string. On failure the { ok: false, error } body goes to stderr and the exit code tells a script what kind of failure it was:
| Exit code | APICliExitCode | When |
|---|---|---|
| 0 | OK | The operation succeeded. |
| 65 | INVALID_INPUT | Input failed validation (a 400 or 422). |
| 64 | USAGE | Bad command line: unknown operation or malformed option. |
| 75 | TIMEOUT | The operation timed out. |
| 1 | FAILURE | Any other client error, such as a 409 conflict. |
| 77 | PERMISSION | Not signed in or not allowed (401, 403). |
| 69 | UNAVAILABLE | Rate limited or unavailable (429, 503); try again later. |
| 70 | INTERNAL | Internal error. |
| 130 | CANCELLED | Cancelled through the abort signal (Ctrl+C). |
Unsafe input
Two kinds of input are refused by every binding before your operation runs, so you do not have to remember to check for them.
Prototype-polluting keys. JSON.parse keeps a key named __proto__, constructor or prototype as an ordinary property. If a handler later copies that input onto another object with Object.assign or a hand-written merge, the key can replace the object's prototype, and fields such as isAdmin appear where they were never set. So a request body, query string, queue job or CLI --json input that holds one of those keys at any depth is refused: 400 over HTTP, a validation error over RPC and queues (the job fails), and exit code 65 from the CLI. The check is findUnsafeKey from @zudojs/security. Over RPC the @zudojs/rpc server usually refuses the frame first, with RPC_INVALID_REQUEST.
Non-JSON bodies. A body route (POST, PUT, PATCH) answers 415 when the request declares any content type other than JSON, even if the body is empty. This is a defence against cross-site request forgery (CSRF): a hostile web page can make a visitor's browser submit an HTML form to your API, with the visitor's cookies attached, and a form can only send text/plain, multipart/form-data or application/x-www-form-urlencoded. Refusing those types means a form cannot trigger even an operation that takes no input. A request with no content type and no body is still accepted, and a real client sending JSON is unaffected.
Send JSON with content-type: application/json and neither check will ever get in your way. Cookie-authenticated browser APIs should still add real CSRF protection, such as the tokens in @zudojs/security.
ROUTES AND OPENAPI
The HTTP binding works out a route for every operation. You can read that route table yourself, for logging, for tests, or to document the API.
describeApiRoutes(operations, { basePath }) returns one frozen APIOperationRoute per operation: operationId, method, path, pathParams, inputSource ("query" or "body"), the input and output schemas, and the description, tags, deprecation and version from metadata. It throws on an invalid route or two operations that claim the same one, which is the check createApiFetchHandler runs at startup too.
toOpenAPIRouteDescriptors(operations, { basePath }) turns the same routes into @zudojs/openapi route descriptors, so one call documents exactly what the fetch handler serves:
Here basePath is right even when you mount with mountFetchHandler: the document should show the full public path. The input schema becomes query (for GET and DELETE) or body, with path fields moved to params. The responses document 200 with the { ok: true, data } envelope around the output schema, and 422, 404, 409, 500 and 504 with the { ok: false, error } body. Only @zudojs/schema schemas are converted; with another schema library the input stays undocumented and data is unknown.
API REFERENCE
Everything below is importable from "@zudojs/api".
Functions
| Name | What it does | Notes |
|---|---|---|
| defineOperation(options) | Validates and freezes an operation definition. | Throws TypeError / RangeError on a bad name, handler, or timeout. Without type arguments, the handler's input type comes from the input schema. |
| resolveOperationTimeout(op) | Returns the effective timeout in ms. | Order: timeout, then metadata.timeout, then the default. |
| createAPIContext(requestId, state, signal?) | Builds a frozen request context. | Throws TypeError on an invalid request id. |
| createContextKey<T>(name) | Creates a typed context key. | Name must be a non-empty string. |
| isValidRequestId(value) | Type guard for a safe request id. | 1–128 chars, [A-Za-z0-9._:-]. |
| normalizeRequestId(value) | Returns the value if valid, else a new UUID. | Use on header values. |
| apiSuccess(data) / apiFailure(error) | Build frozen result objects. | Useful inside interceptors. |
| isApiSuccess(r) / isApiFailure(r) | Type guards on a result. | Same as checking r.ok. |
| createNoopInterceptor() | Interceptor that only calls next(). | |
| normalizeAPIError(error, operationName?) | Turns any thrown value into an APIError. | Non-API errors become APIInternalError with the original on cause. toJSON() includes cause (message and stack), so serialise selected fields for clients. |
| createAPIError(message, options?) / isAPIError(value) | Build or detect a generic APIError. | Re-exported from @zudojs/errors. |
| createApiFetchHandler(operations, options?) | Serves operations as (request: Request) => Promise<Response>. | Options: executor, state, onInternalError, basePath, maxBodyBytes, serializer. Throws at creation on invalid or conflicting routes. |
| registerApiRpcProcedures(target, operations, options?) / createApiRpcProcedure(op, options?) | Expose operations as @zudojs/rpc procedures. | Returns the registered names. Extra option: procedureName. apiErrorToRPCError(error, procedure) is the error mapping. |
| bindApiQueue(queue, operations, options?) / createApiQueueProcessor(op, options?) | Run operations as @zudojs/queue jobs. | Job name = operation name; job.data = input. |
| runApiCli(operations, argv, options?) / parseApiCliArgs(argv, expectOperation) | Run an operation from a command line; resolves to an exit code. | Options add io, signal, operation, programName. |
| describeApiRoutes(operations, { basePath? }) / resolveApiRoute(op, basePath?) | The HTTP route of every operation / of one. | Throws on invalid or conflicting routes. |
| toOpenAPIRouteDescriptors(operations, { basePath? }) / toOpenAPIRouteDescriptor(route) | Routes as @zudojs/openapi descriptors. | Feed to createOpenAPIDocumentFromRoutes. apiSuccessBodySchema / apiWireErrorBodySchema build the envelope schemas. |
| toApiWireResult(result, requestId) / toApiWireError(error, requestId) | Client-safe form of a result or error. | What every binding sends. Use them when you call the executor yourself. |
Classes
| Name | What it does | Notes |
|---|---|---|
| APIExecutor | Runs operations; execute(operation, input, context). | Constructor takes an interceptor array or { interceptors, exposeValidationMessages, maxValidationIssues }. |
| APIOperationRegistry | Stores operations by name. | See Registry. |
Types
| Name | What it does | Notes |
|---|---|---|
| APIOperation<TInput, TOutput> | The frozen object defineOperation returns. | AnyAPIOperation is what the registry accepts. |
| DefineOperationOptions / APIOperationMetadata | Input to defineOperation and its metadata field. | |
| InferAPISchemaOutput<S> / APIInputSchema / DefineOperationWithSchemaOptions | The type a schema produces on success / any schema the executor accepts / the defineOperation options whose input type comes from the schema. | See Letting the schema type the handler. |
| APIHandler<TInput, TOutput> | (input, context) => Promise<TOutput> | |
| APIContext<TState> / APIContextKey<T> | Request context and its typed keys. | Keys carry an id: symbol; always use createContextKey. |
| APIInterceptor / APIExecutionContext | Interceptor contract and the object it receives. | input is writable and not yet validated; result is set after next(). |
| APIExecutorOptions | Options object for new APIExecutor(). | |
| APIResult<T> / APISuccess<T> / APIFailure | Result union and its two halves. | |
| APIWireResult<T> / APIWireError | The client-safe result every binding sends. | { code, message, statusCode, requestId, issues? }. |
| APIOperationRoute / APIOperationHttpOptions | One operation's HTTP route / the metadata.http field. | Also APIHttpMethod, APIRouteInputSource, DescribeApiRoutesOptions. |
| APIBindingOptions and each binding's options | APIFetchHandlerOptions, APIRpcBindingOptions, APIQueueBindingOptions, APICliOptions. | All share executor, state, onInternalError. |
| APIErrorOptions | Options for createAPIError. | statusCode, code, expose, cause, endpoint, method. |
Errors
All are re-exported from @zudojs/errors and extend APIError. Status codes are what the executor or registry attaches.
| Name | What it does | Notes |
|---|---|---|
| APIValidationError | Input failed its schema. | 422, exposed; .issues lists paths. |
| APIAuthenticationError / APIAuthorizationError | Not signed in / not allowed. | 401 / 403, exposed. new X("message"). |
| APINotFoundError(endpoint, method?) | An endpoint does not exist. | 404. For a missing record use createAPIError with statusCode: 404. |
| APIConflictError / APIIdempotencyError | State conflict / idempotency key reuse. | 409. |
| APIRateLimitError(message?, retryAfter?) | Too many requests. | 429. |
| APITimeoutError(timeoutMs) | Handler exceeded its deadline. | 504. Produced by the executor. |
| APIUnavailableError / APIInternalError | Dependency down / unexpected bug. | 503 / 500. Internal errors are never exposed. |
| APIVersionError | Unsupported API version. | 400. |
| APIOperationNotFoundError / APIDuplicateOperationError | Registry miss / duplicate name. | 404 / 409. Thrown by the registry. |
| ErrorCode | Enum of machine-readable codes. | e.g. ErrorCode.OPERATION_CANCELLED for an aborted run. |
Constants
| Name | What it does | Notes |
|---|---|---|
| DEFAULT_OPERATION_TIMEOUT | 30 000 ms. | Used when no timeout is given. |
| MAX_OPERATION_TIMEOUT | 3 600 000 ms (1 hour). | Upper bound for timeout. |
| MAX_INTERCEPTORS | 32. | Enforced by the executor constructor. |
| MAX_VALIDATION_ISSUES / MAX_VALIDATION_ISSUE_LENGTH | 20 issues / 200 characters each. | Caps on client-facing validation errors. |
| MAX_OPERATION_NAME_LENGTH / MAX_REQUEST_ID_LENGTH | 128 characters each. | |
| RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey | Built-in context keys. | All strings except StartTimeContextKey (number). |
| TransportContextKey | Which binding ran the call. | "http" | "rpc" | "queue" | "cli"; unset when you call the executor yourself. |
| APICliExitCode | Exit codes from runApiCli. | See the table in CLI. |
| API_INTERNAL_ERROR_MESSAGE | "An internal error occurred." | Sent in place of any non-exposed message. |
| DEFAULT_API_MAX_BODY_BYTES | 1 048 576 (1 MiB). | Default maxBodyBytes for the fetch handler. |
| API_RPC_TIMEOUT_MARGIN_MS | 1 000 ms. | Extra time the RPC procedure allows, so the operation's own timeout is what a slow call reports. |
COMMON MISTAKES
- Passing a header straight to
createAPIContext. A missing or odd header throws aTypeErrorbefore your handler runs. Wrap it:createAPIContext(normalizeRequestId(header), state). - Expecting
execute()to throw. It never throws for a failed operation; the error is inresult.error. Wrapping it intry/catchcatches nothing. Checkresult.ok. - Sending
result.error.messageto every client. Internal errors carry a generic message, but only if you honourexpose. Send the message whenexposeis true, otherwise a fixed string. - Using
timeout: 0to "disable" the deadline.defineOperationthrows aRangeError. Pick a real number up to one hour. - Building a context key as a plain object.
{ name: "x", type: undefined }does not satisfyAPIContextKey; it lacks theidsymbol. UsecreateContextKey<T>("x"). - Setting
basePathand also mounting under a prefix.mountFetchHandler(router, "/api", …)already strips/api, so abasePath: "/api"oncreateApiFetchHandlermakes every route 404. Set one or the other. - Enqueueing unchecked input with retries. A job whose input fails validation is retried like any failure and can never succeed. Add it with
attempts: 1, or validate before enqueueing. - Posting a form or
text/plainto a body route. The HTTP binding answers 415 by design (a CSRF defence). Send the body as JSON withcontent-type: application/json. - Treating
ctx.inputin an interceptor as validated. Interceptors run before validation, soctx.inputis exactly what the caller sent. Check the context (who is calling), not the input; leave the input to the schema. - Ignoring
context.signalin a slow handler. On timeout the caller gets a 504, but a handler that never looks at the signal keeps running and finishes its side effects anyway. Pass the signal tofetch, your database client or your loop checks. - Registering the same name twice. Usually a module imported from two paths. The registry throws
APIDuplicateOperationError; register once at startup, thenfreeze().
NEXT STEPS
COMPLETE EXPORT INDEX
Every name @zudojs/api exports from its package root at v1.2.0 — 105 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 105 exports
APIAuthenticationError APIAuthorizationError APIConflictError APIDuplicateOperationError APIError APIExecutor APIIdempotencyError APIInternalError APINotFoundError APIOperationNotFoundError APIOperationRegistry APIRateLimitError APITimeoutError APIUnavailableError APIValidationError APIVersionErrorapiErrorToRPCError apiFailure apiSuccess apiSuccessBodySchema bindApiQueue createAPIContext createAPIError createApiFetchHandler createApiQueueProcessor createApiRpcProcedure createContextKey createNoopInterceptor defineOperation describeApiRoutes isAPIError isApiFailure isAPISchema isApiSuccess isValidRequestId normalizeAPIError normalizeRequestId parseApiCliArgs registerApiRpcProcedures resolveApiRoute resolveOperationTimeout runApiCli toApiWireError toApiWireResult toOpenAPIRouteDescriptor toOpenAPIRouteDescriptorsAPIBindingOptions APICliInvocation APICliIO APICliOptions APIContext APIContextKey APIErrorOptions APIExecutionContext APIExecutorOptions APIFailure APIFetchHandlerOptions APIInterceptor APIOperation APIOperationHttpOptions APIOperationMetadata APIOperationRoute APIQueueTarget APIRpcBindingOptions APIRpcProcedureTarget APISchemaIssue APISuccess APIWireError DefineOperationOptions DefineOperationWithSchemaOptions DescribeApiRoutesOptionsAnyAPIOperation APICliExitCodeValue APICliParseResult APIHandler APIHttpMethod APIInputSchema APIOperationSource APIQueueBindingOptions APIResult APIRouteInputSource APISchemaResult APITransportKind APIWireResult InferAPISchemaOutput ToOpenAPIRouteDescriptorsOptionsAPI_INTERNAL_ERROR_MESSAGE API_RPC_TIMEOUT_MARGIN_MS APICliExitCode apiWireErrorBodySchema CorrelationIdContextKey DEFAULT_API_MAX_BODY_BYTES DEFAULT_OPERATION_TIMEOUT ErrorCode MAX_INTERCEPTORS MAX_OPERATION_NAME_LENGTH MAX_OPERATION_TIMEOUT MAX_REQUEST_ID_LENGTH MAX_VALIDATION_ISSUE_LENGTH MAX_VALIDATION_ISSUES RequestIdContextKey StartTimeContextKey TenantIdContextKey TransportContextKey UserIdContextKey