Kynetra FX

Documentation

Lifecycle Hooks

Kynetra FX exposes six hook points across the request lifecycle and the application lifecycle. Hooks are registered with app.hook(name, fn) and run in registration order — predictable, sequential, no magic wrapping.

Request lifecycle order#

Every inbound request travels through the following stages. Each stage has a corresponding hook point where you can observe or short-circuit the flow.

// Request lifecycle (simplified)
//
// onRequest — runs before routing; can short-circuit
// ↓
// [router] — matches path + method, extracts params
// ↓
// preHandler — runs after routing; params available; can short-circuit
// ↓
// [handler] — your route handler runs
// ↓
// postHandler — runs after handler returns; can replace the response
//
// If any stage throws → onError runs

Returning a Response from onRequest or preHandler skips all remaining stages and sends that response immediately. The handler and postHandler do not run.

Hook reference#

NameTypeDescription
onRequest(ctx) => void | ResponseRuns before routing. Return a Response to short-circuit the request entirely.
preHandler(ctx) => void | ResponseRuns after routing and param extraction, before the handler. Return a Response to short-circuit.
postHandler(ctx, res: Response) => void | ResponseRuns after the handler returns. Return a new Response to replace the handler's response.
onError(ctx, err: unknown) => void | ResponseRuns when any error is thrown. Return a Response to override the default error body.
onBoot(app) => void | Promise<void>Runs once on first request (or explicit app.boot()). Awaited before requests are processed.
onClose(app) => void | Promise<void>Runs on app.close(). Use for graceful shutdown: closing DB connections, flushing queues.
onRegister(app, spec) => voidRuns each time a plugin is registered. Useful for diagnostics and validation.

onRequest#

onRequest is the earliest hook point — it fires before the router has matched the request. Route params are not yet available, but you have access to the full Request object via ctx.request. Returning a Response short-circuits the entire pipeline.

Common use cases: IP allowlist/blocklist enforcement, global authentication bypass, request ID injection, early rate-limit checks.

src/index.ts
const BLOCKED_IPS = new Set(['192.0.2.1', '203.0.113.5'])
 
app.hook('onRequest', (ctx) => {
const ip = ctx.request.headers.get('cf-connecting-ip') ?? ''
if (BLOCKED_IPS.has(ip)) {
return new Response('Forbidden', { status: 403 })
}
})
 
// Assign a request ID that flows through the whole lifecycle.
app.hook('onRequest', (ctx) => {
const id = crypto.randomUUID()
ctx.set('requestId', id) // requires app.decorateContext('requestId', ...)
})

preHandler#

preHandler fires after the router has matched a route and extracted path parameters, but before the handler runs. This makes it the right place for per-route concerns that need to know which resource is being accessed.

Common use cases: authentication (verifying a JWT), authorization (checking that the authenticated user may access this resource), tenant resolution from a subdomain or path segment.

src/hooks/auth.ts
import { verifyJwt } from './jwt'
 
app.hook('preHandler', async (ctx) => {
// Public routes skip auth entirely.
const url = new URL(ctx.request.url)
if (url.pathname.startsWith('/public')) return
 
const authHeader = ctx.request.headers.get('Authorization') ?? ''
const token = authHeader.replace(/^Bearer /, '')
 
const payload = await verifyJwt(token, ctx.env.JWT_SECRET)
if (!payload) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
 
// Expose the verified user to the handler via context.
ctx.set('user', payload)
})

postHandler#

postHandler receives both the context and the Response the handler produced. Return a new Response to replace it, or return nothing to let the original response pass through.

Common use cases: adding security headers to every response, stamping a request-ID header, writing audit log entries after the handler has decided what to return.

src/hooks/headers.ts
// Stamp every response with a request ID and security headers.
app.hook('postHandler', (ctx, res) => {
const headers = new Headers(res.headers)
headers.set('X-Request-Id', ctx.get('requestId'))
headers.set('X-Content-Type-Options', 'nosniff')
headers.set('X-Frame-Options', 'DENY')
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
 
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
})
})
 
// Audit log — we only write after the handler succeeds.
app.hook('postHandler', async (ctx, res) => {
if (res.status < 400) {
await ctx.env.AUDIT_QUEUE.send({
path: new URL(ctx.request.url).pathname,
method: ctx.request.method,
status: res.status,
user: ctx.get('user')?.sub,
ts: Date.now(),
})
}
})

