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 resolvers (Drizzle + Better Auth integrations migrated internally) - Production servers using
serve(): add{ handleSignals: true }if you relied on auto SIGINT/SIGTERM - Better Auth users: switch
(request as any).__silgiCtx = ctxtosetRequestContext(request, ctx)(optional — old form still works) - Error type guards: consider swapping
instanceof SilgiErrorforisSilgiError(e)in cross-realm code paths
Everything else is backwards compatible. Run your tests — the API surface is the same.