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).
Create a link
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. AddsRetry-Afterparsing, distinctHTTPError/NetworkError/TimeoutErrorclasses,Idempotency-Keyauto-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.
Link options
The createLink function accepts these options:
| Option | Type | Default | Description |
|---|---|---|---|
url | string | (required) | Server base URL (e.g. "http://localhost:3000") |
headers | Record<string, string> or function | undefined | Static headers or a function that returns headers per request |
retry | number | false | 0 | Number of retries on failure |
retryDelay | number | (ctx) => number | 0 | Delay between retries in milliseconds |
timeout | number | 30000 | Request 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
misina link
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-AfterandRateLimit-Resetparsing — when the server signals a delay, the transport honors it instead of using fixed backoff.- Distinct error classes —
HTTPError(server responded with an error status),NetworkError(DNS, connection reset, offline),TimeoutError(per-attempt or wall-clock budget exceeded). All map intoSilgiErrorfor catch-by-code, but you can introspect the cause via misina'sisHTTPError/isNetworkError/isTimeoutErrorguards. - Idempotency-Key auto-generation — retried POST/PATCH/DELETE calls send the same
Idempotency-Keyacross attempts so the server can deduplicate. SetidempotencyKey: 'auto'on the misina instance. - Wall-clock timeout —
timeoutcaps each attempt;totalTimeoutcaps the whole logical call (including retry delays). - Redirect security — sensitive headers are stripped on cross-origin redirects, and
https → httpdowngrades are refused. - Plugin ecosystem —
cache,breaker,dedupe,cookieJar,bearer,refreshOn401,sigv4,otel,tracing,ratelimit— all compose viacreateMisina({ use: [...] }).
Link options
The link surface itself is intentionally small. Configure transport behavior on the misina instance.
| Option | Type | Default | Description |
|---|---|---|---|
url | string | (required) | Server base URL |
misina | Misina | undefined | Pre-configured misina instance. Defaults to createMisina({ baseURL: url }). |
protocol | 'json' | 'messagepack' | 'devalue' | 'json' | Wire protocol |
headers | HeadersInit | Record<string, string | undefined> or function | undefined | Static 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:
| Option | Type | Description |
|---|---|---|
signal | AbortSignal | Cancel the request |
context | Record | Custom 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 anywhereSee 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
DynamicLink
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
queryOptionsandmutationOptionsfrom the client - Server — the server-side setup that the client connects to