Silgi

Client

Set up the type-safe client with createLink and createClient — safe clients, streaming, interceptors, and type inference.

The Silgi client gives you a fully typed proxy that mirrors your server's router structure. You call procedures like local functions, and TypeScript knows every input and output type.

Setup

Setting up the client takes two steps: create a link (the transport layer) and then create the client (the typed proxy).

A link tells the client how to send requests to the server. Silgi ships two HTTP link adapters with the same Silgi semantics — pick whichever fits your stack:

  • silgi/client/ofetch — the default. Uses ofetch for retries, timeouts, and interceptors. Stable, widely used, h3/Nuxt-aligned.
  • silgi/client/misina — an alternative built on misina. Adds Retry-After parsing, distinct HTTPError/NetworkError/TimeoutError classes, Idempotency-Key auto-generation for retried mutations, and a plugin ecosystem (cache, breaker, dedupe, cookies, auth, otel, tracing).
import {  } from 'silgi/client/ofetch'

const  = ({
  : 'http://localhost:3000',
})

Relative URLs work too — useful with dev server proxies or same-origin deployments:

import {  } from 'silgi/client/ofetch'

const  = ({ : '/api' })
// Calls resolve to /api/users/list, /api/health, etc.

Create the client

Pass the link to createClient with your router type as the generic parameter:

import {  } from 'silgi/client'
import {  } from 'silgi/client/ofetch'
import type {  } from './server' // export type AppRouter = typeof appRouter

const  = ({ : 'http://localhost:3000' })
const  = <>()

Call procedures

The client mirrors your router structure exactly. Nested routers become nested properties:

// Router structure: { users: { list, create }, health }

const  = await client.users.list({ : 10 })
const  = await client.users.create({ : 'Alice' })
const  = await client.health()

Every call has full autocomplete and type checking. If the procedure expects { limit: number }, TypeScript won't let you pass { limit: "ten" }.

Type inference

To get the router type on the client side, export it from your server file:

// server.ts
export const  = s.router({
  : {
    : listUsers,
    : createUser,
  },
})
export type  = typeof 

Then use it when creating the client:

import type {  } from './server'

const  = createClient<>(link)

You can also use the InferClient utility type, which extracts the client type from any router:

import type {  } from 'silgi'

type  = <typeof appRouter>
// Client.users.list: (input: { limit?: number }) => Promise<User[]>
// Client.users.create: (input: { name: string }) => Promise<User>

In a monorepo, you only need to share the type of the router, not the actual code. Use import type so no server code leaks into your client bundle.

The createLink function accepts these options:

OptionTypeDefaultDescription
urlstring(required)Server base URL (e.g. "http://localhost:3000")
headersRecord<string, string> or functionundefinedStatic headers or a function that returns headers per request
retrynumber | false0Number of retries on failure
retryDelaynumber | (ctx) => number0Delay between retries in milliseconds
timeoutnumber30000Request timeout in milliseconds
protocol'json' | 'messagepack' | 'devalue''json'Wire protocol for encoding

The client always uses POST and resolves procedure paths from the router tree structure (e.g. client.users.list() sends POST /users/list). No route metadata is needed.

Authentication headers

The most common use for dynamic headers is passing an auth token:

const  = createLink({
  : 'http://localhost:3000',
  : () => ({
    : `Bearer ${getToken()}`,
  }),
})

The header function runs on every request, so it always picks up the latest token. This is important for scenarios like token refresh — when the token changes, the next request automatically uses the new one.

Retry and timeout

const  = createLink({
  : 'http://localhost:3000',
  : 3, // retry up to 3 times on failure
  : 1000, // wait 1 second between retries
  : 10_000, // 10 second timeout per request
})

For exponential backoff, pass a function:

const  = createLink({
  : 'http://localhost:3000',
  : 3,
  : () => .(2, .options?.retryCount ?? 0) * 500,
  // 500ms, 1000ms, 2000ms
})

Protocol selection

Set protocol to choose the wire format. Payloads are smaller and encoding/decoding is faster with MessagePack:

const  = createLink({
  : 'http://localhost:3000',
  : 'messagepack',
})

The client encodes the request body as MessagePack, sets the Content-Type and Accept headers to application/x-msgpack, and the server responds in MessagePack too.

Binary mode is completely transparent to your application code. You still pass and receive regular JavaScript objects. The encoding happens inside the link — you never see MessagePack bytes in your code.

See the MessagePack protocol page for more details on what types are supported.

Interceptors

The link supports ofetch interceptors for fine-grained control over the request lifecycle:

