Elarion

TypeScript client

Generate typed method contracts, Zod result schemas, and a portable fetch client from an exported rpc-schema.json.

elarion-jsonrpc-client-generator turns an exported rpc-schema.json into a typed TypeScript client. The frontend gets full type safety and runtime validation without hand-writing DTOs, and the generated runtime stays portable across browsers and Node.js.

Generating the client

npm install --save-dev @swimmesberger/elarion-jsonrpc-client-generator
npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated

It always emits the three core files, plus conditional outputs when the schema calls for them:

FilePurpose
rpc-types.tsRpcMethods interface mapping method names to params/result types.
rpc-schemas.tsrpcParamsSchemas and rpcResultSchemas Zod maps for runtime params/result validation.
rpc-client.tsTyped fetch client for single calls and batches.
session-client.tsClient-capability snapshot client and OpenFeature provider — emitted only when the schema exposes the elarion.session operation (rename it with --session-client <file>).
events-client.tsTopic-typed client-event subscription client — emitted only when the schema declares client-event topics.

An opt-in start-adapter.ts joins them under --framework tanstack-start. A schema that declares neither session nor events produces byte-identical output to before — the conditional files simply do not exist.

The schema and client files import zod (v3 and v4 are both supported), so install it as a runtime dependency in the consuming app.

Pin the generator to the backend's version. The npm latest dist-tag tracks stable releases and can lag behind the backend packages, which publish a -preview.N build from every push to main. When your host runs a preview version, install the matching exact preview version of the generator rather than a ^ range — otherwise the generator reads a newer rpc-schema.json with an older code path and silently emits less than the schema describes (a missing emitted file such as session-client.ts, or absent params schemas, are the usual symptoms):

# Substitute the exact version your host's Elarion packages resolve to —
# the full preview string, e.g. <major>.<minor>.<patch>-preview.<run>.<attempt>
npm install --save-dev --save-exact \
  @swimmesberger/elarion-jsonrpc-client-generator@<backend-version>

Move both sides together: bump the backend packages and the generator in the same change.

The emitted Zod schemas are constraint-aware: the DataAnnotations attributes on the server's request DTOs flow through the exported schema into the client (see Validation) — minLength/maxLength become .min()/.max(), pattern becomes .regex(new RegExp(...)), minimum/maximum become .gte()/.lte() (exclusive bounds .gt()/.lt()), minItems/maxItems become array .min()/.max(), integers get .int(), and the uuid/email/uri formats become .uuid()/.email()/.url().

An attribute's explicit message (x-elarion-error-messages, see Error messages) becomes the check's message argument, so the local failure reads like the server's:

code: z.string().max(20, "Code must be at most 20 characters.").regex(new RegExp("^[A-Z]{3}$"), "Code must be three capital letters."),
mode: z.enum(["fast", "slow"], { message: "Pick fast or slow." }),

Checks take the plain-string message and schema constructors (z.enum, z.literal, z.union) take { message } — the forms Zod v3 and v4 share. One difference remains on Zod v3: it applies a constructor message to z.enum but not to the z.literal/z.union emitted for a non-string [AllowedValues], which keeps Zod's default text there. Constraints without an explicit message keep Zod's default text on both versions.

Watch mode

For a tight dev loop, pass --watch to regenerate whenever rpc-schema.json changes:

npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated --watch

It generates once, then watches the schema file and regenerates on every write — surviving the transient states a build tool leaves the file in mid-write (a partial or momentarily-invalid schema is logged and skipped, never fatal). Paired with a server that re-exports the schema on save, an edit to a C# handler flows straight to the typed frontend:

# terminal 1 — API: re-export rpc-schema.json on every change (see Schema generation)
dotnet watch

# terminal 2 — regenerate the TS client whenever the schema lands
npx elarion-jsonrpc-client-generator --schema ../api/rpc-schema.json --out src/generated --watch

