npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@giahung1510/resonata

v0.1.0

Published

a type-first web framework for bun, node, deno and the edge. routes that know their own shape.

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 dev

Docs · Build a link shortener · Migrating from Elysia

or add it to something existing:

npm install resonata
import { 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.2

params.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 declared

it'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 annotation

the 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 body

import 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 /post

errors 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 here

a 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 schema

http:// 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 open

this 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 workers
export 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__, constructor or prototype never 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%252f are all refused rather than only the spellings someone thought of.
  • a JWT's alg header 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 route

there 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 out

an 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 Allow header listing what it does accept. answering 404 here sends people hunting for a typo in a path that was never wrong.
  • OPTIONS is answered from the route table when no handler claims it.
  • HEAD falls back to the GET handler and drops the body, so the headers match the GET exactly, as the spec requires. an explicit .head() wins.
  • headers survive a throw. a plugin that sets retry-after and 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 dev

the 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-process

contracts

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.ts

a 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 compatible

the 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#DISSONANCE

and 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 :userId

and 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 bench

median 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 / 500

532 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