@giahung1510/resonata
v0.1.0
Published
a type-first web framework for bun, node, deno and the edge. routes that know their own shape.
Maintainers
Readme
resonata
a type-first web framework for bun, node and deno. routes that know their own shape.
npx resonata new my-api --ws
cd my-api && npm install && npm run devDocs · Build a link shortener · Migrating from Elysia
or add it to something existing:
npm install resonataimport { Resonata, t } from 'resonata'
const app = new Resonata()
.get('/', () => 'hello, resonator')
.get('/echo/:id', ({ params }) => ({ id: params.id }))
.post('/echo', ({ body }) => body, {
body: t.Object({
name: t.String({ minLength: 1 }),
level: t.Number({ minimum: 1, maximum: 90 })
})
})
.listen(3000) ✦ resonata
≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈
✧ listening http://localhost:3000
✧ routes 3
✧ runtime node v22.22.2params.id is string. body is { name: string; level: number }. you wrote no
type annotations and no generics. that is the whole pitch.
the one rule
resonata accumulates type state across chained calls. chain, don't statement.
// yes — the type flows into `app`
const app = new Resonata()
.get('/a', () => 'a')
.get('/b', () => 'b')
// no — routes work at runtime, but the types are lost
const app = new Resonata()
app.get('/a', () => 'a')the second form still serves requests. it just can't generate a client or an
openapi doc, because there is nothing left in the type to read. resonata routes
warns when it sees this.
vocabulary
resonata borrows its naming from wuthering waves. the method names are boring on
purpose -> you should be able to guess .get() without opening the docs. the
concepts get the interesting names, because those are what you actually have to
learn.
| concept | what it is | how you touch it |
| --- | --- | --- |
| Resonata | the application | new Resonata() |
| Echo | one request and everything it carries | the handler's only argument |
| Guardian | the schema builder | t.Object({ ... }) |
| Memory | app-wide mutable store | .state() -> echo.memory |
| Tethys | injected values and services | .decorate(), .derive() |
| Constellation | a group of routes under a prefix | .group() |
| Sonata | a plugin | sonata() -> .use() |
| Lament | an error that knows its status code | echo.lament.notFound() |
| Awakening | turning a return value into a Response | automatic |
| Tidal Flow | streaming | async function* or echo.flow |
| Black Shores | ambient request context | currentEcho() |
| Convergence | the accumulated compile-time type state | inferred, never written |
| Link | a typed client built from typeof app | link<typeof app>(url) |
| Dreamlink | a websocket route | .ws(path, handlers) |
| Dreamlink | websockets | not implemented yet |
if the flavour isn't for you, every themed alias has a boring twin and they are
interchangeable: .resonate() -> .route(), .astralChord() -> .use(),
.constellation() -> .group(), .remember() -> .state(), .tether() ->
.decorate(), .lament() -> .onError(), .converge() -> .listen().
the Echo
one object, everything about the request.
app.get('/echo/:id', ({ params, query, body, headers, memory, set, lament, flow }) => {
set.status = 201
set.cookie('session', 'abc', { httpOnly: true })
return { id: params.id }
})lament throws. flow streams. memory is shared across requests. anything
added by .decorate() or .derive() is spread onto the same object, so a plugin
that provides a database appears as echo.db with full types.
validation
schemas are declared with t and passed as the third argument. they are not
compiled at startup -> the first request to hit a route compiles its validator,
caches it, and every request after that reuses the closure.
app.post('/echo', ({ body, query }) => body, {
body: t.Object({
name: t.String({ minLength: 1 }),
level: t.Number({ minimum: 1, maximum: 90 }),
tags: t.Array(t.String()),
notes: t.Optional(t.String())
}),
query: t.Object({ dryRun: t.Optional(t.Boolean()) })
})query, params and headers arrive as strings, so numbers and booleans are coerced
there and only there. a body is never coerced -> { level: "90" } in json is a
422, and it should be.
a failure produces a Lament:
{
"error": "body failed validation",
"code": "DISSONANCE",
"status": 422,
"detail": {
"source": "body",
"issues": [{ "path": "level", "message": "expected <= 90", "received": 200 }]
}
}response schemas
declare one schema for the 200 case, or a map to document several statuses:
app.get('/post/:id', ({ params, set }) => {
const post = find(params.id)
if (post) return post
set.status = 404
return { error: 'not found', code: 'NOT_FOUND' }
}, {
response: {
200: t.Object({ id: t.String(), title: t.String() }),
404: t.Object({ error: t.String(), code: t.String() })
}
})both statuses appear in the openapi document with their own schema, and the
response is validated against the schema for the status actually set — so
returning the 200 shape with set.status = 404 is caught. an undeclared status
is left alone: a route documenting 200 and 404 may still legitimately 500, and
validating that against the 200 schema would turn a real failure into a
confusing one.
set.status = 404 is an imperative assignment, so the compiler can only check
the return against the union of declared shapes. status() puts the code in
the value instead, and each branch is then checked against its own schema:
import { status } from 'resonata'
app.get('/post/:id', ({ params }) => {
const post = find(params.id)
return post
? status(200, post)
: status(404, { error: 'not found', code: 'NOT_FOUND' })
}, { response: { 200: PostSchema, 404: ErrorSchema } })return status(404, { id: '1', title: 'a post' })
// ^ compile error: the 404 schema wants error and code
return status(418, { ... })
// ^ compile error: 418 is not declaredit's opt-in. requiring it everywhere would tax the majority of routes — which
have one success shape — to serve the minority documenting several. the
imperative form still works and still validates at runtime. the envelope never
reaches the client: status(200, post) and return post produce the same
response type.
examples in the docs
app.post('/post', createPost, {
body: t.Object({ title: t.String() }),
detail: {
example: { title: 'my first post' },
examples: {
minimal: { summary: 'the minimum', value: { title: 'hi' } },
full: { summary: 'everything', value: { title: 'a longer title' } }
}
}
})documentation people actually read leads with an example, not a schema.
zod 4, valibot and arktype work anywhere t does, via Standard Schema. you lose
openapi generation for those routes, since we can't emit json schema from a
validator we don't understand.
composition
a plugin is just an app. there is no plugin interface to implement.
import { Resonata, sonata } from 'resonata'
const timing = sonata('timing', (app) =>
app
.derive(() => ({ startedAt: performance.now() }))
.onAfterHandle((echo, value) => {
echo.set.headers['x-elapsed'] = `${(performance.now() - echo.startedAt).toFixed(2)}ms`
return value
})
)
const app = new Resonata()
.use(timing)
.get('/', ({ startedAt }) => startedAt) // typed, no annotationthe name is the dedupe key -> a plugin pulled in twice, directly and through another plugin, registers once.
groups and guards nest:
new Resonata()
.group('/v1', (v1) =>
v1
.get('/ping', () => 'pong')
.guard({ headers: t.Object({ authorization: t.String() }) }, (auth) =>
auth.get('/me', ({ headers }) => headers.authorization)
)
)the typed client
no codegen, no schema file, no build step. the client's shape comes from the server's type.
// server.ts
const app = new Resonata()
.get('/echo/:id', ({ params }) => ({ id: params.id, level: 90 }))
.post('/echo', ({ body }) => body, {
body: t.Object({ name: t.String(), level: t.Number() })
})
export default app// client.ts
import type app from './server'
import { link } from 'resonata/client'
const api = link<typeof app>('http://localhost:3000')
const { data, error } = await api.echo({ id: '1' }).get()
// ^? { id: string; level: number } | null
await api.echo.post({ body: { name: 'dreamless', level: 90 } })
// ^ typed, and required because the route declares a bodyimport the type, not the value -> import type app from './server'. importing
the app itself would pull your whole server into the client bundle.
path segments become properties. :params become calls. methods are the leaves.
delete a route on the server and the client stops compiling on the next
tsserver pass.
a segment named after a verb works too — /post/:id is an ordinary route:
await api.post({ id: '1' }).get() // GET /post/1
await api.post.post({ body: { ... } }) // POST /posterrors are values, not exceptions:
const { data, error } = await api.echo({ id: '999' }).get()
if (error) {
console.log(error.status, error.code, error.message) // 404 NOT_FOUND ...
return
}
data.level // narrowed to non-null herea Lament round-trips intact, so error.code and error.detail on the client
are the same values the server threw.
for tests, hand it a fetch that goes straight to the app — no socket, no port:
const api = link<typeof app>('http://test', { fetch: (r) => app.handle(r as Request) })other options: headers (an object or an async factory, for auth), onRequest,
onResponse.
testing
app.handle(request) takes a Request and returns a Response — no listen(),
no socket, no port. that's not a testing feature bolted on afterward; it's the
same method the node, bun, and edge adapters all call, so a test exercising it
exercises the exact code path a real request takes, hooks and all.
hand link() a fetch that goes straight to app.handle instead of the
network, and the typed client becomes a typed integration-test client:
// test/echo.test.ts
import { describe, it, expect } from 'vitest'
import { link } from 'resonata/client'
import app from '../src/index.js'
const api = link<typeof app>('http://test', {
fetch: (r) => app.handle(r as Request)
})
describe('echo', () => {
it('validates the body', async () => {
const { error } = await api.echo.post({ body: { name: '', level: 999 } })
expect(error?.status).toBe(422)
})
it('round-trips a valid one', async () => {
const { data } = await api.echo.post({ body: { name: 'dreamless', level: 90 } })
expect(data).toEqual({ name: 'dreamless', level: 90 })
// ^? { name: string; level: number } | null — still typed, still narrowed
})
})no supertest, no spinning up a real listener and hoping the port is free, no
mock service worker intercepting fetch at the network layer to fake a server
that isn't there. the request genuinely runs through routing, validation,
hooks, and your handler; the only thing missing is a socket, which was never
part of what you were testing anyway. plugins, macros, beforeHandle, error
handling — all of it runs, because app.handle is not a shortcut around them,
it's underneath them.
this is also why resonata new generates tests in exactly this shape rather
than a hello-world you're expected to delete: it's not a demo of the pattern,
it's the pattern the scaffold expects you to keep using.
dreamlink — websockets
.ws() registers a websocket route. params and query come from the upgrade
request and are typed exactly as an http route's are.
app.ws('/chat/:room', {
body: t.Object({ text: t.String({ minLength: 1 }) }),
open(socket) {
socket.subscribe(`room:${socket.echo.params.room}`)
// ^? string, from the path literal
},
message(socket, msg) {
socket.broadcast(`room:${socket.echo.params.room}`, { text: msg.text })
// ^? string, from the schema
}
})messages are decoded and validated. text that looks like json is parsed;
anything else passes through untouched, because plenty of protocols are
line-based. if you declare body, a message that fails validation is answered
with a DISSONANCE error and never reaches your handler — and the
connection stays open, because one typo shouldn't cost a reconnect. the
validator is compiled on the first message, same lazy rule as http.
send takes a value, not a string. objects are serialised for you, which
removes the most common websocket papercut.
declare response to give a connected client a real receive type:
app.ws('/chat/:room', {
body: t.Object({ text: t.String() }), // what clients may send
response: t.Object({ from: t.String(), text: t.String() }), // what you send
message: (socket, msg) => socket.publish(`room:${socket.echo.params.room}`, {
from: socket.id,
text: msg.text
})
})it has to be declared rather than inferred from your socket.send(...) calls —
TypeScript will not infer a type parameter from a call site nested inside a
callback body. without it, clients type received messages as unknown.
a route that sends more than one kind of message can declare response as a
union, and both sides narrow it the ordinary way — no separate mechanism, no
tagging, just a discriminated union doing what a discriminated union does:
const Joined = t.Object({ type: t.Enum(['joined']), room: t.String() })
const Message = t.Object({ type: t.Enum(['message']), text: t.String() })
app.ws('/chat/:room', {
response: t.Union([Joined, Message]),
open: (socket) => socket.send({ type: 'joined', room: socket.echo.params.room }),
// ^ checked against the Joined variant specifically —
// a value with fields from the wrong variant, or
// matching neither, is a compile error right here
message: (socket) => socket.send({ type: 'message', text: 'hi' })
})and on a connected client, socket.on('message', msg => ...) narrows the same
way any discriminated union does — if (msg.type === 'joined') gives you
msg.room, the other branch gives you msg.text.
the typed socket client
connect() is to Dreamlinks what link() is to http routes:
import { connect } from 'resonata/client/socket'
const sockets = connect<typeof app>('http://localhost:3000')
const chat = sockets.chat({ room: 'general' }).open()
await chat.ready()
chat.send({ text: 'hello' }) // checked against the body schema
chat.on('message', (msg) => msg.text) // typed from the response schemahttp:// is rewritten to ws:// so you can pass the same base url as the http
client. sends issued before the socket opens are queued rather than dropped —
sending in the same tick as open() is the natural thing to write. on()
returns an unsubscribe function. reconnect: true adds exponential backoff,
off by default because silently reconnecting changes what a dropped connection
means.
topics are resonata's own, not the runtime's:
| method | who receives it |
| --- | --- |
| socket.subscribe(topic) | — joins |
| socket.publish(topic, data) | everyone in the topic, sender included |
| socket.broadcast(topic, data) | everyone except the sender |
Bun has faster native topics, but using them would make publish behave
differently per runtime — and a message silently not arriving on Node is worse
than a slower broadcast. subscriptions are dropped automatically on close.
auth belongs in upgrade, which runs on the ordinary http request before
the protocol switch. return anything to refuse:
app.ws('/private', {
upgrade: ({ headers }) => (headers.authorization ? undefined : 'token required'),
data: ({ headers }) => ({ user: parseToken(headers.authorization!) }),
message(socket) {
socket.send({ you: socket.data.user })
}
})a connection that shouldn't exist is cheapest to refuse before it does.
scaling past one process
topics are in-process by default, which is correct for a single node and needs no infrastructure. behind a load balancer it is not — a message published by the process holding socket A never reaches socket B elsewhere. install a bus:
import { createClient } from 'redis'
import { setTopicBus } from 'resonata'
import { redisTopics } from 'resonata/topics/redis'
const publisher = createClient({ url: process.env.REDIS_URL })
const subscriber = publisher.duplicate() // redis needs two: a subscribed
await Promise.all([publisher.connect(), subscriber.connect()]) // connection
// takes no commands
setTopicBus(redisTopics({ publisher, subscriber, prefix: 'myapp' }))you pass your own client, so resonata still has zero runtime dependencies and
you keep control of TLS, retries and observability. redis and ioredis both
fit — only four methods are used.
local delivery stays synchronous and never waits on the network, so a bus
outage degrades to single-node behaviour rather than silence. messages carry
their origin node so a publisher doesn't receive its own broadcast twice.
TopicBus is a four-method interface if you'd rather use NATS or Postgres
LISTEN/NOTIFY.
compression
permessage-deflate (RFC 7692) is negotiated automatically when the client offers it. payloads under 1 KB are sent uncompressed — deflate has a fixed overhead that makes short messages larger.
new Resonata({
websocket: {
compression: true, // default
compressionThreshold: 4096, // default 1024
heartbeatMs: 30_000,
maxPayload: 16 * 1024 * 1024
}
})these apply on every runtime. node uses resonata's own implementation; bun and
deno are handed the equivalent native option. previously node compressed and
the others didn't, so the same app behaved differently depending on where it
ran — the kind of inconsistency you only find in production. (deno negotiates
permessage-deflate unconditionally and offers no switch, so compression:
false warns there rather than silently doing nothing.)
repetitive json compresses to under a quarter of its size, and context
takeover means later messages benefit from earlier ones. no_context_takeover
and max_window_bits are both honoured.
runtime support
Bun and Deno hand you a native upgrade. Node doesn't, so resonata ships its own
RFC 6455 implementation — handshake, frame codec, fragmentation, ping/pong,
close semantics, permessage-deflate — with no dependency on ws.
it's tested end to end against Node's built-in WebSocket as the client, so
the codec is checked by an independent implementation rather than agreeing with
its own bugs. cross-process fanout is verified between two genuinely separate
processes, not two objects sharing a heap.
plugin scope
a hook applies to routes on the instance that declared it, and reaches upward
into whoever mounted that instance only as far as its scope allows. hooks
propagate up, the way export does in a module.
| scope | reaches |
| --- | --- |
| local (default) | only routes on this instance |
| scoped | this instance, plus whoever directly .use()s it |
| global | this instance and every ancestor, transitively |
// this plugin guards its own routes, and only its own
const admin = new Resonata({ name: 'admin' })
.onBeforeHandle(({ headers }) =>
headers.authorization ? undefined : new Response(null, { status: 401 }))
.get('/admin/panel', () => 'secret')
const app = new Resonata()
.use(admin)
.get('/public', () => 'open') // stays openthis is a correctness property, not a convenience. a framework that hoists plugin hooks into the host will silently lock down every unrelated route the moment you mount an auth plugin. resonata used to do exactly that.
a plugin whose whole job is app-wide says so once:
sonata('cors', build, { scope: 'global' }).
cookies
one jar, both directions. reads parse the request; writes queue a Set-Cookie.
const app = new Resonata({
cookie: { secrets: process.env.COOKIE_SECRET, httpOnly: true }
})
.get('/', ({ cookie }) => {
const visits = Number(cookie.get('visits').value ?? 0) + 1
cookie.set('visits', String(visits))
return { visits }
})signing is HMAC-SHA256 through WebCrypto, verified in constant time. a tampered
cookie reads as undefined rather than throwing -> a tampered cookie should
look like no cookie, not like an error someone can probe. secrets accepts an
array so a key can be rotated without invalidating live sessions.
cookie.get(name) and cookie.name are the same thing. prefer the method form
under noUncheckedIndexedAccess, where index access picks up a | undefined
that blocks writes.
running on the edge
resonata never generates code at runtime. no new Function, so nothing
trips a CSP or an isolate that forbids eval, and nothing on the request path
imports a node: builtin.
npx resonata new edge-api --runtime workersexport default { fetch: (request: Request) => app.handle(request) }routing, validation, cookies with WebCrypto signing, cors and gzip via
CompressionStream all work unchanged. brotli is the one exception -> it has no
web-standard equivalent, so compression falls back to gzip automatically.
the test suite proves this rather than asserting it: a real app is bundled with
every node:* specifier aliased to a module that throws on import, then
exercised. anything reaching for a node builtin fails in CI, not on a deploy.
macros
a macro is a named bundle of lifecycle behaviour that routes switch on from
their options object. the alternative — wrapping routes in .guard()
callbacks — works, but forces every policy to become a level of nesting.
const app = new Resonata()
.macro('role', (required: string) => ({
resolve: ({ headers }) => ({ user: parse(headers.authorization) }),
beforeHandle: ({ user, lament }) => {
if (user.role !== required) lament.forbidden()
}
}))
.get('/admin', ({ user }) => user, { role: 'admin' })
.get('/public', () => 'open')macro names are typed: { role: 'admin' } compiles, { rle: 'admin' } does
not, and neither does { role: 42 } when the factory takes a string. a plugin
can ship macros, and they are visible to whatever mounts it.
what a macro adds is typed too. echo.user above is
{ id: string; role: string }, inferred from the macro's own resolve —
and only on routes that actually named the macro. a route that doesn't, or
that opts out with { role: false }, doesn't see it, which is right: it
won't be there.
mounting other apps
import legacy from './legacy-app.js'
new Resonata()
.mount('/v1', legacy.fetch) // an elysia app, a hono app, a worker
.get('/v2/echo', modernHandler)anything that takes a Request and returns a Response mounts. the prefix is
stripped before forwarding, so an app mounted at /v1 sees /echo and its own
routes match unchanged. that makes an incremental migration possible — mount
the old application and move routes out one at a time.
a mounted handler is opaque: its routes are not in the accumulated types, so they don't appear in the generated client or the openapi document. resonata cannot see inside a function.
scheduled jobs
app.use(cron({
jobs: [
{ name: 'digest', schedule: '0 9 * * 1-5', run: sendDigest },
{ name: 'sweep', schedule: 'every 5m', run: sweepExpired }
]
}))five-field cron expressions, @daily-style aliases, or plain every 30s.
named months and weekdays work, and both 0 and 7 mean sunday.
jobs start when the server binds and stop when it stops, so a job can't outlive
its app and a test suite doesn't accumulate live timers. a malformed schedule
throws at construction rather than at three in the morning. an overrunning job
skips its next tick instead of piling up, and .stop() waits for anything
mid-flight. leader gates a job so it doesn't run on every node behind a load
balancer.
named schemas
const User = t.Object({ id: t.String(), name: t.String() })
const app = new Resonata()
.model('User', User)
.get('/user/:id', getUser, { response: User })changes nothing at runtime — validation is identical. it changes the generated
document: the schema is emitted once under components/schemas and referenced
with $ref everywhere it is used, instead of being inlined at every route.
.model() tags the object itself; pass that same reference wherever you'd
otherwise inline the schema, not the name as a string — there's no lookup by
name, so a route only has to know the schema, the same as any other route.
hardening
things resonata refuses without being asked:
- prototype-mutating keys are dropped. a form field or json property named
__proto__,constructororprototypenever reaches your body — assigning one replaces the object's prototype rather than adding a property, which hands an attacker control of whatever the app later reads off it. - header values are truncated at CR, LF or NUL. a route reflecting user input into a header cannot be made to split the response, and — just as importantly — cannot be made to throw. an unsanitised value makes the runtime reject the whole Response, turning any reflecting endpoint into an attacker-triggered 500.
- cookie values are percent-encoded, so the same applies there.
- path traversal is checked after resolution, so
..%2f,%2e%2e%2f,....//and%252e%252e%252fare all refused rather than only the spellings someone thought of. - a JWT's
algheader is ignored in favour of the configured algorithm.
limits
request bodies are capped at 1 MB by default. a declared Content-Length
over the limit is refused before a byte is read; a chunked request with no
length is counted as it arrives and abandoned the moment it goes over, so an
oversized body is never fully buffered.
new Resonata({ maxBody: '5mb' }) // app-wide
app.post('/upload', handler, { maxBody: '50mb' }) // one routethere is also an optional request timeout, off by default because a sensible value depends entirely on what a route does:
new Resonata({ timeout: 30_000 })
app.get('/report', slowHandler, { timeout: 120_000 })
app.get('/stream', streamHandler, { timeout: 0 }) // opt outan overrun answers 503 and fires echo.signal, so a fetch or a driver that
honours an AbortSignal stops. the handler itself is not killed — javascript
has no way to interrupt one — and saying so is better than implying otherwise.
file uploads are checked, not merely declared:
body: t.Object({
avatar: t.File({ maxSize: '2mb', accept: ['image/png', 'image/jpeg'] }),
docs: t.Files({ maxCount: 5, accept: ['application/pdf'] })
})accept understands image/*. repeated form fields arrive as arrays rather
than being collapsed to the last value.
batteries
ten first-party Sonatas, importable from the root or from resonata/plugins.
they have no privileged access — everything they do is reachable from userland.
they exist so the first hour with the framework isn't spent reimplementing cors.
import { Resonata, cors, logger, docs, bearer, rateLimit } from 'resonata'
const app = new Resonata()
.use(cors())
.use(logger({ skip: ['/health'] }))
.use(rateLimit({ max: 100 }))
.use(bearer())
.use(docs({ title: 'archive', version: '1.0.0' }))cors() — permissive by default, which is right for local dev. origin
takes a string, array, or predicate. credentials: true with a reflected origin
throws rather than shipping a same-origin bypass. preflight is answered in
onRequest and never reaches a handler.
logger() — one line per request, coloured by status class, with duration.
json: true for production, skip for health checks, write to redirect the
sink.
docs() — mounts a rendered reference at /docs and the spec at
/docs/json. reads the route table at request time, so routes registered after
.use(docs()) still appear. ui: 'swagger' if you prefer it to Scalar.
any Dreamlinks show up in their own section beneath the spec viewer — OpenAPI
has no vocabulary for websockets, so Scalar and Swagger have nothing to render
for them no matter how the rest of the page is built; this reads send and
receive straight from the app instead, and links to a full AsyncAPI 3.0
document at /docs/asyncapi.json covering the same sockets. no request-testing
here on purpose — that's what the spec viewer's own "try it" panel is for.
bearer() — extracts the token to echo.bearer, typed. it does not verify
it: that needs your secret, your clock-skew policy and your revocation list.
compression() — gzip, deflate and brotli, negotiated from
Accept-Encoding with q-values. skips anything under 1 KB, anything already
compressed, and anything streaming. gzip and deflate go through the
web-standard CompressionStream, so it works on the edge.
staticFiles({ root }) — serves a directory with ETags, conditional
requests, range requests and SPA fallback. path traversal is checked after
resolution, so encoded and double-encoded ../ are both refused.
jwt({ secret }) — sign and verify, on WebCrypto so it works on the edge.
HS256/384/512 and ES256/384. A token claiming alg: "none", or any algorithm
other than the configured one, is rejected — algorithm confusion works
precisely because implementations trust the header over their own config.
trace() — times each phase and anything you mark with
trace.span('db', fn), then hands the result to an exporter.
toOtelSpans() converts a trace to OpenTelemetry's shape without depending on
the SDK.
cron({ jobs }) — scheduled jobs, tied to the app lifecycle.
rateLimit() — fixed-window counter, in memory by default. pass
store: redisStore({ redis }) to share one budget across every instance;
without it, n nodes means n times the limit. a store outage lets requests
through rather than taking the application down with it, and says so. honest about its limits: it
resets hard at the window boundary and doesn't coordinate across processes. fine
for one node, not for a cluster.
lifecycle
onStart once, when the server binds.
onRequest before routing. cheapest place to reject traffic.
onParse body decoding. first parser to return wins.
onTransform mutate the Echo before validation.
derive add per-request values, pre-validation.
[Guardian] params, query, headers, body
resolve add per-request values, post-validation.
beforeHandle auth and short-circuits. returning a value skips the handler.
[handler]
afterHandle reshape the return value.
[Awakening] value becomes a Response.
mapResponse transform the Response itself.
onResponse observe only.
onError fires from anywhere, jumps straight to Awakening.
onStop once, on graceful shutdown, before the listener closes.onStart gets the bound url — the place for warmups. onStop hooks are awaited
before the port is released, so a queue can finish draining:
const app = new Resonata()
.onStart(({ url }) => console.log(`up at ${url}`))
.onStop(async () => { await db.end() })
process.once('SIGTERM', () => app.stop().then(() => process.exit(0)))http correctness
things resonata does without being asked:
- 405, not 404, when a path exists under a different verb — with an
Allowheader listing what it does accept. answering 404 here sends people hunting for a typo in a path that was never wrong. OPTIONSis answered from the route table when no handler claims it.HEADfalls back to theGEThandler and drops the body, so the headers match theGETexactly, as the spec requires. an explicit.head()wins.- headers survive a throw. a plugin that sets
retry-afterand then throws a 429 gets both.
returning things
return plain values. resonata works out the rest.
| you return | you get |
| --- | --- |
| string | text/plain |
| object, array, number, boolean | application/json |
| null / undefined | 204 No Content |
| Response | passed through untouched |
| ReadableStream, Blob, ArrayBuffer | streamed as-is |
| async function* | chunked stream |
| echo.flow.sse(iterable) | text/event-stream |
scaffolding
resonata new my-api # http only
resonata new my-api --ws # with a websocket route
resonata new my-api --runtime bun
resonata new my-api --runtime deno # generates deno.json too — deno task devthe generated project is not a hello-world. it ships with a route module, a
validated body, a Lament, a plugin, mounted docs, and tests that drive the typed
client against app.handle — no port, no lifecycle. it typechecks and its tests
pass the moment you install.
src/index.ts composes everything, exports the app
src/routes/echo.ts one module per resource
src/plugins/timing.ts your own Sonatas
test/echo.test.ts the typed client, in-processcontracts
the compiler knows your API's shape. a producer's CI can freeze that shape and fail the build the moment a change would break it — not a diff someone reads later, a webhook, or a 500 in staging, but a compile error in the producer's own repo at the point of the change.
resonata contract emit src/index.ts > contracts/orders.d.ts # freeze it
resonata contract check src/index.ts --against contracts/orders.d.tsa consumer imports the frozen file as its client's type. the producer commits
it (or publishes it, e.g. as @acme/contracts) and checks the current app
against it in CI:
✧ contract broken — src/index.ts vs contracts/orders.d.ts
≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈
· route removed: get /orders/:id
· post /orders body.warehouse is no longer compatiblethe same check is available inline, for a CI pipeline that only has
tsc --noEmit's exit code to work with:
import { assertCompatible } from 'resonata/contracts'
import type { OrdersContract } from '../contracts/orders.js'
import type app from './index.js'
assertCompatible<OrdersContract, NonNullable<(typeof app)['__convergence']>>()what counts as breaking is asymmetric on purpose: an input (params, query,
body, and a Dreamlink's send) may lose fields or gain optional ones freely,
but a new required field breaks an old caller who never sent it. a response
(and a Dreamlink's receive) may gain fields freely, but losing one — or
narrowing one a consumer built against — breaks whoever's already reading it.
contracts cover websocket routes the same way they cover http ones — a
removed .ws() route, or a tightened send/narrowed receive, fails the
check the same way a broken http route does. see DESIGN.md, "contracts," for
the bugs that shipped in earlier versions of this and why none of them showed
up until tested against a real, frozen file instead of an in-memory type.
cli
resonata new <name> [--ws] [--runtime bun|node|deno]
resonata dev [entry] [--watch src,lib] [--port 3000]
resonata routes [entry]
resonata openapi [entry] > openapi.json
resonata contract emit [entry] > contract.d.ts
resonata contract check [entry] --against <file>
resonata deploy <workers|vercel|fly|docker> [--entry path] [--name n]contract emit and contract check drive the TypeScript compiler API rather
than importing your app, so typescript needs to be installed — it's a peer
dependency, not a bundled one, and nothing else in the cli touches it.
the entry file must export default an app without calling .listen() itself.
dev respawns the process on change rather than hot-reloading modules. esm has
no reliable cache invalidation, so in-process reload leaves stale closures and
listeners bound to a dead server -> a respawn is a few hundred milliseconds
slower and always correct.
deploy writes the target platform's own config for an app that already
exists — wrangler.toml, vercel.json plus an edge function, fly.toml
plus a Dockerfile, or a bare Dockerfile — and prints what to change in
your entry file rather than editing it or package.json for you. it never
overwrites a file that's already there unless you pass --force.
when things go wrong
in development (NODE_ENV !== 'production'), resonata tries to tell you what
happened rather than hand you a json blob.
a validation failure prints the failing fields to the terminal:
✧ dissonance POST /echo (body)
· name expected at least 1 characters — got ""
· level expected <= 90 — got 500
-> /_resonata/errors#DISSONANCEand a browser that navigated to a broken route gets a rendered page with the
issues and the stack, instead of raw json displayed as plain text. api clients
still get the plain Lament envelope — the switch is on the Accept header.
both are development-only. set RESONATA_QUIET=1 to keep the behaviour and drop
the terminal noise, which is what you want in a test suite that asserts on 4xx.
the error catalogue
every code a Lament can carry — DISSONANCE, TIMEOUT, BODY_TOO_LARGE, all
of them — has a hand-written entry: what it means, why it usually fires, and
what to do about it. /_resonata/errors serves it live, in development,
straight from the running app — no plugin to mount, no docs site to deploy.
the terminal block and the html error page both link to it, anchored to the
specific code that just fired. the same content also builds docs/errors.html
via scripts/gen-error-catalogue.mjs, so the static reference site and the
live in-app page can never drift apart; both render from
src/errors/catalogue.ts, which is the one place the explanations are
actually written.
it tells you what to do
a 404 during development names a likely path — the framework has the whole route table and is better placed to spot a typo than whoever is reading the log:
{
"error": "no route resonates with GET /echos — did you mean /echoes?",
"code": "NOT_FOUND",
"detail": { "suggestion": "/echoes" }
}typo distance is Damerau-Levenshtein, so a transposition (/uesrs) counts as
one edit rather than two, and the budget scales with path length so an
unrelated path stays unadorned. production gets the plain message — a
suggestion there would hand the route table to anyone probing for it.
a route registered twice is almost always a copy-paste or two modules claiming the same path, and the symptom is a handler that mysteriously never runs:
✧ GET /echo is registered more than once
the later handler wins and the earlier one will never run.
if two modules both claim this path, give one a prefix.so is naming one path position two ways — /u/:id and /u/:userId/posts both
route, but each handler only sees its own name:
✧ path parameter named two ways at /u/:userId/posts
:id and :userIdand a failed bind explains itself:
port 3000 is already in use.
something else is listening there — often a previous run that did not exit.
find it with: lsof -i :3000 (macos, linux)
netstat -ano | findstr :3000 (windows)
or start on another port: app.listen(3001)the statement-form warning
TypeScript cannot catch the chaining mistake — discarding a return value is legal, so there is no error to produce. resonata catches it at runtime instead and reports at startup:
✧ these routes serve, but their types were discarded
· GET /lost-one
registering a route as a bare statement throws away the accumulated
type, so the generated client and openapi document will not see it.it's a heuristic, tuned to never fire on legitimate code — routes inside
.group(), .guard() or a plugin are consumed by the enclosing combinator and
are never reported, however they're written.
scaling: chain depth, not route count
resonata accumulates route types across a chain. TypeScript has a hard limit on
how deeply it will instantiate nested generics, and past it you get
TS2589: Type instantiation is excessively deep. that limit is on chain
depth, not on how many routes your app has -> .use() resets it.
measured on TypeScript 5.7, node 22 (node scripts/gen-stress.mjs regenerates
the test that pins these):
| shape | ceiling |
| --- | --- |
| one unbroken chain, no client | ~92 routes, fails at 96 |
| one unbroken chain + typed client | ~40 routes, fails at 50 |
| composed with .use(), with client | 400+ routes (16 modules x 25) |
| composed with .use(), no client | 1000+ routes, no failure found |
generating a client roughly halves the budget, because it has to fully resolve the accumulator the server side was deferring.
the practical rule: keep any single chain under ~40 routes and compose.
// modules/echo.ts
export default new Resonata({ prefix: '/echo' })
.get('/', listEchoes)
.get('/:id', getEcho)
// app.ts
const app = new Resonata()
.use(echoModule)
.use(userModule)
.use(adminModule)resonata routes warns when one instance carries more than 40 routes.
status
this is v0.1. the core path is real and tested; several advertised pieces are not built yet, and they are marked as such in the source rather than quietly missing.
| area | state |
| --- | --- |
| radix router, params, wildcards | done |
| type accumulation, param + body + response inference | done |
| Guardian validation, lazy compilation | done |
| Standard Schema interop | done |
| lifecycle hooks, groups, guards, plugins | done |
| Memory, decorate, derive | done |
| node adapter | done |
| bun / deno adapters | done — verified against real bun and deno processes, not just typechecked; resonata new --runtime deno generates an actual deno.json, not a relabelled node scaffold |
| Lament, error handling | done |
| Tidal Flow streaming, sse | done |
| openapi 3.1 generation | done — paths, params, bodies, multi-status responses, examples, security schemes, tags |
| generated typed client | done — paths, params, bodies, query, response types, error envelope |
| resonata dev file watching | done — debounced respawn |
| onStart / onStop / graceful stop | done |
| 405, OPTIONS, auto-HEAD | done |
| first-party plugins | cors, logger, docs, bearer, rateLimit |
| dev error pages and terminal diagnostics | done |
| route conflict and typo diagnostics | done |
| plugin hook scope isolation | done — local / scoped / global |
| cookies | done — read, write, sign, rotate |
| resolve and mapResponse | done |
| http compression | done — gzip, deflate, brotli |
| cloudflare workers / edge | done — verified in CI with node builtins removed |
| docs site, elysia migration guide, and a full walkthrough (link shortener) | done |
| macros | done — typed names, plugin-provided |
| named schemas / $ref | done |
| request body limit | done — 1 MB default, streaming-aware |
| file upload validation | done — size and MIME |
| static files, jwt, tracing | done |
| ci | done — node 20/22, bun smoke test, scaffold verification |
| typed macro output | done |
| mount for foreign fetch handlers | done |
| cron | done |
| request timeout | done — optional, per route |
| prototype-pollution and header-injection defences | done |
| cross-process rate limiting | done — redis store |
| Dreamlink websockets | done — node/bun/deno, topics, validated messages, typed params |
| permessage-deflate | done — negotiated on all three runtimes, with context takeover |
| typed websocket client | done — connect(), typed params, send and receive |
| per-status return narrowing | done — opt-in via status() |
| multi-node topic fanout | done — redis adapter, pluggable TopicBus |
| resonata new scaffold | done |
| response-type validation on the client | not started (server validates, client trusts) |
| type-level API contracts (resonata contract emit/check) | done — http routes and websocket routes both |
| error catalogue (/_resonata/errors, docs/errors.html) | done |
| resonata deploy (workers, vercel, fly, docker) | done — writes platform config for an existing app; not deploy-verified against real infrastructure |
| dev-mode playground | not built — docs()'s mounted Scalar/Swagger already has a working request-tester; see DESIGN.md, "the playground that didn't get built" |
| websocket reference in docs() | done — Dreamlinks have no OpenAPI representation, so this reads send/receive straight from the app |
| AsyncAPI 3.0 generation (/docs/asyncapi.json) | done — verified against the real @asyncapi/parser, not just eyeballed json |
benchmarks
npm run benchmedian of 5 runs, node 22, in process (app.handle(Request) -> Response):
| case | req/s | | --- | --- | | text response | ~152,000 | | json response | ~141,000 | | one path param | ~150,000 | | two path params, nested | ~143,000 | | query, validated | ~145,000 | | headers read | ~137,000 | | json body, validated | ~49,000 | | 5 lifecycle hooks | ~131,000 | | framework only, no harness | ~195,000 | | router lookups, 300 routes | ~4,200,000 |
the harness reports a median rather than a single number, because run-to-run variance on a jit'd runtime is comfortably 10% — enough to report a real regression as an improvement.
it measures the built package, not the source. running it through tsx
measures tsx: esbuild wraps every function in a __name helper and the loader
shows up in a profile too, and that overhead was reading as framework cost —
it understated these numbers by about 30%.
"framework only" reuses one Request rather than constructing a fresh one per
iteration. the gap between it and the rows above is how much of any framework
benchmark is really the runtime building Request and Response objects.
it deliberately does not open a socket. that would fold the runtime's http
stack and the loopback interface into every measurement, which is the part
resonata cannot influence — on this machine a bare node:http handler that
returns a string tops out at roughly the same rate as resonata does through the
same client, so a socket benchmark mostly measures the load generator.
the validated-body case is lower because it is bounded by the runtime reading and parsing the request body; resonata's own validation does not appear in a profile of it.
boot cost stays flat as schemas grow:
500 routes with nested body and response schemas
boot time 19.8 ms
validators compiled 0 / 500 (before any request)
after one request 1 / 500532 tests cover the router, validation, lifecycle, plugins, websockets, compression, cookies, hook scope, macros, named models, static files, jwt, tracing, cron, mounting, body limits, timeouts, file uploads, injection and pollution defences, topic fanout, openapi, both clients, diagnostics, edge-runtime compatibility, and the type-level behaviour of all of it — including generated stress tests that fail loudly if the scaling envelope regresses.
licence
MIT
