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/generatedIt always emits the three core files, plus conditional outputs when the schema calls for them:
| File | Purpose |
|---|---|
rpc-types.ts | RpcMethods interface mapping method names to params/result types. |
rpc-schemas.ts | rpcParamsSchemas and rpcResultSchemas Zod maps for runtime params/result validation. |
rpc-client.ts | Typed fetch client for single calls and batches. |
session-client.ts | Client-capability snapshot client and OpenFeature provider — emitted only when the schema exposes the elarion.session operation (rename it with --session-client <file>). |
events-client.ts | Topic-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 --watchIt 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 --watchThe 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-startcreateStartRpcApi 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
}| Getter | Code | AppError kind |
|---|---|---|
isInvalidParams | -32602 | Validation |
isNotFound | -32001 | NotFound |
isConflict | -32002 | Conflict |
isForbidden | -32003 | Forbidden |
isBusinessRule | -32004 | BusinessRule |
isUnauthorized | -32005 | Unauthorized |
isInternalError | -32603 | Internal |
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:
| Field | From |
|---|---|
requires.anonymous | [AllowAnonymous] |
requires.authenticated | any 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 |
readOnly | the 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 joinsrpc-schema.jsonand 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-tswith its Zod plugin for typed params and runtime-validated responses. Errors arrive as RFC 7807 ProblemDetails with the same stablecodeanddatamembers as anRpcError; the OpenAPI document declares both, so branch onerror.codewithout 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.
Schema generation
Export rpc-schema.json automatically during dotnet build with the Elarion.AspNetCore.SchemaGeneration MSBuild package.
gRPC
Add unary and request-driven server-streaming gRPC service methods over Elarion handlers with explicit protobuf mapping, scoped identity, and stable AppError status translation.