Kynetra FX

Documentation

Migrating from Fastify

Fastify and Kynetra FX share a number of structural ideas: plugins, decorators, lifecycle hooks, and schema validation are first-class in both. The main shift is runtime target — Fastify is Node.js-native and JSON Schema compiler-heavy; Kynetra FX is edge-native and built on Web Standards (Request/Response/Headers/URL). Handlers return a Response via ctx.json() instead of calling reply.send(). Lifecycle hooks carry over almost name-for-name and plugins use app.register + definePlugin.

Concept Mapping#

The table below maps every common Fastify API to its Kynetra FX equivalent. Hooks and decorators are especially close — most are direct renames.

NameTypeDescription
fastify(opts)createFX({ name, runtime })Factory function with explicit name and runtime.
fastify.get/post/...app.get/post/...Same methods, same path syntax.
(request, reply) => ...(ctx) => ...Single context; no separate reply object.
reply.send(data)return ctx.json(data)Must return, not call.
reply.code(201).send(data)return ctx.json(data, { status: 201 })Status in second argument init object.
reply.redirect(url)return ctx.redirect(url, status?)Optional status code.
request.params.idctx.params.idSame property path.
request.query.qctx.query('q')Method call; no args returns all params.
request.bodyawait ctx.jsonBody<T>()Explicit async read; no auto-parse.
request.headers['x-foo']ctx.header('x-foo')Request header reader.
reply.header('X-Foo', val)ctx.set('X-Foo', val)Sets a response header.
fastify.decorate('key', val)app.decorate('key', val)App-level instance decoration.
fastify.decorateRequest('key', val)app.decorateContext('key', val)Per-request context decoration.
fastify.addHook('onRequest', fn)app.hook('onRequest', fn)Request lifecycle hook.
fastify.addHook('preHandler', fn)app.hook('preHandler', fn)Pre-handler lifecycle hook.
fastify.addHook('onError', fn)app.hook('onError', fn)Error lifecycle hook.
fastify.addHook('onClose', fn)app.hook('onClose', fn)Shutdown lifecycle hook.
fastify.addHook('onReady', fn)app.hook('onBoot', fn)Boot hook; runs once before first request.
fastify.register(plugin, opts)app.register(plugin, opts)Same API shape.
fp(async (fastify, opts) => {...})definePlugin({ name, register(app, opts) })Plugin factory from @kynetra/fx-plugin.
schema: { body: {...} } (JSON Schema)app.route({ input: fx.object({...}) })fx schemas or any Standard Schema library.
prefix in registerapp.group('/prefix', fn)Inline group builder replaces prefix option.
fastify.listen({ port })serve(app, { port }) via @kynetra/fx-nodeNode.js runtime adapter.
fastify.setErrorHandler(fn)app.hook('onError', fn)Error hook; return a Response to override.

App Setup#

Fastify is instantiated with fastify(opts) and started with await fastify.listen(). Kynetra FX uses a factory function and a separate runtime adapter. The logger: true Fastify option is replaced by the logger() built-in middleware.

before.ts
import Fastify from 'fastify'
const fastify = Fastify({ logger: true })
await fastify.listen({ port: 3000 })
after.ts
// Node.js
import { createFX, logger } from '@kynetra/fx'
import { serve } from '@kynetra/fx-node'
 
const app = createFX({ name: 'api', runtime: 'node' })
app.use(logger())
serve(app, { port: 3000 })
 
// Cloudflare Workers
import { createFX, cloudflare } from '@kynetra/fx'
const app = createFX({ name: 'api', runtime: 'cloudflare' })
return cloudflare(app)

Basic Routes and Params#

Route method calls are identical. The main change is the handler signature — instead of (request, reply), you receive a single ctx object and return a response. Params are accessed via ctx.params without any cast, and query strings use a method call.

before.ts
fastify.get('/users/:id', async (request, reply) => {
const { id } = request.params as { id: string }
return reply.send({ id })
})
 
fastify.get('/search', async (request, reply) => {
const { q } = request.query as { q: string }
return reply.send({ q })
})
after.ts
app.get('/users/:id', (ctx) => {
return ctx.json({ id: ctx.params.id })
})
 
app.get('/search', (ctx) => {
return ctx.json({ q: ctx.query('q') })
})

Middleware and Hooks#

Fastify uses fastify.addHook for lifecycle events and separates middleware from hooks. Kynetra FX unifies these: app.use() is for onion-model middleware and app.hook() is for lifecycle events. Request-lifecycle hooks run in registration order (sequential, not onion).

