Kynetra FX

Documentation

Migrating from Express

Express was designed for Node.js in 2010. Kynetra FX is built for the modern Web Platform — Cloudflare Workers, Bun, Deno, and Node.js alike. The biggest mental shift is moving from mutation to return: instead of calling res.send() and mutating the response object, your handler returns a Response value via ctx.json(), ctx.text(), and friends. Everything else — routing, middleware chains, groups, error handling — maps cleanly.

Concept Mapping#

Every common Express pattern has a direct Kynetra FX equivalent. The most important structural difference is that req and res are merged into a single ctx object, and handlers return a response rather than writing to one.

NameTypeDescription
express()createFX({ name, runtime })Factory function; name and runtime are required.
(req, res, next) => ...(ctx, next) => ...Single context object merges req + res.
res.json(data)return ctx.json(data, init?)Must return the response, not mutate.
res.send(str)return ctx.text(str)Plain-text response.
res.status(201).json(data)return ctx.json(data, { status: 201 })Status is passed in the second argument init object.
res.redirect(url)return ctx.redirect(url, status?)Optional status code (default 302).
req.params.idctx.params.idSame property path, different object.
req.query.qctx.query('q')Method call, not a property. No args returns all params.
req.body (after body-parser)await ctx.jsonBody<T>()Explicit async read; no global body-parser needed.
req.headers['x-foo']ctx.header('x-foo')Request header reader.
res.set('X-Foo', val)ctx.set('X-Foo', val)Sets a response header.
res.append('X-Foo', val)ctx.append('X-Foo', val)Appends to a multi-value response header.
express.Router()app.group('/prefix', fn)Inline group builder replaces the Router object.
app.use("/path", router)app.group('/path', fn)Prefix mounting via group.
(err, req, res, next) => ...app.hook('onError', (err, ctx) => ...)Error hook; return a Response to override.
app.listen(port)serve(app, { port }) via @kynetra/fx-nodeRuntime adapter for Node.js.
res.cookie(name, val)ctx.setCookie(name, val, opts)No cookie-parser package required.
req.cookies.namectx.cookie(name)Reads a request cookie by name.

App Setup#

Express apps call express(), optionally register express.json()for body parsing, and start with app.listen(). Kynetra FX uses a factory function and a separate runtime adapter. There is no global body-parser — bodies are read on demand with ctx.jsonBody().

before.ts
import express from 'express'
const app = express()
app.use(express.json())
app.listen(3000, () => console.log('listening on :3000'))
after.ts
// Node.js
import { createFX } from '@kynetra/fx'
import { serve } from '@kynetra/fx-node'
 
const app = createFX({ name: 'api', runtime: 'node' })
serve(app, { port: 3000 })
 
// Cloudflare Workers
import { createFX, cloudflare } from '@kynetra/fx'
const app = createFX({ name: 'api', runtime: 'cloudflare' })
return cloudflare(app)

Tip

No body-parser is needed. ctx.jsonBody(), ctx.formBody(), and ctx.textBody() are built in and read the Request body directly from the Web Streams API.

Basic Routes and Params#

Route methods map one-to-one: app.get, app.post, app.put, app.patch, app.delete. Params are accessed the same way — ctx.params.id — but query strings use a method call rather than a property.

before.ts
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id })
})
 
