Building Custom Plugins
Create reusable guards, wraps, and hooks.
Katman 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 "katman"
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 = k.query({
: [tenantGuard],
: ({ }) => {
// 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 k.guard(async () => {
const = .headers[]
if (! && . !== false) {
throw new KatmanError("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 k.wrap(async (, ) => {
const = .
? .(, .__input)
: .(.__input)
const = .()
if ( && . > .()) {
return .
}
const = await ()
.(, {
: ,
: .() + ,
})
return
})
}Use it:
const = createCacheWrap({ : 60_000 })
const = k.query({
: [],
: ({ }) => .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 array.
import { } from "katman"
export function (: ) {
return ({
: ({ }) => {
.increment("rpc.requests")
},
: ({ }) => {
.histogram("rpc.duration", )
},
: ({ }) => {
.increment("rpc.errors")
},
})
}Apply it per-procedure:
const = createMetricsPlugin(statsd)
const = k.query({
: [],
: ({ }) => .db.users.findMany(),
})Or apply it globally via instance-level hooks:
const = katman({
: () => ({ : 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 = k.guard(() => {
return {
: [] as string[],
}
})
const = k.wrap(async (, ) => {
const = await ()
if (.auditTrail.length > 0) {
..info({
: .__procedurePath,
: .user?.id,
: .auditTrail,
})
}
return
})
return { , }
}Use both:
const { , } = createAuditPlugin({ })
const = k.mutation({
: [auth, , ],
: ({ , }) => {
.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
katmaninstance or options - Use TypeScript generics so the guard/wrap types compose with any context
- Document what the plugin adds to
ctxand what it expects
// katman-plugin-tenant/index.ts
import type { } from "katman"
export function < extends >(: ) {
const = .guard(async () => {
// ...
return { }
})
return { }
}Consumers install and use it:
import { } from "katman-plugin-tenant"
const { } = (k)What's next?
- Middleware — guard and wrap fundamentals
- Plugins — built-in plugins for reference
- Server — lifecycle hooks reference