Kynetra FX

Documentation

Decorators

Decorators are the primary way to share state in Kynetra FX. App decorators attach values to the app instance — database connections, config, caches. Context decorators attach values to each request's ctx — per-request user objects, tenant data, derived state.

App decorators#

App decorators are set once at startup and live for the lifetime of the process. They are ideal for heavyweight singletons: database clients, connection pools, secret stores, and feature-flag SDKs. Register them with app.decorate(key, value) and retrieve them anywhere with app.getDecorator(key).

src/app.ts
import { createFX } from '@kynetra/fx'
import { createDbClient } from './db'
 
const app = createFX({ name: 'my-api' })
 
// Register the database client once at startup
app.decorate('db', createDbClient(process.env.DATABASE_URL))
 
// Read it inside a route handler
app.get('/users', (ctx) => {
const db = app.getDecorator('db')
const users = db.query('SELECT * FROM users LIMIT 20')
return ctx.json(users)
})
 
return app

The value is shared — every call to app.getDecorator('db') returns the exact same object. This is what you want for a database client: one connection pool, reused across all requests.

Type-safe app decorators#

The key passed to decorate and getDecorator is always a plain string, but TypeScript does not know the type of the returned value without help. Pass a generic to getDecorator<T>(key) to get a typed return value and full editor autocomplete.

src/types.ts
// Define your client types once
export interface DbClient {
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>
close(): Promise<void>
}
 
export interface CacheClient {
get(key: string): Promise<string | null>
set(key: string, value: string, ttlSeconds?: number): Promise<void>
del(key: string): Promise<void>
}
src/app.ts
import { createFX } from '@kynetra/fx'
import type { DbClient, CacheClient } from './types'
import { createDbClient } from './db'
import { createCacheClient } from './cache'
 
const app = createFX({ name: 'my-api' })
 
app.decorate('db', createDbClient(process.env.DATABASE_URL))
app.decorate('cache', createCacheClient(process.env.REDIS_URL))
 
app.get('/products/:id', async (ctx) => {
const db = app.getDecorator<DbClient>('db')
const cache = app.getDecorator<CacheClient>('cache')
 
const cacheKey = `product:${ctx.params.id}`
const cached = await cache.get(cacheKey)
if (cached) return ctx.json(JSON.parse(cached))
 
const [product] = await db.query<{ id: string; name: string }>(
'SELECT id, name FROM products WHERE id = ?',
[ctx.params.id]
)
if (!product) return ctx.json({ error: 'Not found' }, { status: 404 })
 
await cache.set(cacheKey, JSON.stringify(product), 300)
return ctx.json(product)
})

Checking before decorating#

When writing a plugin that may be registered more than once — or that you want to make idempotent — use app.hasDecorator(key) to check whether the key already exists before calling app.decorate. Calling app.decorate with a key that is already set throws an error, so the guard prevents accidental double-decoration in composed plugin trees.

src/plugins/metrics.ts
import { createFX, definePlugin } from '@kynetra/fx'
import { MetricsClient } from './metrics-client'
 
export const metricsPlugin = definePlugin({
name: 'metrics',
 
register(app) {
// Safe to call this plugin multiple times — only decorates once
if (!app.hasDecorator('metrics')) {
app.decorate('metrics', new MetricsClient({ endpoint: process.env.METRICS_URL }))
}
},
})

Tip

Always set app decorators before calling app.boot() or handling requests. Plugin register functions run before boot, making them the right place to call app.decorate.

Context decorators#

Context decorators attach values to the per-request ctx object rather than to the app. Register them once with app.decorateContext(key, value | factory). There are two modes:

  • Plain value — all requests share the same reference. Use this for immutable config or read-only objects where sharing is safe.
  • Factory function (ctx) => T — called fresh for each incoming request. Use this whenever the decoration holds mutable state or needs to be scoped to a single request.
src/app.ts
import { createFX } from '@kynetra/fx'
import { appConfig } from './config'
 
const app = createFX({ name: 'my-api' })
 
// Plain value — shared across all requests (safe: object is read-only)
app.decorateContext('config', appConfig)
 