app.get('/search', (req, res) => {
const q = req.query.q
res.json({ 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#

Express middleware uses the three-argument (req, res, next) pattern. Kynetra FX middleware uses (ctx, next). The onion model is the same — code before await next() runs on the way in, code after runs on the way out. The key difference: next() is always async in Kynetra FX and must be awaited when you need to run code after downstream handlers.

before.ts
app.use((req, res, next) => {
console.log(req.method, req.url)
next()
})
 
function requireLogin(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}
 
app.get('/dashboard', requireLogin, (req, res) => res.json({ ok: true }))
after.ts
import { logger, requestId } from '@kynetra/fx'
 
app.use(logger())
app.use(requestId())
 
// Custom global middleware
app.use(async (ctx, next) => {
console.log(ctx.req.method, ctx.url.pathname)
await next()
})
 
// Inline per-route middleware
const requireLogin = async (ctx, next) => {
if (!ctx.header('authorization')) {
return ctx.json({ error: 'Unauthorized' }, { status: 401 })
}
return next()
}
 
app.get('/dashboard', requireLogin, (ctx) => ctx.json({ ok: true }))

Body Handling#

Express requires express.json() or body-parser to populate req.body. Kynetra FX reads the body explicitly via helper methods — one call per request since the underlying stream can only be consumed once.

before.ts
app.post('/users', (req, res) => {
const { name, email } = req.body
res.status(201).json({ name, email })
})
 
app.post('/upload', express.urlencoded({ extended: true }), (req, res) => {
res.json(req.body)
})
after.ts
app.post('/users', async (ctx) => {
const { name, email } = await ctx.jsonBody()
return ctx.json({ name, email }, { status: 201 })
})
 
app.post('/upload', async (ctx) => {
const form = await ctx.formBody()
return ctx.json(Object.fromEntries(form.entries()))
})

Route Groups#

Express uses express.Router() to create a sub-router, then mounts it with app.use('/prefix', router). Kynetra FX replaces this with app.group('/prefix', fn), which is an inline builder that keeps the hierarchy clear and enables scoped middleware without a separate file.

before.ts
const userRouter = express.Router()
userRouter.get('/', (req, res) => res.json([]))
userRouter.post('/', (req, res) => res.status(201).json({}))
userRouter.get('/:id', (req, res) => res.json({ id: req.params.id }))
app.use('/users', userRouter)
after.ts
app.group('/users', (users) => {
users.get('/', (ctx) => ctx.json([]))
users.post('/', (ctx) => ctx.json({}, { status: 201 }))
users.get('/:id', (ctx) => ctx.json({ id: ctx.params.id }))
})

Groups can be nested and carry their own middleware:

after.ts
app.group('/api/v1', (v1) => {
v1.use(async (ctx, next) => {
ctx.set('X-API-Version', '1')
await next()
})
 
v1.group('/users', (users) => {
users.get('/:id', (ctx) => ctx.json({ id: ctx.params.id }))
})
})

Validation#

Express has no built-in validation — the typical pattern is manual checks or third-party middleware. Kynetra FX has first-class contract support. Define the expected shape on the route and the validated, typed value is available as ctx.input. Validation failures automatically return a 422 before your handler runs.

before.ts
app.post('/issues', (req, res) => {
if (!req.body.title) {
return res.status(400).json({ error: 'title required' })
}
res.status(201).json(req.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) }),
handler: (ctx) => ctx.json(ctx.input, { status: 201 }),
})

Error Handling#

Express identifies error handlers by the four-argument signature (err, req, res, next) and it must be registered last. Kynetra FX uses the hook system — register onError anywhere and return a Response to override the default structured error body. You can throw an FXError from any handler to produce a structured error response.

before.ts
// Must be last in the middleware chain
app.use((err, req, res, next) => {
console.error(err)
res.status(500).json({ error: err.message })
})
after.ts
import { FXError } from '@kynetra/fx'
 
app.hook('onError', (err, ctx) => {
console.error(err)
if (err instanceof FXError) {
return ctx.json({ error: err.message }, { status: err.status })
}
return ctx.json({ error: 'Internal server error' }, { status: 500 })
})
 
// Throw structured errors from any handler
app.get('/users/:id', async (ctx) => {
const user = await db.find(ctx.params.id)
if (!user) throw new FXError(404, 'USER_NOT_FOUND', 'User not found')
return ctx.json(user)
})

Cookies#

Express requires the cookie-parser package to read cookies and uses res.cookie() to set them. Kynetra FX has cookie support built in — no extra package needed.

before.ts
import cookieParser from 'cookie-parser'
app.use(cookieParser())
 
app.get('/me', (req, res) => {
const token = req.cookies.token
res.cookie('session', 'abc', { httpOnly: true, secure: true })
res.json({ token })
})
after.ts
app.get('/me', (ctx) => {
const token = ctx.cookie('token')
ctx.setCookie('session', 'abc', { httpOnly: true, secure: true })
return ctx.json({ token })
})

Deploy / Entry Point#

Express calls app.listen() directly. Kynetra FX apps expose a standard fetch handler and are served via the appropriate runtime adapter. The same application code works on Node.js, Cloudflare Workers, Bun, and Deno without changes.

before.ts
app.listen(3000, () => console.log('Server on :3000'))
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 — useful in tests
const response = await app.fetch(new Request('http://localhost/users/42'))

Warning

Handlers must return a Response. There is no res.send() to call. If a handler returns undefined, Kynetra FX treats it as an unmatched route and returns a 404. Always return ctx.json(...), return ctx.text(...), or another response helper. This is the single most common mistake when migrating from Express.

Migration Checklist#

  • Replace express() with createFX({ name, runtime })
  • Remove express.json() and body-parser — use await ctx.jsonBody()
  • Remove cookie-parser — use ctx.cookie() and ctx.setCookie()
  • Change (req, res) signatures to (ctx) that return a response
  • Replace res.json(data) with return ctx.json(data)
  • Replace res.status(N).json(data) with return ctx.json(data, { status: N })
  • Replace res.send(str) with return ctx.text(str)
  • Replace res.redirect(url) with return ctx.redirect(url)
  • Replace req.params.id with ctx.params.id
  • Replace req.query.q with ctx.query('q')
  • Replace req.body with await ctx.jsonBody()
  • Replace req.headers['x-foo'] with ctx.header('x-foo')
  • Replace res.set('X-Foo', val) with ctx.set('X-Foo', val)
  • Replace express.Router() and app.use('/path', router) with app.group('/path', fn)
  • Replace four-argument error middleware with app.hook('onError', fn)
  • Replace res.cookie / req.cookies with ctx.setCookie / ctx.cookie
  • Replace app.listen() with serve(app, opts) or cloudflare(app)

Further Reading

See Routing for the full route API, Body for body-reading details, Cookies for cookie options, Contracts for typed validation, and Errors for the FXError class.

Migrating from Express · Kynetra FX