Skip to content

v0.52.0

Choose a tag to compare

@github-actions github-actions released this 16 Apr 06:37
· 28 commits to main since this release

   🚨 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 using instead 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).trace

Drizzle 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 swallowed

isSilgiError(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 pass schemaConverters: [zodConverter] to silgi({...})
  • Analytics consumers: rename ctx.__analyticsTracectx.trace in 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 = ctx to setRequestContext(request, ctx) (optional — old form still works)
  • Error type guards: consider swapping instanceof SilgiError for isSilgiError(e) in cross-realm code paths

Everything else is backwards compatible. Run your tests — the API surface is the same.