// Factory — fresh object per request (necessary: holds mutable request state)
app.decorateContext('requestMeta', (ctx) => ({
startedAt: Date.now(),
ip: ctx.header('cf-connecting-ip') ?? ctx.header('x-forwarded-for') ?? 'unknown',
requestId: ctx.header('x-request-id') ?? crypto.randomUUID(),
}))
 
app.get('/status', (ctx) => {
const config = ctx.get('config')
const meta = ctx.get('requestMeta')
return ctx.json({ env: config.env, requestId: meta.requestId })
})

Warning

If you pass a mutable object (like a plain {}) as a context decoration value (not a factory), all requests share the same reference. Mutations in one request bleed into the next. Use a factory function to get isolated per-request instances.

Factory-per-request pattern#

The factory pattern is the right choice whenever the decorated value depends on data from the incoming request — a structured logger with the request ID embedded, a user object resolved from the session, or a metrics span tied to this specific request lifecycle.

src/plugins/request-logger.ts
import { createFX, definePlugin } from '@kynetra/fx'
import { createLogger, Logger } from './logger'
 
export const requestLoggerPlugin = definePlugin({
name: 'request-logger',
 
register(app) {
// Factory receives ctx so it can read request headers
app.decorateContext('logger', (ctx) =>
createLogger({
requestId: ctx.header('x-request-id') ?? crypto.randomUUID(),
method: ctx.method,
path: ctx.path,
})
)
},
})

Inside any handler or middleware, retrieve the logger with ctx.get<Logger>('logger'). Because the factory runs for every request, each request gets a logger instance that carries its own requestId — no state bleeds between concurrent requests.

src/routes/users.ts
import type { Logger } from './logger'
 
app.get('/users/:id', async (ctx) => {
const logger = ctx.get<Logger>('logger')
 
logger.info({ userId: ctx.params.id }, 'fetching user')
 
const db = app.getDecorator<DbClient>('db')
const [user] = await db.query('SELECT * FROM users WHERE id = ?', [ctx.params.id])
 
if (!user) {
logger.warn({ userId: ctx.params.id }, 'user not found')
return ctx.json({ error: 'Not found' }, { status: 404 })
}
 
logger.info({ userId: user.id }, 'user fetched')
return ctx.json(user)
})

Reading context decorations#

Use ctx.get<T>(key) to retrieve a context decoration from within a handler, middleware, or hook. The generic parameter T tells TypeScript what type to expect. Without the generic, the return type is unknown.

Context decorations are available at all stages of the request lifecycle — including preHandler hooks — because they are set up before routing begins.

src/hooks/audit-log.ts
import { createFX } from '@kynetra/fx'
import type { Logger } from './logger'
import type { CurrentUser } from './auth'
 
// Reading context decorations inside a preHandler hook
app.addHook('preHandler', async (ctx) => {
const logger = ctx.get<Logger>('logger')
const user = ctx.get<CurrentUser>('user')
 
logger.info(
{ userId: user?.id ?? 'anonymous', path: ctx.path, method: ctx.method },
'request received'
)
})
src/middleware/auth.ts
import type { Logger } from './logger'
 
// Reading in middleware — same API as in a handler
app.use(async (ctx, next) => {
const logger = ctx.get<Logger>('logger')
const token = ctx.header('authorization')?.replace('Bearer ', '')
 
if (!token) {
logger.warn('missing authorization header')
return ctx.json({ error: 'Unauthorized' }, { status: 401 })
}
 
// Resolve the user and make it available downstream
const user = await verifyToken(token)
ctx.set('user', user)
 
return next()
})

Decorators in plugins#

Plugins are the canonical home for decorators. A well-written plugin decorates the app and the context in its register function, and tears down any resources in an onClose hook. This keeps setup, usage, and teardown co-located.

src/plugins/cache-plugin.ts
import { createFX, definePlugin } from '@kynetra/fx'
import { LRUCache } from 'lru-cache'
import type { Logger } from './logger'
 
interface RequestMetrics {
cacheHits: number
cacheMisses: number
flush(): void
}
 