onError#

When a handler or hook throws, Kynetra FX calls all registered onError hooks in registration order. Return a Response to override the default error format. The thrown value is passed as err — check its code property if it is an FXError to branch on known error types.

src/hooks/error.ts
app.hook('onError', (ctx, err) => {
// FXError carries a machine-readable code.
const isFXError = err !== null && typeof err === 'object' && 'code' in err
 
if (isFXError) {
const fxErr = err as { code: string; message: string; statusCode?: number }
 
if (fxErr.code === 'FX_VALIDATION_ERROR') {
return Response.json(
{ error: 'Validation failed', detail: fxErr.message },
{ status: 422 }
)
}
 
if (fxErr.code === 'FX_NOT_FOUND') {
return Response.json({ error: 'Not found' }, { status: 404 })
}
 
return Response.json(
{ error: fxErr.message, code: fxErr.code },
{ status: fxErr.statusCode ?? 500 }
)
}
 
// Unknown error — log it and return a generic 500.
console.error('[fx] unhandled error', err)
return Response.json({ error: 'Internal Server Error' }, { status: 500 })
})

onBoot and onClose#

onBoot runs exactly once — either lazily on the first request, or immediately when you call app.boot() explicitly. It is awaited before the first request is processed, making it safe to open database connections, preload caches, or validate configuration here.

onClose is the mirror: it runs on app.close() and is the canonical place for graceful-shutdown logic.

src/hooks/db.ts
import { createPool } from './db/pool'
 
let pool: ReturnType<typeof createPool>
 
app.hook('onBoot', async (app) => {
pool = createPool({
connectionString: app.env.DATABASE_URL,
maxConnections: 10,
})
await pool.connect() // verify connectivity at startup
app.decorate('db', pool)
console.log('[fx] database pool connected')
})
 
app.hook('onClose', async (app) => {
await pool?.end()
console.log('[fx] database pool closed')
})

Calling app.boot() more than once is safe — subsequent calls are no-ops.

// Explicitly boot before running tests.
await app.boot()
 
// Tear down after the test suite.
afterAll(() => app.close())

Sequential execution#

Hooks of the same name run in registration order — one after another, not wrapped around each other. This is not an onion model. There is no "next()" to call and no way to run code both before and after the next hook from a single registration.

For onRequest and preHandler, the first hook to return a Response wins. Remaining hooks of the same type do not run.

app.hook('onRequest', (ctx) => {
console.log('hook A') // runs first
})
 
app.hook('onRequest', (ctx) => {
console.log('hook B') // runs second
return new Response('blocked', { status: 403 }) // short-circuits here
})
 
app.hook('onRequest', (ctx) => {
console.log('hook C') // never runs — B already returned a Response
})

This makes execution order explicit and easy to reason about. If you need both hooks to run regardless, neither should return a Response — accumulate state and let the final hook decide.

Hooks inside plugins#

The recommended way to share hook logic is to encapsulate it in a plugin. The plugin's register function calls app.hook(), so the hook is installed once per app — not once per import — and its dependencies are managed by the plugin system.

src/plugins/request-logger.ts
import { definePlugin } from '@kynetra/fx'
 
export const requestLoggerPlugin = definePlugin({
name: 'requestLogger',
 
register(app) {
app.hook('onRequest', (ctx) => {
const { method, url } = ctx.request
console.log('[req]', method, new URL(url).pathname)
})
 
app.hook('postHandler', (ctx, res) => {
const { method, url } = ctx.request
console.log('[res]', method, new URL(url).pathname, res.status)
})
 
app.hook('onError', (ctx, err) => {
console.error('[err]', ctx.request.method, new URL(ctx.request.url).pathname, err)
})
},
})
src/index.ts
import { createApp } from '@kynetra/fx'
import { requestLoggerPlugin } from './plugins/request-logger'
 
const app = createApp()
app.register(requestLoggerPlugin) // hooks registered once, automatically
 
app.get('/hello', (ctx) => ctx.text('Hello, world!'))
 
return app

Note

See Plugins for how to compose hooks with definePlugin and dependencies. See Decorators for attaching values to the app or context that hooks can read. See Contracts for validating hook signatures at the type level.