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 emits three files:
| 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. |
The schema and client files import zod (v3 and v4 are both supported), so install it as a runtime
dependency in the consuming app.
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().
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 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 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' })For finer control the adapter also exports forwardRequestCookie — the isomorphic headers function on its
own — so you can compose it into a plain createRpcApi({ headers: forwardRequestCookie }) call.
The cookie 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 carrying code, message, and
optional data. 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 — so a frontend
branches on the kind directly instead of re-wrapping the error:
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.
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.
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.
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.