export const cachePlugin = definePlugin({
name: 'cache',
 
register(app) {
// 1. App-level decorator: one shared LRU cache for the process lifetime
if (!app.hasDecorator('cache')) {
const cache = new LRUCache<string, string>({ max: 1000, ttl: 1000 * 60 * 5 })
app.decorate('cache', cache)
}
 
// 2. Context decorator: per-request metrics collector using a factory
app.decorateContext('cacheMetrics', (_ctx): RequestMetrics => {
let hits = 0
let misses = 0
return {
get cacheHits() { return hits },
get cacheMisses() { return misses },
flush() {
// e.g. emit to a metrics backend here
},
}
})
 
// 3. Hook: flush per-request metrics after the response is sent
app.addHook('onSend', async (ctx) => {
const metrics = ctx.get<RequestMetrics>('cacheMetrics')
metrics.flush()
})
 
// 4. Hook: close the shared cache gracefully when the app shuts down
app.addHook('onClose', async () => {
const cache = app.getDecorator<LRUCache<string, string>>('cache')
cache.clear()
})
},
})

Register the plugin with app.register(cachePlugin) and the shared cache and per-request metrics are available everywhere without any imports or globals.

src/routes/items.ts
import type { LRUCache } from 'lru-cache'
import type { RequestMetrics } from './plugins/cache-plugin'
 
app.get('/items/:id', async (ctx) => {
const cache = app.getDecorator<LRUCache<string, string>>('cache')
const metrics = ctx.get<RequestMetrics>('cacheMetrics')
 
const cacheKey = `item:${ctx.params.id}`
const hit = cache.get(cacheKey)
 
if (hit) {
metrics.cacheHits // incremented inside the decorator
return ctx.json(JSON.parse(hit))
}
 
metrics.cacheMisses
const item = await fetchItem(ctx.params.id)
cache.set(cacheKey, JSON.stringify(item))
return ctx.json(item)
})

App vs context — when to use which#

The choice between app decorators and context decorators comes down to lifetime and isolation:

  • App decorators — created once, shared across every request for the life of the process. Use them for singletons: database clients, connection pools, configuration, SDKs, caches. Accessing them via app.getDecorator is cheap — there is no per-request overhead.
  • Context decorators (plain value) — registered once, shared reference attached to every ctx. Functionally equivalent to an app decorator but accessible via ctx.get inside handlers. Useful for read-only objects you want reachable from ctx for ergonomic reasons.
  • Context decorators (factory) — registered once, but the factory runs fresh for every incoming request. The resulting value is scoped to that request's ctx and is garbage-collected when the request finishes. Use these for anything that holds request-scoped state: loggers with embedded request IDs, open database transactions, per-request auth principals, span tracers.

Quick reference

NameTypeDescription
App decoratorsShared singletonSet once at startup. Ideal for database clients, caches, config, and SDKs that are expensive to create and safe to reuse across requests.
Context decorators (value)Shared reference on ctxSame reference on every request ctx. Use only for immutable or read-only objects — mutable shared state causes cross-request bugs.
Context decorators (factory)Per-request instanceFactory runs once per request. Ideal for loggers, transactions, principals, and any state that must be isolated per request.

API reference#

App decorator methods

NameTypeDescription
app.decorate(key, value)voidAttaches value to the app under key. Throws if key is already set — use app.hasDecorator first to guard against double-decoration.
app.getDecorator<T>(key)T | undefinedRetrieves a previously decorated value typed as T. Returns undefined if the key has not been set.
app.hasDecorator(key)booleanReturns true if a value has been decorated under key. Use in plugins to make decoration idempotent.

Context decorator methods

NameTypeDescription
app.decorateContext(key, value | factory)voidRegisters a context decoration. Pass a plain value to share one reference across all requests, or a factory (ctx) => T to produce a fresh instance per request.
ctx.get<T>(key)TRetrieves the context-decorated value for this request, typed as T. Available in handlers, middleware, and hooks.
  • Plugins — the primary place to register decorators alongside hooks and routes.
  • Hooks — use decorated values at each stage of the request lifecycle.
  • Context — the full ctx API, including ctx.get and ctx.set.
Decorators · Kynetra FX