Migrating from Fastify to s200
Fastify and s200 agree on the important things: a route table, scoped middleware, validation at the edge, and JSON everywhere. The difference is substrate — Fastify is an instance with lifecycle hooks and plugin encapsulation; s200 is plain data with top-level functions, and every hook maps to one of two middleware shapes.
The mechanical mapping
| Fastify | s200 |
|---|---|
fastify() | createApp() |
app.get('/users/:id', handler) | get(app, '/users/:id', handler) |
app.post / put / patch / delete / head / options | post / put / patch / del / head / options registrars |
app.addHook('onRequest', fn) | use(app, fn) — work before await next() |
app.addHook('preValidation', fn) | a middleware — params are already decoded; validation is your gate's job |
app.addHook('preHandler', fn) | route-scoped middlewares: get(app, path, mw, handler) |
app.addHook('onSend', fn) | a stamper on the unwind — rewrite headers after await next() |
app.addHook('onError', fn) / app.setErrorHandler(fn) | createApp({ onError }) |
app.setNotFoundHandler(fn) | createApp({ onNotFound }) |
app.register(plugin, { prefix: '/v1' }) | mount(app, '/v1', subApp) — snapshot semantics (below) |
app.decorate('db', db) | createApp<{ db: Db }>() + ctx.state.db, or withState |
route schema: { body, response } | jsonBody(schema) gate in + serialize(schema) out |
reply.code(201).headers(h).send(body) | json(ctx, body, { status: 201, headers: h }) |
reply.status(204).send() | send(ctx, null, { status: 204 }) — no content-length advertised |
request.body / request.query | await readJson(ctx) / ctx.query |
request.params | ctx.params.id (typed from the pattern literal) |
app.inject({ method, url }) (testing) | request(app, url, { method }) from s200/test |
app.listen({ port: 3000 }) | serve(app, { port: 3000 }) from s200/node |
app.addHook('onClose', fn) | lifecycle(server, { onShutdown: fn }) from s200/lifecycle |
app.ready() / boot ordering | none needed — registration is synchronous data |
@fastify/* plugins → s200 batteries
| Fastify plugin | s200 |
|---|---|
@fastify/cors | cors() from s200/cors |
@fastify/helmet | secureHeaders() from s200/secure-headers |
@fastify/jwt | s200/jwt — signJwt / jwtAuth (route-scoped gate) |
@fastify/cookie | s200/cookies — getCookie / setCookie |
@fastify/session | s200/session — createSession |
@fastify/rate-limit | s200/rate-limit — rateLimit (sliding window) |
@fastify/formbody | readForm(ctx) — urlencoded and multipart, one call |
@fastify/multipart | streamForm from s200/multipart, or uploadForm from s200/upload |
@fastify/static | serveStatic (core barrel) over the adapter's injected readers |
@fastify/swagger | s200/openapi (spec from the live route table) + s200/swagger (Scalar UI) |
@fastify/under-pressure | s200/health — readiness({ db: ping }) + createGate() |
Where s200 differs architecturally
Plugins are data transforms, not scopes. Fastify's
registerbuilds an encapsulated child context: hooks and decorators added inside don't leak out, and the child stays live — register more routes afterregisterPluginresolves and they serve.mount(app, '/v1', sub)is a snapshot: it copies the sub-app's routes and middlewares at call time, never mutates the sub, and post-mount registration onsubstays invisible to the mount. The sub'sonErroris carried per mounted route; itsonNotFoundand custommatchcannot travel (the parent dispatches mounted routes) and are dropped with oneconsole.warnpermount. If you need the register-later pattern, mount after all sub registrations, or mount a fresh sub per version.Hooks collapse into one shape. Fastify spreads the request lifecycle across named hooks; s200 has exactly two middleware shapes — work before
await next()(onRequest/preValidation/preHandler territory) and work after it (onSend/onResponse territory). There is no preParsing hook because there is no eager body read: bodies are read in the handler (readJson,readForm), counted against their limit before buffering. And the unwind sees a real response, always — see next point.The error contract runs inside the chain. Fastify's
setErrorHandler(and its hooks) run after the route chain unwinds — middleware cannot observe the 500 it produced. s200 wraps route chain and fallbacks in an inner error boundary: the error response is materialized before the unwind starts, soawait next()resolving meansctx.resexists, 500s included — logger/CORS/request-id stamp the real error response. Gate rejections (jwtAuth401,rateLimit429 withRetry-After+ IETFRateLimit-*) take the same path, soonErrorowns the policy for them too.Validation and serialization are separate, explicit gates. Fastify compiles one JSON Schema per route —
schema.bodyguards input,schema.responseshapes output. s200 splits the two directions: input flows through a gate (jsonBodyfroms200/validate— a hand-rolled parse fn, any Standard Schema library, or the JSON-Schema DSL ofs200/openapi; the parsed value lands onctx.state.validated), output flows throughserializefroms200/serialize— a schema'd serializer emitting exactly the declared keys with an exactcontent-length. Plainjson(ctx, …)remains the honest default; nothing implicit ever rewrites your payload. A wrong-method request answers RFC 9110405+Allow(Fastify answers 404).decoratebecomes state, at three granularities. Instance-level decorations map tocreateApp<{ db: Db }>()(per-app shape) or a plain module-level import — the pool is just a value. Per-request decorations map toctx.stateviawithState(async (ctx, next) => { ctx.state.user = …; await next(); }), which types the slot for everything downstream, no global merge. Reply decorations are unnecessary — the respond helpers take{ status, headers }directly.
Common patterns
A schema'd route — Fastify:
app.post('/users', {
schema: {
body: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
response: { 201: { type: 'object', properties: { id: { type: 'integer' } } } },
},
}, async (request, reply) => {
const user = await db.create(request.body);
reply.code(201).send({ id: user.id });
});The same route in s200 — the two directions are two explicit gates:
import { createApp, get, httpError, json, post, send } from 's200';
import { jsonBody } from 's200/validate';
import { jsonRaw, serialize } from 's200/serialize';
const userOut = serialize({
type: 'object',
properties: { id: { type: 'integer' }, name: { type: 'string' } },
required: ['id', 'name'],
});
post(
app,
'/users',
jsonBody((data) => {
if (typeof data !== 'object' || typeof (data as { name?: unknown }).name !== 'string') {
throw httpError(422, 'name must be a string');
}
return data as { name: string };
}),
async (ctx) => {
const user = await db.create(ctx.state.validated as { name: string });
return jsonRaw(ctx, userOut(user), { status: 201 });
},
);An auth plugin → a route-scoped gate:
import { jwtAuth } from 's200/jwt';
// fastify: app.register(jwt, { secret }) + app.addHook('onRequest', app.jwtAuth)
get(app, '/me', jwtAuth({ secret }), (ctx) => json(ctx, ctx.state.jwt));onSend header rewriting → an unwind stamper:
use(app, async (ctx, next) => {
await next(); // ctx.res is guaranteed set here — 500s included
ctx.res.headers.set('x-request-id', ctx.state.requestId ?? '');
});setErrorHandler/setNotFoundHandler:
const app = createApp({
onError: (ctx, error) => {
const status = isHttpError(error) ? error.status : 500;
json(ctx, { error: String((error as { message?: string }).message) }, { status });
},
onNotFound: (ctx) => json(ctx, { error: 'no such route' }, { status: 404 }),
});Gotchas
- No
reply.raw/reply.hijack()— the response is a web-standardResponse. Return one directly for full control, or usestream()/streamSSE()froms200/streamingfor streaming bodies. onResponse(after the socket flushes) is not a core hook — the unwind stamper sees the materialized response whennext()settles, which covers logging needs; anything strictly post-send belongs in the adapter.request.bodyhas no eager equivalent — the handler awaitsreadJson(ctx)/readForm(ctx); the parse is cached per request context, so multiple reads replay from one buffer.app.log(Pino) is not built in — bring your own logger and inject it, or use thelogger()battery with itssink/formatseams.content-lengthis set explicitly by the respond helpers (platforms serialize lazily) — HEAD keeps the would-be size, andetag/compresskey on that signal. A barenew Response('…')carries no length;send()/json()do.- Type providers (fastify
TypeBoxgenerics on reply/request) map to s200's literal-pattern inference —ctx.paramsis typed from'/users/:id'itself, andthrows(404)types the error branch fors200/client's response union.