Documentation
Plugins
Plugins are self-contained units of functionality — middleware, decorators, hooks, routes — that register against the app. The dependency system ensures boot order and validates that required plugins are present before your first request is served.
Registering a plugin#
Call app.register(plugin, options?) to add a plugin to your app. You can pass the result of definePlugin, or an inline function that receives the FXApp instance directly. Options are forwarded to the plugin's register function.
import { createApp } from '@kynetra/fx'import { cachePlugin } from './plugins/cache' const app = createApp() // register a definePlugin resultapp.register(cachePlugin, { ttl: 60 }) // register an inline functionapp.register((app) => { app.decorate('startedAt', Date.now())}) return appDefining a plugin#
Use definePlugin — imported from @kynetra/fx or @kynetra/fx-plugin — to create a reusable plugin spec. The object accepts a name, an optional dependencies array, and a register function. The register function receives the FXApp instance and the options object passed at registration time.
import { definePlugin } from '@kynetra/fx' interface CacheOptions { ttl: number} export const cachePlugin = definePlugin({ name: 'cache', async register(app, options: CacheOptions) { const store = new Map<string, { value: unknown; expires: number }>() app.decorate('cache', { get(key: string) { const entry = store.get(key) if (!entry || Date.now() > entry.expires) return undefined return entry.value }, set(key: string, value: unknown) { store.set(key, { value, expires: Date.now() + options.ttl * 1000 }) }, }) app.hook('onClose', async () => { store.clear() }) },})The register function may be async. Kynetra FX awaits it during boot, so you can open database connections, fetch remote config, or perform any async initialization inside it.
Dependencies#
Declare dependencies: string[] to name other plugins that must be registered before yours. At boot time, Kynetra FX validates the dependency graph. If a listed plugin is absent, app.boot() throws an error with code FX_PLUGIN_MISSING_DEPENDENCY.
import { definePlugin } from '@kynetra/fx' export const rateLimiterPlugin = definePlugin({ name: 'rateLimiter', dependencies: ['logger', 'cache'], // both must be registered first register(app) { const logger = app.getDecorator('logger') const cache = app.getDecorator('cache') app.hook('onRequest', (ctx) => { const key = 'rl:' + ctx.request.headers.get('cf-connecting-ip') const count = (cache.get(key) as number | undefined) ?? 0 if (count >= 100) { logger.warn('rate limit exceeded', { key }) return new Response('Too Many Requests', { status: 429 }) } cache.set(key, count + 1) }) },})If you register rateLimiterPlugin without first registering logger or cache, app.boot() throws:
// Error: FX_PLUGIN_MISSING_DEPENDENCY// Plugin "rateLimiter" requires "cache" but it is not registered.Boot lifecycle#
app.boot() is called lazily on the first incoming request, but you can trigger it explicitly — for example, in test setup or to catch misconfiguration at startup rather than on the first real request.
import { createApp } from '@kynetra/fx'import { loggerPlugin } from './plugins/logger'import { cachePlugin } from './plugins/cache' const app = createApp() app.register(loggerPlugin)app.register(cachePlugin, { ttl: 30 }) // Explicit boot — errors surface immediately, not on first request.await app.boot() return appCall app.close() to run all onClose hooks in registration order. This is the right place to drain queues, flush telemetry, and close open connections.
// Graceful shutdown in a Cloudflare WorkeraddEventListener('beforeunload', async () => { await app.close()})Inspecting plugins#
app.plugins() returns the list of registered plugin specs. Each entry is the object passed to definePlugin. This is useful for diagnostics, admin endpoints, or verifying that a required plugin was loaded.
const specs = app.plugins()console.log(specs.map((s) => s.name))// ['logger', 'cache', 'rateLimiter']You can also use this in tests to assert that a plugin registered correctly before running integration scenarios.
import { describe, it, expect } from 'vitest'import { app } from '../src' describe('app plugins', () => { it('registers the cache plugin', async () => { await app.boot() const names = app.plugins().map((p) => p.name) expect(names).toContain('cache') })})onRegister hook#
The onRegister hook fires synchronously each time a plugin is registered. It receives the FXApp instance and the plugin spec. Use it for audit logging, enforcing naming conventions, or preventing duplicate registrations.
app.hook('onRegister', (app, spec) => { console.log('[fx] plugin registered:', spec.name)}) // Now register your plugins — each registration triggers the hook.app.register(loggerPlugin)// [fx] plugin registered: logger app.register(cachePlugin, { ttl: 60 })// [fx] plugin registered: cacheBecause onRegister fires at registration time (before boot), it is called for plugins added after the hook itself is set up. Hooks added after a plugin has already been registered will not fire retroactively for that plugin.
Full plugin example — rateLimiter#
This is a complete, self-contained rate-limiter plugin. It declares a dependency on the logger plugin, decorates the app with a limiter helper, and installs an onRequest hook that enforces a per-IP request budget.
import { definePlugin } from '@kynetra/fx' interface RateLimiterOptions { maxRequests: number // per window windowMs: number // milliseconds} export const rateLimiterPlugin = definePlugin({ name: 'rateLimiter', dependencies: ['logger'], register(app, options: RateLimiterOptions) { const { maxRequests = 100, windowMs = 60_000 } = options ?? {} const counters = new Map<string, { count: number; resetAt: number }>() const logger = app.getDecorator<{ warn(msg: string, meta?: unknown): void }>('logger') // Expose a helper so handlers can query the current count. app.decorate('rateLimit', { remaining(ip: string) { const entry = counters.get(ip) if (!entry || Date.now() > entry.resetAt) return maxRequests return Math.max(0, maxRequests - entry.count) }, }) app.hook('onRequest', (ctx) => { const ip = ctx.request.headers.get('cf-connecting-ip') ?? 'unknown' const now = Date.now() let entry = counters.get(ip) if (!entry || now > entry.resetAt) { entry = { count: 0, resetAt: now + windowMs } counters.set(ip, entry) } entry.count++ if (entry.count > maxRequests) { logger.warn('rate limit exceeded', { ip, count: entry.count }) return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(Math.ceil((entry.resetAt - now) / 1000)) }, }) } }) // Drain counters on close to free memory. app.hook('onClose', async () => { counters.clear() }) },})import { createApp } from '@kynetra/fx'import { loggerPlugin } from './plugins/logger'import { rateLimiterPlugin } from './plugins/rate-limiter' const app = createApp() app.register(loggerPlugin)app.register(rateLimiterPlugin, { maxRequests: 200, windowMs: 60_000 }) app.get('/status', (ctx) => { const limiter = ctx.get('rateLimit') return ctx.json({ remaining: limiter.remaining(ctx.ip ?? 'unknown') })}) return appTip
app.route(), app.use(), app.hook(), app.decorate(), and app.decorateContext() — anything you'd do at the top level. This makes plugins a natural home for any cross-cutting concern: auth, logging, caching, rate limiting, request tracing, or feature flags.