The two stay decoupled: the .NET build owns schema export, the generator owns the client — no Node/npm dependency is baked into the server build. For a permanent setup, wire the watch command into a package.json script (e.g. "rpc:watch").

Calling methods

Dotted JSON-RPC method names become nested properties, so clients.get is rpc.clients.get(...):

import { createRpcApi } from './generated/rpc-client'

const rpc = createRpcApi({
  url: '/rpc',
  headers: { Authorization: `Bearer ${token}` },
})

const abort = new AbortController()
const client = await rpc.clients.get({ id }, { signal: abort.signal })

The client uses globalThis.fetch in browsers and modern Node.js. The lower-level createRpcClient(...) generic transport is also exported for advanced cases.

File payloads map to native File

A handler parameter or result typed ElarionFile is exported with an x-elarion-file marker, and the generated client surfaces it as a native File on both sides — the base64 wire envelope never leaks into application code:

// Upload: pass the File from an <input type="file"> straight through.
const receipt = await rpc.documents.import({ container: 'invoices', file: input.files[0] })

// Download: the result is a real File (name, type, bytes) — hand it to URL.createObjectURL.
const exported = await rpc.exports.get({ list: 'clients' })
window.open(URL.createObjectURL(exported))

Params validate the File instance (Zod z.instanceof(File)); the client encodes it to the base64 envelope after validation and decodes result envelopes back into File objects before validation — so the conversion also runs when validation is disabled. Everything used is standard in browsers and Node 20+; schemas without file payloads generate byte-identical output. Remember ElarionFile is the small-file tier — for large transfers use the staged-blob flows.

Batching

Batch requests are built through generated $request helpers and preserve input order even when the server responds out of order. Each item resolves independently, so one failure does not reject the whole batch:

const [clientResult, projectsResult] = await rpc.$batch([
  rpc.$request.clients.get({ id }),
  rpc.$request.projects.list({ clientId: id }),
] as const)

Client-event subscriptions

When the schema declares an events block (the host registers client-event topics), the generator also emits events-client.ts: a topic-typed subscription client that multiplexes every subscription over one EventSource, validates payloads with the same generated Zod schemas, and exposes $client.onConnected as the "re-query now" signal. A connection the EventSource gave up on (a 401, a login page, a 502 mid-deploy) is reopened with a backoff (reconnectDelay). A schema without events produces byte-identical output — the file simply doesn't exist.

import { createElarionEvents } from './generated/events-client.js'

const events = createElarionEvents({ url: '/events' })
events.invoicing.invoiceChanged.subscribe((evt) => refetch(evt.invoiceId))

Server-side and SSR usage

Pass an injected fetch and dynamic headers for SSR, edge, or server-function deployments — for example to forward request-scoped authentication without forking the generated client:

const rpc = createRpcApi({
  url: process.env.API_INTERNAL_URL + '/rpc',
  fetch,
  headers: () => ({ Authorization: `Bearer ${forwardedJwt}` }),
  transformResult: normalizeRpcResultForSchema,
})

TanStack Start

During SSR you usually need to forward the incoming request's cookie (or another identity header) so the RPC call runs as the signed-in user. Generate with --framework tanstack-start to emit an opt-in start-adapter.ts next to the neutral core client (it needs the @tanstack/react-start peer dependency):

npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated --framework tanstack-start

createStartRpcApi is a turnkey createRpcApi with request-scoped cookie forwarding pre-wired; any headers you pass are layered on top:

import { createStartRpcApi } from './generated/start-adapter'

export const rpc = createStartRpcApi({ url: '/rpc' })

Forwarding other request headers

An app behind an authenticating reverse proxy (Cloudflare Access, Google IAP, oauth2-proxy, …) authenticates from a header the proxy sets on the incoming request rather than from a cookie, and an SSR call to the API has to carry that header too. Name the headers to forward with forwardHeaders:

export const rpc = createStartRpcApi({
  url: '/rpc',
  forwardHeaders: ['cookie', 'cf-access-jwt-assertion'], // e.g. Cloudflare Access
})