const  = createLink({
  : 'http://localhost:3000',
  ({ ,  }) {
    // Runs before every request is sent
    .('Sending:', )
  },
  ({  }) {
    // Runs after every successful response
    .('Received:', .status)
  },
  ({  }) {
    // Runs when the request fails (network error, DNS failure, etc.)
    .('Request failed:', )
  },
  ({  }) {
    // Runs when the server responds with an error status
    .('Server error:', .status)
  },
})

Interceptors are useful for:

  • Logging in development
  • Token refresh — intercept 401 responses, refresh the token, and retry
  • Metrics — measure request timing and success rates

The misina link is an alternative transport with stricter retry semantics, distinct error classes, and a plugin ecosystem. Unlike the ofetch link, transport configuration lives on a Misina instance you build with createMisina(...) and pass through — the link itself only handles silgi-specific concerns (URL construction, protocol negotiation, SSE branching, SilgiError lifting).

import {  } from 'misina'
import {  } from 'silgi/client'
import {  } from 'silgi/client/misina'

const  = ({
  : 'http://localhost:3000',
  : ({
    : 2,
    : 'auto',
  }),
})

const  = <>()

If you omit misina, the adapter constructs a minimal default (createMisina({ baseURL: url })) — fine for plain RPC, but you opt into retries, hooks, plugins, etc. by passing your own.

Why pick misina?

  • Retry-After and RateLimit-Reset parsing — when the server signals a delay, the transport honors it instead of using fixed backoff.
  • Distinct error classesHTTPError (server responded with an error status), NetworkError (DNS, connection reset, offline), TimeoutError (per-attempt or wall-clock budget exceeded). All map into SilgiError for catch-by-code, but you can introspect the cause via misina's isHTTPError / isNetworkError / isTimeoutError guards.
  • Idempotency-Key auto-generation — retried POST/PATCH/DELETE calls send the same Idempotency-Key across attempts so the server can deduplicate. Set idempotencyKey: 'auto' on the misina instance.
  • Wall-clock timeouttimeout caps each attempt; totalTimeout caps the whole logical call (including retry delays).
  • Redirect security — sensitive headers are stripped on cross-origin redirects, and https → http downgrades are refused.
  • Plugin ecosystemcache, breaker, dedupe, cookieJar, bearer, refreshOn401, sigv4, otel, tracing, ratelimit — all compose via createMisina({ use: [...] }).

The link surface itself is intentionally small. Configure transport behavior on the misina instance.

OptionTypeDefaultDescription
urlstring(required)Server base URL
misinaMisinaundefinedPre-configured misina instance. Defaults to createMisina({ baseURL: url }).
protocol'json' | 'messagepack' | 'devalue''json'Wire protocol
headersHeadersInit | Record<string, string | undefined> or functionundefinedStatic headers or per-call factory. Merged on top of headers from the misina instance.

For everything else — retry, timeout, totalTimeout, hooks, idempotencyKey, validateResponse, redirect policy, plugins, custom drivers, request id headers, body timeouts, max response size — see the misina docs.

Retry with backoff

Configure on the instance:

import {  } from 'misina'

const  = createLink({
  : 'http://localhost:3000',
  : ({
    : {
      : 3,
      : () => 0.3 * 2 ** ( - 1) * 1000, // 300ms, 600ms, 1200ms
      : 30_000,
      : true,
      : [408, 429, 500, 502, 503, 504],
    },
  }),
})

Hooks

misina exposes a typed lifecycle (init, beforeRequest, beforeRetry, beforeRedirect, afterResponse, beforeError, onComplete). Pass them through createMisina({ hooks: { ... } }):

import {  } from 'misina'

const  = createLink({
  : 'http://localhost:3000',
  : ({
    : {
      : () => {
        const  = new (..)
        .('x-trace-id', .())
        return new (., {  })
      },
      : ({  }) => {
        if () .('status:', .)
      },
      : ({ , , ,  }) => {
        metrics.observe('rpc.duration', , {
          : .,
          : ( + 1),
          :  ? 'false' : 'true',
        })
      },
    },
  }),
})

onComplete is the right place for tracing and metrics — it fires exactly once per logical call, after all retries and redirects, with either response or error populated.

Plugins

Cross-cutting features (auth, cache, cookies, dedupe, breaker, rate limit, tracing, …) ship as misina plugins. Compose them on the instance via use: [...]:

import {  } from 'misina'
import { ,  } from 'misina/auth'
import { ,  } from 'misina/cache'
import {  } from 'misina/breaker'
import {  } from 'misina/dedupe'

const  = createLink({
  : 'http://localhost:3000',
  : ({
    : 'http://localhost:3000',
    : 3,
    : 'auto',
    : [
      (() => store.token),
      ({ : async () => fetchNewToken() }),
      ({ : ({ : 500 }), : 60_000 }),
      ({ : 5, : 30_000 }),
      (),
    ],
  }),
})

