Releases: productdevbook/silgi
v0.53.6
🚨 Breaking Changes
- client/misina: Instance-only API, drop flat option re-exports - by @productdevbook in #32 (3fe73)
The misina client link no longer accepts misina options (retry, timeout, hooks, idempotencyKey, validateResponse, redirect*, etc.) directly. Configure those on a Misina instance and pass it via the new misina field. Per misina#108, this is the recommended adapter pattern going forward.
Migration
// before
import { createLink } from 'silgi/client/misina'
createLink({
url,
retry: 3,
timeout: 5000,
idempotencyKey: 'auto',
beforeRetry: refreshToken,
})
// after
import { createMisina } from 'misina'
import { createLink } from 'silgi/client/misina'
createLink({
url,
misina: createMisina({
retry: 3,
timeout: 5000,
idempotencyKey: 'auto',
hooks: { beforeRetry: refreshToken },
}),
})Plugin composition is now first-class
import { createMisina } from 'misina'
import { cache } from 'misina/cache'
import { breaker } from 'misina/breaker'
import { bearer, refreshOn401 } from 'misina/auth'
createLink({
url,
misina: createMisina({
baseURL: url,
retry: 3,
use: [
bearer(() => store.token),
refreshOn401({ refresh: getNewToken }),
cache({ ttl: 60_000 }),
breaker({ failureThreshold: 5, windowMs: 30_000 }),
],
}),
})What the adapter still owns
- URL construction from path tuples
- Protocol negotiation (json / messagepack / devalue)
- Per-call
responseType: 'stream'override (SSE branching) - Per-call
throwHttpErrors: falseoverride (SilgiErrorlift) SilgiErrorlifting from response payloads + misina error mapping
Why
The hybrid surface had three costs the adapter couldn't mitigate: drift on every misina release (every new option had to be re-exported manually), plugins were unreachable from flat options (use: [...] is the only way to compose cache / breaker / dedupe / cookies / auth / otel / tracing), and "two ways to do the same thing" forced duplicated documentation.
Stats
- Adapter source: ~250 → ~110 LOC of code
- Generated
.d.mts: ~150 → 41 lines - Tests: 26 → 14 (focused on adapter-only concerns; misina's suite owns retry/timeout/idempotency/validateResponse coverage)
- Bumps
misina:^0.2.0→^0.4.0
View changes on GitHub
v0.53.5
🐞 Bug Fixes
- types: Wrap subscription InferClient output in Promise - by @productdevbook and Claude Opus 4.7 (1M context) in #29 and #30 (b7318)
View changes on GitHub
v0.53.4
🌟 v0.53.4 — Meet the misina link
This release adds a brand-new HTTP transport for the Silgi client, sitting side-by-side with the existing ofetch link. Nothing in your existing code changes — silgi/client/ofetch keeps working exactly as before. The new silgi/client/misina is purely additive, opt-in, and built for teams that want stricter retry and error semantics out of the box.
✨ What's new
silgi/client/misina — a second link adapter
Powered by misina, a zero-dependency, fetch-first HTTP client. Same createLink({ url, headers, retry, timeout, protocol }) shape you already know, with a few new tricks:
import { createClient } from 'silgi/client'
import { createLink } from 'silgi/client/misina'
const link = createLink({
url: 'http://localhost:3000',
retry: 2,
idempotencyKey: 'auto',
})
const client = createClient<AppRouter>(link)Why you might pick it
Retry-After&RateLimit-Resetparsing — when the server signals a delay, the link honors it instead of using your fixed backoff.- Distinct error classes —
HTTPError(server returned an error status),NetworkError(DNS, connection reset, offline), andTimeoutError(per-attempt or wall-clock budget exceeded). All still surface asSilgiErrorfor catch-by-code, so your existing error handling keeps working. Idempotency-Keyauto-generation — retriedPOST/PATCH/DELETEcalls send the sameIdempotency-Keyacross attempts so the server can deduplicate. SetidempotencyKey: 'auto'to opt in.- 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. - Lifecycle hooks —
beforeRequest,afterResponse,beforeError, and a terminalonCompletethat fires exactly once per logical call (perfect for tracing & metrics).
Retry with backoff
const link = createLink({
url: 'http://localhost:3000',
retry: {
limit: 3,
delay: (attempt) => 0.3 * 2 ** (attempt - 1) * 1000, // 300ms, 600ms, 1200ms
backoffLimit: 30_000,
jitter: true,
statusCodes: [408, 429, 500, 502, 503, 504],
},
})Re-exports
A new silgi/misina subpath re-exports the upstream package, mirroring the existing silgi/ofetch ergonomics — so you can grab misina's typed errors and helpers directly from Silgi:
import { isHTTPError, isNetworkError, isTimeoutError } from 'silgi/misina'📚 Docs
- Full new "misina link" section in the Client docs — options table, retry-with-backoff, hooks, and trade-offs vs ofetch.
🤝 Compatibility
- Zero breaking changes. The default link is still
silgi/client/ofetch. - Both links produce the same
SilgiErrorinstances on failure — switching transports doesn't change your catch logic. - Same wire protocols (
json/messagepack/devalue), same SSE subscription support, same per-callsignal.
🙏 Which one should I use?
- Stick with ofetch if you're already happy, deploying to Nuxt/h3, or want the most battle-tested option.
- Try misina if you care about retry correctness on rate-limited APIs, want explicit
NetworkErrorvsHTTPErrordiscrimination, or need idempotency keys for retried mutations.
You can even mix both via DynamicLink if different parts of your API live behind different services.
Full Changelog: v0.53.3...v0.53.4
v0.53.3
🐞 Bug Fixes
- client: Decode SSE responses in fetch + ofetch adapters - by @productdevbook and Claude Opus 4.7 (1M context) in #24 and #25 (a5f8c)
- compile: Validate each yielded item on subscription $output - by @productdevbook and Claude Opus 4.7 (1M context) in #26 and #27 (d61f3)
- schema: Surface validator crashes instead of generic 500 - by @productdevbook and Claude Opus 4.7 (1M context) in #22 and #23 (ce5c1)
View changes on GitHub
v0.53.2
🐞 Bug Fixes
- Root wraps now wrap guards too - by @productdevbook and Claude Opus 4.7 (1M context) in #14 and #16 (212c6)
View changes on GitHub
v0.53.0
🚀 Features
- Root-level wraps + de-magic sweep - by @productdevbook and Claude Opus 4.7 (1M context) in #12 (6fbe7)
🐞 Bug Fixes
- Correctness sweep — 7 silent-failure bugs - by @productdevbook and Claude Opus 4.7 (1M context) in #13 (e9c60)
View changes on GitHub
v0.52.2
Revert
Rolls back defineRouteKit, mapDomainErrors, and the $use MiddlewareDef union overload added in v0.52.1 (#10).
In practice, these helpers made route-level dependency injection typeable instead of removing it — and the DI pattern itself is the real source of the any ctx and GuardDef<any, any> noise reported in #9. The cleaner fix is the shared rpc package pattern (export s + shared guards from an internal package, import directly in domain packages) which removes the boilerplate without new API surface. See #9 for the pattern and rationale.
Breaking for v0.52.1 consumers only. If you adopted one of these in v0.52.1:
-
defineRouteKit<Ctx>()→ inline the route in the package that owns thesilgi()instance, or importsfrom a shared package. -
mapDomainErrors(mapper)→ keep as a userland wrapper; ~10 lines, not core. -
$use(mw)with aGuardDef | WrapDefunion → pass the concrete middleware; the union boundary was only needed for the factory pattern we're recommending against. -
Revert: defineRouteKit, mapDomainErrors, $use union overload (#10) - by @productdevbook (ae33d)
View changes on GitHub
v0.52.1
🚀 Features
- DefineRouteKit, mapDomainErrors, $use union overload - by @productdevbook and Claude Opus 4.7 (1M context) in #10 (9ce23)
🏎 Performance
- handler: Drop redundant Object.keys on rou3 params - by @productdevbook and Claude Opus 4.7 (1M context) (4051e)
View changes on GitHub
v0.52.0
🚨 Breaking Changes
- De-magic — remove global state, auto-register, hidden signals - by @productdevbook and Claude Opus 4.6 (1M context) in #8 (35ad3)
🐞 Bug Fixes
- Forward hooks to createFetchHandler in adapter code paths - by @productdevbook and Claude Opus 4.6 (1M context) (42558)
- adapters: Propagate event via AsyncLocalStorage, not WeakMap - by @productdevbook and Claude Opus 4.6 (1M context) (6249a)
- analytics: Require auth and always redact sensitive headers - by @productdevbook and Claude Opus 4.6 (1M context) (e785a)
- bun: Forward analytics/scalar/basePath through wrapHandler - by @productdevbook and Claude Opus 4.6 (1M context) (a00a1)
- core: Release pooled context via
usinginstead of manual calls - by @productdevbook and Claude Opus 4.6 (1M context) (486be)
View changes on GitHub
De-Magic 🧹 — What changed for you
A structural refactor that eliminates every hidden global singleton, import-order side effect, and process-level magic from silgi. The codebase is now fully auditable, multi-instance-safe, and easier to contribute to — without giving up the compiled-pipeline performance that made silgi fast in the first place.
874 tests passing (+55 new), pipeline and HTTP benchmarks within measurement variance of v0.51.7.
Full rationale per phase: docs/rfcs/0001-de-magic.md · Architecture tour: ARCHITECTURE.md
⚠️ Breaking Changes — migration details
Three breaking changes total. All migrations are mechanical — copy-paste fixes below.
1. Zod now requires explicit injection
The side-effect import 'silgi/zod' is removed. Pass the converter explicitly so bundlers can audit it, tree-shake correctly, and so multiple silgi instances don't fight over a shared global registry.
// ❌ v0.51
import 'silgi/zod'
const k = silgi({ context })
// ✅ v0.52
import { zodConverter } from 'silgi/zod'
const k = silgi({ context, schemaConverters: [zodConverter] })Good news for Zod v4 users: Zod v4 implements the native Standard Schema jsonSchema.input() fast path, so OpenAPI/analytics works even without schemaConverters. Passing zodConverter is still recommended as a safety net.
Writing your own converter? The SchemaConverter interface now declares its own vendor string:
import type { SchemaConverter } from 'silgi'
const myConverter: SchemaConverter = {
vendor: 'my-lib',
toJsonSchema(schema, { strategy }) { /* ... */ },
}2. Analytics trace context key renamed
ctx.__analyticsTrace (double-underscore convention) is now ctx.trace (typed on BaseContext). The module-scoped analyticsTraceMap WeakMap was also deleted — custom analytics wrappers now integrate via the new request:prepare hook.
// ❌ v0.51
const trace = ctx.__analyticsTrace as RequestTrace | undefined
// ✅ v0.52
import type { BaseContext } from 'silgi'
const trace = (ctx as BaseContext).traceDrizzle and Better Auth integrations are migrated internally — if you're only a consumer of those, nothing to do.
3. serve() no longer auto-registers SIGINT/SIGTERM handlers
Signal handling is now opt-in. The monkey-patch on server.close is also gone; a clean wrapper takes its place.
// ❌ v0.51 — implicit process.once('SIGINT') registered
const server = await k.serve(router)
// ✅ v0.52 — opt-in when you want the old behavior
const server = await k.serve(router, { handleSignals: true })The srvx-level gracefulShutdown option (HTTP drain) is unchanged. Explicit server.close() still stops cron jobs — that's orthogonal to OS signal handling.
✨ New APIs
silgi.runInContext(ctx, fn) and silgi.currentContext()
Each silgi instance now owns its own AsyncLocalStorage bridge. Instrumented integrations (Drizzle, Better Auth) read the ambient context via this per-instance channel — multi-tenant setups with two silgi instances in the same process no longer silently share state.
const k = silgi({ context: () => ({ userId: 'u_1' }) })
k.runInContext({ userId: 'u_1' }, () => {
k.currentContext()?.userId // 'u_1'
})silgi.ready()
Storage init is no longer fire-and-forget. Await ready() to guarantee the storage driver is connected before your first useStorage() call — or skip it; useStorage() awaits it internally.
import redisDriver from 'unstorage/drivers/redis'
const k = silgi({
context: () => ({}),
storage: { cache: redisDriver({ url: 'redis://localhost' }) },
})
await k.ready() // errors reject here instead of being swallowedisSilgiError(e) — blessed cross-realm error type guard
The internal error registry (a globalThis[Symbol.for('silgi.error.registry')] WeakSet) is gone. Cross-realm instanceof SilgiError still works via a prototype brand, but isSilgiError() is now the recommended check — it's faster and realm-transparent without relying on the prototype walk.
import { isSilgiError } from 'silgi'
try {
await client.users.get({ id })
} catch (e) {
if (isSilgiError(e)) {
console.error(e.code, e.status) // type-narrowed
}
}setRequestContext(request, ctx) for Better Auth
Replaces the manual (request as any).__silgiCtx = ctx assignment that was documented but awkward. Uses a WeakMap so no mutation of the Request object, and the entry auto-releases when the request is GC'd. The legacy assignment still works as a fallback.
import { setRequestContext } from 'silgi/better-auth'
const authHandler = k.$resolve(async ({ ctx, request }) => {
setRequestContext(request, ctx) // preferred
return auth.handler(request)
})createContextBridge() / createSchemaRegistry() / BaseContext type
Exposed for framework-level builders who want their own scopes or want to extend silgi's typed context surface. See the API reference on silgi.dev.
🏗️ Architecture changes (under the hood)
| What was there | What's there now |
|---|---|
globalThis[Symbol.for('silgi.error.registry')] WeakSet |
Non-enumerable prototype brand on SilgiError.prototype |
src/core/trace-map.ts module-scoped WeakMap |
Internal request:prepare / response:finalize hooks |
Module-global AsyncLocalStorage in context-bridge.ts |
Per-instance bridge built by createContextBridge() |
registerSchemaConverter('zod', ...) side-effect registry |
Per-instance Map built from the schemaConverters option |
import 'silgi/zod' auto-registers globally |
Explicit { schemaConverters: [zodConverter] } |
ctx.__analyticsTrace (string key) |
ctx.trace (typed via BaseContext) |
(request as any).__silgiCtx = ctx |
setRequestContext(request, ctx) (WeakMap) |
Fire-and-forget import('./core/storage.ts').catch(console.error) |
Explicit readyPromise exposed as silgi.ready() |
process.once('SIGINT'/'SIGTERM') registered unconditionally |
Opt-in { handleSignals: true } |
server.close = async () => { stopCron(); originalClose() } (monkey patch) |
Returned object wraps close cleanly, no mutation |
"sideEffects": false in package.json (buggy for zod) |
Narrow ["./dist/integrations/zod/index.mjs"] |
New contributor docs: CONTRIBUTING.md, ARCHITECTURE.md, SECURITY.md. TypeDoc validation added to CI (pnpm docs:check).
📊 Performance
The refactor introduces one small cost (per-instance AsyncLocalStorage wrap around every request) and removes one implicit cost (the old analyticsTraceMap.get(request) + WeakMap.set pair that ran on every request even with analytics disabled). Net impact is within measurement variance.
RPC pipeline (ns, lower is better)
| Scenario | v0.51 | v0.52 | vs oRPC | vs H3 v2 |
|---|---|---|---|---|
| No middleware | 96 | 112 | 6.7× | 25.6× |
| Zod validation | 133 | 136 | 7.0× | 35.0× |
| 3 middleware + Zod | 178 | 179 | 9.9× | 23.3× |
| 5 middleware + Zod | 330 | 343 | 6.6× | 12.0× |
HTTP/1.1 latency (Node.js, sequential requests, µs)
| Scenario | v0.51 | v0.52 |
|---|---|---|
| Simple (no mw, no validation) | 90 | 77 |
| Zod input validation | 93 | 89 |
| Guard + Zod validation | 87 | 83 |
HTTP throughput (bombardier, 256 conc. connections)
| Framework | req/s |
|---|---|
| Fastify | 52,866/s |
| Express | 45,813/s |
| Silgi | 39,920/s |
| Hono | 36,333/s |
WebSocket: 38 µs (unchanged — within 1 µs of v0.51). Memory: ~40 bytes/call (unchanged).
Full benchmark methodology and numbers: BENCHMARKS.md.
📦 Migration Checklist
If you use silgi today, these are the only edits you'll likely need:
- Zod users: add
import { zodConverter } from 'silgi/zod'and passschemaConverters: [zodConverter]tosilgi({...}) - Analytics consumers: rename
ctx.__analyticsTrace→ctx.tracein your resolver...
v0.51.8
🚨 Breaking Changes
- core: Replace trace-map WeakMap with request:prepare hook - by @productdevbook and Claude Opus 4.6 (1M context) (b1740)
- schema: Require explicit schemaConverters on silgi() - by @productdevbook and Claude Opus 4.6 (1M context) (6902b)
- silgi: Add silgi.ready() + opt-in signal handling in serve() - by @productdevbook and Claude Opus 4.6 (1M context) (c302e)
🐞 Bug Fixes
- Forward hooks to createFetchHandler in adapter code paths - by @productdevbook and Claude Opus 4.6 (1M context) (42558)
- adapters: Propagate event via AsyncLocalStorage, not WeakMap - by @productdevbook and Claude Opus 4.6 (1M context) (6249a)
- analytics: Require auth and always redact sensitive headers - by @productdevbook and Claude Opus 4.6 (1M context) (e785a)
- bun: Forward analytics/scalar/basePath through wrapHandler - by @productdevbook and Claude Opus 4.6 (1M context) (a00a1)
- core: Release pooled context via
usinginstead of manual calls - by @productdevbook and Claude Opus 4.6 (1M context) (486be) - pkg: Declare silgi/zod as side-effectful in sideEffects array - by @productdevbook and Claude Opus 4.6 (1M context) (3b581)
🏎 Performance
- handler: Skip microtask cost for request:prepare when no hooks - by @productdevbook and Claude Opus 4.6 (1M context) (afbaf)