Building Custom Plugins
Create reusable guards, wraps, and hooks.
Silgi has no special plugin API. A "plugin" is a guard, a wrap, a set of lifecycle hooks, or a combination — packaged for reuse. This page shows how to build and share your own.
Custom guard — tenant isolation
A guard that reads a tenant ID from the request and adds it to the context. Every downstream procedure and guard can access ctx.tenant.
import { , } from 'silgi'
const = ({
: () => ({
: .(.),
}),
})
// The guard
export const = .(async () => {
const = .['x-tenant-id']
if (!) {
throw new ('BAD_REQUEST', { : 'Missing X-Tenant-Id header' })
}
const = await db.tenants.findById()
if (!) {
throw new ('NOT_FOUND', { : 'Tenant not found' })
}
return { } // ctx.tenant is now typed
})Use it in any procedure:
const = s.$use(tenantGuard).$resolve(({ }) => {
// ctx.tenant is typed — TypeScript knows it exists
return .db.users.findMany({
: { : .tenant.id },
})
})Making it configurable
Wrap the guard in a factory function to accept options:
export function (: { ?: string; ?: boolean }) {
const = . ?? 'x-tenant-id'
return s.guard(async () => {
const = .headers[]
if (! && . !== false) {
throw new SilgiError('BAD_REQUEST', {
: `Missing ${} header`,
})
}
if (!) return { : null }
const = await db.tenants.findById()
return { }
})
}Custom wrap — response caching
A wrap that caches procedure responses in memory. It intercepts the pipeline, checks the cache, and either returns the cached value or calls next().
export function (
: {
?: number // milliseconds
?: (: any, : any) => string
} = {},
) {
const = . ?? 60_000 // 1 minute default
const = new <string, { : unknown; : number }>()
return s.wrap(async (, ) => {
const = . ? .(, .__input) : .(.__input)
const = .()
if ( && . > .()) {
return .
}
const = await ()
.(, {
: ,
: .() + ,
})
return
})
}Use it:
const = createCacheWrap({ : 60_000 })
const = s.$use().$resolve(({ }) => .db.products.findMany())This is an in-memory cache for demonstration. For production, use Redis or a dedicated caching layer. The pattern is
the same — the wrap checks the cache before calling next().
Custom lifecycle hooks
For cross-cutting concerns that apply to every procedure, lifecycle hooks are cleaner than adding a wrap to every .$use() call.
import { } from 'silgi'
export function (: ) {
return ({
: ({ }) => {
.increment('rpc.requests')
},
: ({ }) => {
.histogram('rpc.duration', )
},
: ({ }) => {
.increment('rpc.errors')
},
})
}Apply it per-procedure:
const = createMetricsPlugin(statsd)
const = s.$use().$resolve(({ }) => .db.users.findMany())Or apply it globally via instance-level hooks:
const = silgi({
: () => ({ : getDB() }),
: {
: () => metrics.increment('rpc.requests'),
: ({ }) => metrics.histogram('rpc.duration', ),
: () => metrics.increment('rpc.errors'),
},
})Composing multiple pieces
A real plugin often combines a guard, a wrap, and hooks. Package them together:
export function (: { : }) {
const = s.guard(() => {
return {
: [] as string[],
}
})
const = s.wrap(async (, ) => {
const = await ()
if (.auditTrail.length > 0) {
..info({
: .__procedurePath,
: .user?.id,
: .auditTrail,
})
}
return
})
return { , }
}Use both:
const { , } = createAuditPlugin({ })
const = s.$use(auth, , ).$resolve(({ , }) => {
.auditTrail.push(`Deleted user ${.id}`)
return .db.users.delete(.id)
})Packaging for distribution
If you want to publish your plugin as an npm package:
- Export factory functions, not instances — let consumers pass their own
silgiinstance or options - Use TypeScript generics so the guard/wrap types compose with any context
- Document what the plugin adds to
ctxand what it expects
// silgi-plugin-tenant/index.ts
import type { } from 'silgi'
export function < extends >(: ) {
const = s.guard(async () => {
// ...
return { }
})
return { }
}Consumers install and use it:
import { } from 'silgi-plugin-tenant'
const { } = (k)What's next?
- Middleware — guard and wrap fundamentals
- Plugins — built-in plugins for reference
- Server — lifecycle hooks reference