Plugins apply left-to-right (first is innermost). The full set is documented in the misina README.

Both links produce the same SilgiError instances on failure, so your catch logic doesn't change when you swap. The difference is in the request lifecycle — retries, idempotency, and how the underlying transport classifies failures.

The adapter forces responseType: 'stream' and throwHttpErrors: false per call, regardless of your instance defaults — both are required for SSE subscription branching and SilgiError lifting. Every other misina option flows through unchanged.

Call options

Each procedure call can receive a second argument with per-call options:

const  = new ()

const  = await client.users.list({ : 10 }, { : . })

// Cancel the request at any time
.()

Available per-call options:

OptionTypeDescription
signalAbortSignalCancel the request
contextRecordCustom context passed to the link

Error handling

Server errors are reconstructed as SilgiError instances on the client. You can catch them with try/catch:

import {  } from 'silgi'

try {
  await client.users.delete({ : 1 })
} catch () {
  if ( instanceof ) {
    .(.) // "FORBIDDEN"
    .(.) // 403
    .(.) // { reason: "Not the owner" }
  }
}

Or use the safe() wrapper to avoid try/catch entirely:

import {  } from 'silgi/client'

const { , , ,  } = await (client.users.delete({ : 1 }))

if () {
  .(.code) // "FORBIDDEN"
} else {
  .() // the deleted user
}

For an entire client that never throws, use createSafeClient:

import {  } from 'silgi/client'

const  = <>(link)

const { , , ,  } = await .users.list({ : 10 })
// Every call returns SafeResult — no try/catch needed anywhere

See the Typed Errors page for the complete error handling guide, including the defined flag for distinguishing business errors from unexpected failures.

Server-side client

For SSR, server components, or testing — use createServerClient to get the same typed client interface without any network overhead:

import {  } from 'silgi/client/server'

const  = (appRouter, {
  : () => ({ : getDB() }),
})

// Same API as the HTTP client — but runs in-process
const  = await .users.list({ : 10 })
const  = await .users.create({ : 'Alice' })

The server client compiles the router once and reuses the compiled handlers. Every call runs the full pipeline (guards, wraps, validation) — the only thing skipped is HTTP serialization.

This is especially useful for:

  • SSR data fetching — call procedures during server rendering without a network round-trip
  • Testing — write integration tests against the real pipeline without starting a server
  • Server-to-server — call procedures from background jobs, cron tasks, or other services running in the same process

A DynamicLink lets you route requests to different links at runtime. The factory function receives the procedure path, input, and call options, and returns the link to use:

import { ,  } from 'silgi/client'

const  = new ((, , ) => {
  if ([0] === 'admin') return adminLink
  return defaultLink
})

const  = <>()

The selector can also be async — useful for lazy-loading a link:

const  = new DynamicLink(async () => {
  if ([0] === 'analytics') {
    const {  } = await import('silgi/client/ofetch')
    return ({ : 'https://analytics.example.com' })
  }
  return defaultLink
})

This is useful when different parts of your API live on different servers, or when you want to swap between real and mock links based on environment.

withInterceptors

Wrap any link with lifecycle hooks. Unlike ofetch interceptors (which are HTTP-level), these run at the Silgi procedure level and receive the procedure path and input:

import {  } from 'silgi/client'

const  = (baseLink, {
  ({ ,  }) {
    .(`-> ${.('/')}`)
  },
  ({ ,  }) {
    .(`<- ${.('/')} (${}ms)`)
  },
  ({ ,  }) {
    reportToSentry()
  },
})

All three hooks are optional. onError fires on any thrown error — the error still propagates to the caller after the hook runs.

Consuming streams

For subscription procedures that return async iterators, use the consumption utilities from silgi/client/consume:

import {  } from 'silgi/client/consume'

const  = await client.events.subscribe()

await (, {
  : () => .('Event:', ),
  : () => .('Error:', ),
  : () => .('Stream ended'),
  : controller.signal, // optional abort
})

For automatic reconnection with lastEventId resumption:

import {  } from 'silgi/client/consume'

await ({
  : () => client.events.subscribe(, {  }),
  : () => updateUI(),
  : () => .(`Reconnecting (#${})...`),
  : 10,
  : 2000,
})

You can also transform stream values with mapIterator:

import {  } from 'silgi/client/consume'

const  = (iterator, () => ({
  ...,
  : .(),
}))

What's next?

  • Typed Errors — handle server errors on the client with full type safety
  • Protocols — JSON, MessagePack, and devalue encoding
  • TanStack Query — generate queryOptions and mutationOptions from the client
  • Server — the server-side setup that the client connects to

On this page