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 emits three files:

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.

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 --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 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-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' })

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
}
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.

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.

On this page