The list replaces the default ['cookie'] — keep 'cookie' in it when the session cookie should still travel, and pass [] to forward nothing. Names match case-insensitively and are sent lower-case; a header the incoming request does not carry is skipped. In the browser nothing is forwarded (the browser attaches its own cookies), and explicitly passed headers win over a forwarded header of the same name. Forward identity and correlation headers only, never hop-by-hop or framing headers such as host, connection, or content-length.

For finer control the adapter also exports the isomorphic headers functions on their own — forwardRequestHeaders(names) and forwardRequestCookie — so you can compose them into a plain createRpcApi call, or into any other fetch-based client that runs during SSR:

import { createRpcApi } from './generated/rpc-client'
import { forwardRequestHeaders } from './generated/start-adapter'

const rpc = createRpcApi({ url: '/rpc', headers: () => forwardRequestHeaders(['cookie', 'authorization']) })

The header read must stay inside createIsomorphicFn().server(...). Importing @tanstack/react-start/server from any module the client bundle can reach — even a dynamic import behind a typeof window guard — fails vite build with [import-protection] Import denied. The emitted adapter is structured so the Start compiler strips the import (and the read) from the client build; keep that shape if you hand-roll it instead. Because the frontend rarely rebuilds in CI, this is easy to get wrong unnoticed.

Error handling

A JSON-RPC error returned by the server is thrown as a typed RpcError. Every error carries a stable string code (the contract to branch on, for example not_found or token.malformed), a typed data payload, the human-readable message, and rpcCode — the numeric JSON-RPC code, which only encodes the coarse AppError kind and is why it is not called code. The wire shape is always error.data = { code, data? } (ADR-0080).

Handlers declare the errors they produce with [ProducesError] (on the handler, or once on a decorator, module or assembly for errors that many operations share), and the generator emits them per method: each method's errors entry in RpcMethods maps a code to its kind and payload type, and the client exports RpcMethodError<M>, a union of RpcError variants discriminated by code, with data typed per variant:

import { isRpcMethodError, type RpcMethodError } from './generated/rpc-client'

function describe(error: RpcMethodError<'tokens.redeem'>) {
  switch (error.code) {
    case 'token.malformed':
      return `Malformed: ${error.data.reason}` // data is typed from the declared payload
    case 'not_found':
      return 'Unknown token'
  }
}

try {
  await rpc.tokens.redeem({ token })
} catch (error) {
  if (isRpcMethodError('tokens.redeem', error)) return describe(error) // narrows by the declared codes
  throw error
}

A declared payload is validated against its generated Zod schema (rpcErrorDataSchemas) unless validateResults: false; a mismatch throws RpcProtocolError instead of surfacing a mistyped error. Batch items report error: RpcError; narrow them with isRpcMethodError. Without a { code, data } envelope (a foreign server) code falls back to the default code of the numeric kind.

Beyond the standard JSON-RPC codes, Elarion maps its transport-agnostic AppError kinds onto the server-reserved range, and the generated RpcError exposes a getter per kind (reading rpcCode) — a quick way to branch on the kind alone:

import { RpcError, ElarionErrorCodes } from './generated/rpc-client'

try {
  await rpc.clients.get({ id })
} catch (error) {
  if (error instanceof RpcError) {
    if (error.isNotFound) return renderNotFound()
    if (error.isForbidden || error.isUnauthorized) return redirectToLogin()
    if (error.isConflict) return showConflictToast(error.message)
  }
  throw error
}
GetterCodeAppError kind
isInvalidParams-32602Validation
isNotFound-32001NotFound
isConflict-32002Conflict
isForbidden-32003Forbidden
isBusinessRule-32004BusinessRule
isUnauthorized-32005Unauthorized
isInternalError-32603Internal