before.ts
fastify.addHook('onRequest', async (request, reply) => {
console.log('incoming', request.url)
})
 
fastify.addHook('preHandler', async (request, reply) => {
// runs before each route handler
})
after.ts
import { logger, requestId } from '@kynetra/fx'
 
app.use(logger())
app.use(requestId())
 
app.hook('onRequest', async (ctx) => {
console.log('incoming', ctx.url.pathname)
})
 
app.hook('preHandler', async (ctx) => {
// runs before each route handler
})

Body Handling#

Fastify auto-parses request.body for JSON content types. Kynetra FX reads the body explicitly via ctx.jsonBody() — this is by design since Request.body is a stream that can only be consumed once, and speculative parsing would break non-JSON routes.

before.ts
fastify.post('/users', async (request, reply) => {
const { name, email } = request.body as { name: string; email: string }
return reply.code(201).send({ name, email })
})
after.ts
app.post('/users', async (ctx) => {
const { name, email } = await ctx.jsonBody<{ name: string; email: string }>()
return ctx.json({ name, email }, { status: 201 })
})

Decorators#

Fastify uses fastify.decorate to attach values to the instance and fastify.decorateRequest to attach per-request values. Kynetra FX mirrors this with app.decorate (instance-level, read with app.getDecorator) and app.decorateContext (per-request, read with ctx.get). Per-request context values can also be set inline with ctx.decorate.

before.ts
fastify.decorate('config', { version: '1.0' })
fastify.decorateRequest('user', null)
 
fastify.addHook('preHandler', async (request) => {
request.user = await resolveUser(request.headers.authorization)
})
 
fastify.get('/me', async (request, reply) => {
return reply.send(request.user)
})
after.ts
app.decorate('config', { version: '1.0' })
app.decorateContext('user', null)
 
app.hook('preHandler', async (ctx) => {
ctx.decorate('user', await resolveUser(ctx.header('authorization')))
})
 
app.get('/me', (ctx) => {
return ctx.json(ctx.get('user'))
})

Plugins#

Fastify uses fastify-plugin (fp) to bypass encapsulation and share decorations across the tree. Kynetra FX uses definePlugin from @kynetra/fx-plugin. The dependency system validates that required plugins are registered before boot.

before.ts
import fp from 'fastify-plugin'
 
const myPlugin = fp(async (fastify, opts) => {
fastify.decorate('db', createDb(opts.url))
 
fastify.addHook('onClose', async () => {
await fastify.db.close()
})
})
 
await fastify.register(myPlugin, { url: process.env.DB_URL })
after.ts
import { definePlugin } from '@kynetra/fx-plugin'
 
const dbPlugin = definePlugin({
name: 'db',
async register(app, opts) {
app.decorate('db', createDb(opts.url))
 
app.hook('onClose', async () => {
await app.getDecorator('db').close()
})
},
})
 
app.register(dbPlugin, { url: process.env.DB_URL })

Plugins with dependencies declared in definePlugin are validated at boot — a missing dependency throws FX_PLUGIN_MISSING_DEPENDENCY before the first request is served.

Route Groups (Prefix)#

Fastify uses fastify.register(fn, { prefix }) to scope routes under a path prefix. Kynetra FX uses app.group('/prefix', fn), which is more explicit and avoids the async registration ceremony. Middleware added inside a group is scoped to that group and its children only.

before.ts
fastify.register(
async (instance) => {
instance.get('/users', async (req, reply) => reply.send([]))
instance.post('/users', async (req, reply) => reply.code(201).send({}))
},
{ prefix: '/api' }
)
after.ts
app.group('/api', (api) => {
api.get('/users', (ctx) => ctx.json([]))
api.post('/users', (ctx) => ctx.json({}, { status: 201 }))
})
 
// Nested groups with scoped middleware
app.group('/api/v2', (v2) => {
v2.use(async (ctx, next) => {
ctx.set('X-API-Version', '2')
await next()
})
v2.group('/users', (users) => {
users.get('/:id', (ctx) => ctx.json({ id: ctx.params.id }))
})
})

Validation#

Fastify compiles JSON Schema into an Ajv validator at startup. Kynetra FX uses the app.route() contract API with the fx schema builder or any Standard Schema-compatible library (Zod, Valibot, ArkType). The validated body is available as typed ctx.input; validated query params as ctx.validatedQuery. No separate schema compiler is needed.

before.ts
fastify.post('/issues', {
schema: {
body: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string', minLength: 1 },
priority: { type: 'string' },
},
},
},
handler: async (request, reply) => {
return reply.code(201).send(request.body)
},
})
after.ts
import { createFX, fx } from '@kynetra/fx'
 