The application codes are also exported as ElarionErrorCodes for switch statements, and the standard JSON-RPC getters (isParseError, isInvalidRequest, isMethodNotFound) stay available. These codes are the framework default from AppErrorMapper; a host that registers a custom IAppErrorTranslator<RpcError> changes the wire codes, so keep the frontend aligned. A network or HTTP-level failure throws RpcTransportError and a malformed JSON-RPC envelope throws RpcProtocolError.

An unexpected server error (an exception, code: "internal") carries a support reference in error.reference — the server's configured unexpected-error answer. Show it next to the message instead of matching the message text; it is undefined for every other error:

if (error instanceof RpcError && error.code === 'internal')
  return showError(`${error.message} (reference ${error.reference ?? 'unavailable'})`)

Method requirements and UI gating

rpcMethodInfo holds, for every method, what the schema says before the method is called — exported from the handler's own declarations, so the UI never repeats them:

FieldFrom
requires.anonymous[AllowAnonymous]
requires.authenticatedany requirement, or an [ElarionAuthorizationDefaults] in scope
requires.permissions / requires.roles[RequirePermission] / [RequireRole] (all required)
requires.serverChecks[RequirePolicy], [RequireClaim] or [RequireResource] — only the server can tell
readOnlythe request is an IQuery
idempotent[Idempotent]
tags[ClientTag(key, value)] hints, e.g. [ClientTag("timeout", "180s")]

Pair it with canCall from @swimmesberger/elarion-contributions to grey out an action the current user cannot call, without naming the permission at the call site:

import { canCall } from '@swimmesberger/elarion-contributions'
import { rpcMethodInfo } from './generated/rpc-client'

const decision = canCall(rpcMethodInfo['invoices.approve'], capabilities)   // the session capabilities
<Button disabled={!decision.allowed}>Approve</Button>

canCall returns { allowed: false, reason, missing? } for the first unmet requirement (unauthenticated, permission, role), or { allowed: true, serverChecks }. Like a contribution's when, it is a UX projection: the server authorizes every call. The same map answers other per-method questions — rpcMethodInfo[m].tags.timeout for a slow operation's client timeout, readOnly for which actions a read-only session may still use.

Params validation

The client pre-validates request params by default through rpcParamsSchemas, before anything touches the wire. A tier-1 violation — a too-long name, a malformed email — throws a local RpcParamsValidationError (carrying the method name and the underlying Zod error) instead of costing a round trip; batch items are validated the same way. Because the schemas are generated from the same attributes the server enforces, a request that passes the pre-flight can only fail server-side on business rules (which come back as field-keyed validation errors using the same wire-named paths). Set validateParams: false to opt out when another layer owns input validation.

RpcParamsValidationError.fieldErrors groups the Zod issue messages by field path in the server's fieldErrors notation — member names joined with ., array indexes as [i] ("lines[1].sku"), "" for the params object itself — so one form helper marks fields for a local failure and for a server validation error alike. cause still carries the Zod error.

function fieldErrorsOf(error: unknown): Record<string, string[]> | undefined {
  if (error instanceof RpcParamsValidationError) return error.fieldErrors
  if (error instanceof RpcError && error.isInvalidParams) {
    return (error.data as { fieldErrors?: Record<string, string[]> } | undefined)?.fieldErrors
  }
  return undefined
}

Result validation

Result validation runs by default through rpcResultSchemas. Use transformResult for app-specific normalization before validation, or set validateResults: false when another layer validates responses.

Tracing with OpenTelemetry

The client stays OpenTelemetry-package-free — it never imports a tracing SDK. To make a call appear in a distributed trace, pass an instrumentation adapter that starts a span per request/batch and injects the W3C traceparent header. An ASP.NET Core host running OpenTelemetry with AddAspNetCoreInstrumentation() extracts that header, so the server request span, the JSON-RPC dispatch, and the handler-pipeline spans all nest under your client span — one end-to-end trace.

Implement RpcInstrumentation over @opentelemetry/api:

import { context, propagation, trace, SpanKind, SpanStatusCode } from '@opentelemetry/api'
import { createRpcApi, type RpcInstrumentation } from './generated/rpc-client'

const instrumentation: RpcInstrumentation = {
  startSpan(ctx) {
    const span = trace
      .getTracer('rpc-client')
      .startSpan(ctx.batch ? 'rpc batch' : `rpc ${ctx.methods[0]}`, { kind: SpanKind.CLIENT })

    // Inject traceparent for this span into the headers the client is about to send.
    const headers: Record<string, string> = {}
    propagation.inject(trace.setSpan(context.active(), span), headers)

    return {
      headers,
      setError: (error) => {
        span.recordException(error as Error)
        span.setStatus({ code: SpanStatusCode.ERROR })
      },
      end: () => span.end(),
    }
  },
}

const rpc = createRpcApi({ url: '/rpc', instrumentation })

RpcRequestContext gives you methods (one per call, several for a $batch) and a batch flag for span naming; the span's headers are applied last, so trace propagation stays authoritative over your static or dynamic headers. The adapter above is the only Elarion-specific piece — wire the rest of the browser SDK (WebTracerProvider, an OTLP exporter, ZoneContextManager) as shown for the HTTP client, and see Telemetry & observability for the server side.

Handlers exposed only over REST

The generator reads rpc-schema.json, and that schema lists the operations the JSON-RPC transport serves — handlers carrying [Handler] with HandlerTransports.JsonRpc. A handler that carries only [HttpEndpoint] (a public link answered without a session, a route that must live outside /rpc, an endpoint for third-party callers) is not on the JSON-RPC bus, so the generated client has no function, no types, and no Zod schemas for it.

That is deliberate. REST's machine-readable contract is the OpenAPI document, and Elarion leaves REST client generation to the off-the-shelf OpenAPI tooling instead of owning a second, bespoke HTTP client generator (ADR-0026). Folding routes, verbs, and route/query/header/body binding into rpc-schema.json would make this generator re-implement what OpenAPI already describes and what those tools already consume. Pick one of two paths instead — never hand-copy the response DTO into a Zod schema, because a C# rename then still compiles and silently breaks the page:

  • The operation does not need to be a REST route. Add [Handler] next to (or instead of) [HttpEndpoint]. It joins rpc-schema.json and gets the generated function, params/result Zod schemas, and the typed error union for its declared codes. Authorization and validation stay on the handler, so an anonymous handler stays anonymous on either transport.
  • It must stay REST-only. Add Elarion.AspNetCore.OpenApi, export the document, and generate a client from it — the recommended setup uses @hey-api/openapi-ts with its Zod plugin for typed params and runtime-validated responses. Errors arrive as RFC 7807 ProblemDetails with the same stable code and data members as an RpcError; the OpenAPI document declares both, so branch on error.code without a cast.

Both clients run side by side: the JSON-RPC client for the operation-shaped API, the OpenAPI-generated one for the handful of REST-only routes. Regenerate each from its own contract file in the same build step.

Design boundaries

The generated runtime stays framework-neutral: it uses standard fetch, accepts an injected transport and AbortSignal, validates with Zod, and supports batching — but never imports React, TanStack, Vite, or any downstream framework. Applications own the adapter layer: server functions, auth forwarding, UI-framework hooks, and result normalization wrap createRpcApi(...) rather than replacing the transport.

The one exception is an opt-in framework adapter (--framework tanstack-start), emitted as a separate start-adapter.ts that the core client never imports. A consumer that does not pass the flag gets byte-identical, framework-free output — the neutrality boundary is about what the core client pulls in, not about whether a separate opt-in file may exist.

The generator interprets the framework's schema format and does not support arbitrary JSON Schema composition. Schemas using oneOf, anyOf, or allOf are rejected; adjust the exported DTO shape or extend the generator deliberately. Re-run the generator whenever rpc-schema.json changes, or the frontend types go stale.

On this page