// Built-in fx schema builder
app.route({
method: 'POST',
path: '/issues',
input: fx.object({
title: fx.string().min(1),
priority: fx.optional(fx.string()),
}),
handler: (ctx) => ctx.json(ctx.input, { status: 201 }),
})
 
// Standard Schema compatible — Zod works without an adapter
import { z } from 'zod'
 
app.route({
method: 'POST',
path: '/issues',
input: z.object({
title: z.string().min(1),
priority: z.string().optional(),
}),
handler: (ctx) => ctx.json(ctx.input, { status: 201 }),
})

Query string validation works the same way via the query field:

after.ts
app.route({
method: 'GET',
path: '/search',
query: fx.object({ q: fx.optional(fx.string()) }),
handler: (ctx) => ctx.json({ q: ctx.validatedQuery.q }),
})

Error Handling#

Fastify uses fastify.setErrorHandler for global error handling. Kynetra FX uses the same onError hook that also handles per-route errors. Throw an FXError from any handler for a structured, status-bearing error.

before.ts
fastify.setErrorHandler(async (error, request, reply) => {
reply.code(error.statusCode ?? 500).send({ error: error.message })
})
after.ts
import { FXError } from '@kynetra/fx'
 
app.hook('onError', (err, ctx) => {
const status = err instanceof FXError ? err.status : 500
return ctx.json({ error: err.message }, { status })
})

Lifecycle Hook Reference#

Most Fastify lifecycle hooks map directly to Kynetra FX hook names. The only rename is onReady to onBoot. Hook handlers receive (ctx) instead of (request, reply) — there is no separate reply object; return a Response from the hook to short-circuit.

NameTypeDescription
onRequestonRequest(ctx) => void | Response — runs at the start of each request.
preHandlerpreHandler(ctx) => void | Response — runs before the route handler.
postHandlerpostHandler(ctx, res) => void | Response — runs after the handler.
onErroronError(err, ctx) => void | Response — handles errors from handlers.
onCloseonClose(app) => void | Promise<void> — shutdown cleanup.
onReady / bootonBoot(app) => void | Promise<void> — runs once before first request.
onRegisteronRegister(app, spec) => void — fires when a plugin is registered.

Deploy / Entry Point#

Fastify binds to a port with fastify.listen(). Kynetra FX apps expose a standard fetch handler and are served via runtime adapters. The same application code targets Node.js, Cloudflare Workers, Bun, and Deno.

before.ts
await fastify.listen({ port: 3000, host: '0.0.0.0' })
after.ts
// Node.js
import { serve } from '@kynetra/fx-node'
serve(app, { port: 3000 })
 
// Cloudflare Workers
return cloudflare(app)
 
// Bun
return { fetch: app.fetch.bind(app) }
 
// Direct fetch — for tests or any WinterCG runtime
const res = await app.fetch(new Request('http://localhost/users/1'))

Warning

No automatic body parsing. Fastify parses request.body automatically for JSON content types. Kynetra FX requires an explicit await ctx.jsonBody() call. Forgetting this is the most common migration mistake — if your handler receives undefined instead of the expected body, this is almost certainly the cause.

Migration Checklist#

  • Replace fastify() with createFX({ name, runtime })
  • Replace (request, reply) with (ctx) in all handlers
  • Replace reply.send(data) with return ctx.json(data)
  • Replace reply.code(N).send(data) with return ctx.json(data, { status: N })
  • Replace request.params.id with ctx.params.id
  • Replace request.query.q with ctx.query('q')
  • Replace request.body with await ctx.jsonBody<T>()
  • Replace request.headers['x-foo'] with ctx.header('x-foo')
  • Replace reply.header('X-Foo', val) with ctx.set('X-Foo', val)
  • Replace fastify.decorate with app.decorate
  • Replace fastify.decorateRequest with app.decorateContext
  • Replace fastify.addHook(name, fn) with app.hook(name, fn)
  • Replace fp(plugin) wrapping with definePlugin({ name, register })
  • Replace JSON Schema body validation with app.route({ input: fx.object({...}) })
  • Replace prefix register groups with app.group('/prefix', fn)
  • Replace fastify.setErrorHandler with app.hook('onError', fn)
  • Replace fastify.listen() with serve(app, opts) or cloudflare(app)

Further Reading

See Hooks for the full lifecycle hook API, Decorators for app and context decorations, Contracts for typed validation, Groups for prefix scoping, and Cloudflare for the Workers adapter.