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

@alxia/core

v0.15.1

Published

A zero-dependency, type-safe HTTP framework for Bun: routes validated with any Standard Schema, typed from the request to the reply, OpenAPI spec first

Readme

@alxia/core

An HTTP framework for Bun, type-safe from the request to the reply, with no dependency. Each route declares what it reads and what it answers with any Standard Schema — Zod, Valibot, ArkType, or one written by hand — among its middlewares, and the types follow: the handler reads validated values, and can only answer what it declared.

alxia is OpenAPI spec first: the OpenAPI document, written by hand, is the contract. A client is generated from it with the generator you choose, and so are the server's routes — the examples use @nxgt/openapi-codegen, whose alxia option writes each operation for route(). The operation's schemas check every request and reply at run time, and @alxia/openapi's matchesSpec checks, in a test, that the app routes the document's operations (and, under strict: true, nothing else) (upgrading).

Getting started

bun create @alxia my-app

writes a new app from a template — minimal (the default), api (OpenAPI spec first), graphql or react-router — installs it, and prints cd my-app and bun dev (@alxia/create). Start in 5 minutes walks through it. Into an existing project:

bun add @alxia/core
bun add -d typescript

Everything else is a package of its own, to take or leave: @alxia/zod, @alxia/openapi, @alxia/graphql, @alxia/cors, @alxia/secure-headers, @alxia/rate-limit, @alxia/compress, @alxia/jwt, @alxia/logger, @alxia/env.

A first app

import { alxia, defineMiddleware, responds, validate } from '@alxia/core';
import { zq } from '@alxia/zod';
import { z } from 'zod';

const User = z.object({ id: z.number(), name: z.string() });
const NotFound = z.object({ error: z.literal('not_found') });

const auth = defineMiddleware(async ({ request, reply }, next) => {
	const user = await session(request);
	return user ? next({ user }) : reply(401, { error: 'unauthorized' as const });
});

const app = alxia()
	.get(
		'/users/:id',
		validate({ params: z.object({ id: zq.int() }) }),
		responds({ 200: User, 404: NotFound }),
		async ({ params, reply }) => {
			const user = await findUser(params.id); // params.id: number
			return user ? reply(200, user) : reply(404, { error: 'not_found' });
		},
	)
	.post(
		'/users',
		auth,
		validate({ body: z.object({ name: z.string().min(1) }) }),
		responds({ 201: User }),
		async ({ user, body, reply }) => reply(201, await createUser(user, body.name)),
	);

app.listen(3000);

A route is a path, its middlewares, then its handler. They run in the order given: POST /users answers a stranger 401 before it reads his body, then 400 for a bad body, then 201. listen hands the routes to Bun.serve's own router. app.fetch is the same app as a fetch handler; app.request('/users/1') calls it in process.

What the types refuse

Each of these is a compile error, not a runtime surprise:

app.get('/users/:id', validate({ params: z.object({ name: z.string() }) }), ...); // not the path's parameters
app.get('/users', validate({ quey: z.object({}) }), ...);                          // a typo
app.get('/me', ({ user }, next) => ..., auth, ...);                                // `user` before the middleware that adds it
app.post('/users', { body: User }, auth, ...);                                     // a schema in the options
({ reply }) => reply(201, user);                                                   // behind responds({ 200: User }): a status not declared
({ reply }) => reply(200, { id: '1' });                                            // a body its schema refuses

Middlewares

A middleware is a plain (ctx, next) => … function, written inline or kept in a const and given to every route that needs it. It returns next(added) — added is inferred, and typed in the context of everything after it — a reply, which ends the request, or a Response, sent as it is. Awaited, next() resolves to the response of the rest of the route, so a middleware can run around it. validate and responds are middlewares too, and stand where they are given:

import { alxia, defineMiddleware, responds, validate } from '@alxia/core';
import { z } from 'zod';

const Post = z.object({ title: z.string().min(1) });

const auth = defineMiddleware(async ({ request, reply }, next) => {
	const user = await session(request);
	if (!user) return reply(401, { error: 'unauthorized' as const });
	return next({ user }); // `user`, typed, in everything after
});

const app = alxia().post(
	'/posts',
	{ bodyLimit: 1024 * 1024, detail: { summary: 'Create a post' } }, // options: never a schema
	async (_ctx, next) => {   // inline: the rest of the route, timed
		const started = performance.now();
		const response = await next();
		response.headers.set('server-timing', `app;dur=${performance.now() - started}`);
		return response;
	},
	auth,                     // a 401 before the body is read
	validate({ body: Post }), // a 400, or the reply of a middleware before it
	responds({ 201: Post }),  // types the handler's `reply`, checks it
	({ user, body, reply }) => reply(201, createPost(user, body)),
);
// POST /posts answers 201, 400, 401, 413 or 500

| Piece | Does | | --- | --- | | app.<method>(path, options?, ...middlewares, handler) | up to 8 middlewares, each reading what the ones before it added; the handler last | | options | bodyLimit (bytes, a 413 past it) and detail (summary, operationId, tags…: read by nothing at run time; a generated operation carries it); a schema there does not compile, and throws where the route is declared | | (ctx, next) => … | a middleware: what it passes next is inferred and read by the middlewares after it and the handler. One that calls next() and returns nothing answers with the rest's response, as Koa and Hono do | | defineMiddleware(fn) | types fn and returns it: a middleware kept in a module and shared, reading the base context | | defineAppMiddleware(fn) | the same, reading the Registered context (db, env, user…) with no import of the app | | compose(...middlewares) | several middlewares as one, typed for any number of them: past the 8 a call takes, or to name a set once; spliced into the chain where it stands | | app.use(...middlewares) | up to 8 middlewares, run on every request in the order declared: a route runs the ones declared before it, then its own, and a request no route matches — a 404 — runs them all; what each adds is typed in the routes after it | | app.use(path, ...middlewares) | the same for the requests under path alone, matched against the request's path; they may add nothing | | defineMiddleware<{ user: User; pathParams: { id: string } }>()(fn) | a middleware that reads more than the base context; a route that does not give it, where the middleware is placed, does not compile | | validate({ params, query, headers, cookies, body }) | any Standard Schema per part; what follows reads their output; a request it refuses throws a ValidationError. Before it, a middleware reads the request as it arrived: params as strings, query raw, body undefined. Each validate reads the request as it arrived, a second one of the cookies included | | responds({ 200: Post, 404: NotFound }) | the handler's reply typed by the statuses, and its reply checked and sent as its schema's output: a body it refuses is a 500. A middleware after it that replies with a declared status is checked too; any other status it sends as it is | | validate(operation), responds(operation) | the same, read from an operation's schema: on route(operation, …), where one stands replaces the implicit one the route places just before its handler |

Position is meaning: auth, validate(…) answers a stranger 401 before his body is read; validate(…), auth answers a bad body 400 first. A reply made before a responds is never checked; auth's 401 after it is checked only if responds declares a 401. Both declare their schemas on the route, in app.routes.

A middleware that reads what the context in force does not give is one compile error, on that middleware, naming the key:

Type 'Promise<Next<…>>' is not assignable to type '"`user` is missing from the context: add a middleware that gives it before this one"'.

A ninth middleware is one error on it, "at most 8 middlewares per route: group them with compose(...)"; a factory given uncalled, use(cors), one too, and it throws where it is declared (Development).

0.5 removed the forms of 0.3 — a list of hooks, a schema before the handler — and the request hooks: a middleware does what each did. Upgrading has each one, before and after.

Middlewares for the whole app: use

app.use(...middlewares) runs them on every request, in the order they are declared, Koa and Hono style: the code before await next() runs on the way in, the code after it on the way out. A route runs the ones declared before it, then its own; what they pass next is typed in the routes after them, and not in the routes before. A request no route matches — a 404, a 405, a preflight — runs all of them, wherever declared, then the router's answer: a middleware may answer before it. app.use(path, ...middlewares) runs them on the requests under path alone, matched against the request's path with the syntax of a route's, and they may add nothing to the context: to add to a subtree's, use them in a group.

import { alxia, defineMiddleware } from '@alxia/core';

const admin = defineMiddleware(({ request, reply }, next) =>
	request.headers.has('x-admin') ? next() : reply(403, { error: 'forbidden' as const }),
);
const timed = defineMiddleware(async (_ctx, next) => {
	const response = await next();
	response.headers.set('x-timed', '1');
	return response;
});
const loadTeams = defineMiddleware<{ user: { id: string } }>()(({ user }, next) =>
	next({ teams: [`${user.id}'s team`] }),
);

alxia()
	.get('/health', ({ reply }) => reply(200, 'ok')) // declared before use(…): open
	.use(({ request, reply }, next) => {             // every request after: a 401, or `user` typed
		const id = request.headers.get('x-user');
		return id ? next({ user: { id } }) : reply(401, { error: 'unauthorized' as const });
	})
	.use('/admin', admin)                            // /admin and under: a 403 next
	.get('/me', ({ user, reply }) => reply(200, user))
	.get('/admin/stats', timed, ({ reply }) => reply(200, { users: 1 })) // the guards, timed, handler
	.group('/teams', (teams) =>
		teams.use(loadTeams).get('/', ({ teams, reply }) => reply(200, teams)), // adds, in a group
	);
// GET /missing: 401 without x-user — auth runs before the 404 — and 404 with it

| Path | Runs on | | --- | --- | | '/admin' | /admin and every request under it, segment by segment: not /administrators | | '/admin/*' | the requests under /admin, not /admin itself | | '/users/:any/posts' | :any is any one segment: /users/:id/posts, /users/me/posts/:postId; a literal matches that literal alone |

The request's path is read as the router and the static files read it, fail closed: decoded segment by segment (%2F splits one), empty segments collapsed, without case — /%61dmin, //admin and /ADMIN run use('/admin', guard) too.

use takes middlewares alone, any (ctx, next) function: an app given to it throws use(): argument 1 is an app: a plugin is given to app.plugin(), use() takes middlewares — a plugin goes to app.plugin(…). A socket runs them on its upgrade. derive stays, the shorthand for a middleware that only adds.

Four rules follow from running on every request:

  • ctx.route is string | undefined in a middleware given to use: undefined on a request no route matches. A route's own middlewares and its handler read a string.
  • A guard on the app answers a missing path too: bearer, a required session and rateLimit give an anonymous request to /nowhere its 401, not a 404. A group guards some routes only; use('/api', guard) does for a guard that adds nothing to the context.
  • A group's middlewares stay under its prefix: its routes, and an unmatched request under the prefix, before its 404 or 405 — never a route after the group. So does a plugin app with a prefix of its own, defineRoutes('/todos'); the middlewares of a plugin app without one are the app's. A group without a prefix guards the 405 at its routes' paths, never a 404.
  • A use after a route does not run for it, and does for a request no route matches; in development it warns once, naming the routes.

The order matters. Give the observers first — logger(), telemetry(), secureHeaders(), cors(), compress() — so that they wrap everything, a 404 included. A middleware that answers errors itself (a try/catch around next(), janusErrors()) goes after them, so that they see its reply. An observer settles next() without swallowing the error: the try/catch catches it wherever it stands.

An error is a rejection through next(): a middleware's try { return await next() } catch (error) { … } sees what the rest threw, an HttpError and a validate refusal included. What nobody catches reaches the route's boundary, outermost, which answers an HttpError with its status and body and anything else with a 500 that leaks nothing — but for a client that hung up mid-request, a 499 nobody reads, which also reaches a try/catch as an error. A middleware that must see the response the client will get — a logger, a header on every response, the error's included — settles next() with settle:

import { defineMiddleware, settle } from '@alxia/core';

const poweredBy = defineMiddleware(async (ctx, next) => {
	const response = await settle(ctx, next());
	response.headers.set('x-powered-by', 'alxia'); // a 404 and a 500 carry it
	return response;                               // the error stays on ctx.error
});

Once it returns, the error goes on to the middlewares around it, and the response it made is the one sent when none catches it.

next.behind(added?) runs the rest behind a reply the middleware returns at once — a stale cache entry served while the route refreshes it.

Requests

validate(…) reads each part it is given a schema for; what follows it reads the schema's output. Before it, or without one:

| part | read from | before a validate | | --- | --- | --- | | params | the path, as strings | { id: string }, from the path; pathParams is the same, after a validate too | | query | the query string: a key given once is a string, more than once an array | Record<string, string \| string[]> | | headers | the headers, names lowercased | Record<string, string> | | cookies | the Cookie header; a middleware or a hook before the validate reads it unvalidated | Record<string, string> | | body | by content-type: a parser the app added, JSON, a form, text, or the bytes | undefined: read ctx.request |

A request a validate refuses throws a ValidationError, an HttpError of the 400: nobody catches it, and the answer is a 400 that names every issue, whatever part it is in:

{ "error": "validation", "issues": [{ "target": "params", "path": ["id"], "code": "invalid_type", "message": "…" }] }

A middleware given before the validate answers it in its own format. refusalOf(error) reads the refusal — { kind: 'validation', part, issues }, or { kind: 'body_limit', limit } for a body past its limit — and is undefined for any other error. problem(details) builds an RFC 9457 problem, sent as application/problem+json, its extension members typed:

import { alxia, defineMiddleware, problem, refusalOf, validate } from '@alxia/core';
import { z } from 'zod';

const JmapRequest = z.object({ using: z.array(z.string()), methodCalls: z.array(z.unknown()) });

const jmapErrors = defineMiddleware(async (_ctx, next) => {
	try {
		return await next();
	} catch (error) {
		const refusal = refusalOf(error);
		if (refusal === undefined) throw error; // not a refusal: not ours
		return refusal.kind === 'body_limit'
			? problem({ type: 'urn:ietf:params:jmap:error:limit', status: 413, limit: 'maxSizeRequest' })
			: problem({
					type: refusal.issues.some((issue) => issue.code === 'invalid_json')
						? 'urn:ietf:params:jmap:error:notJSON'
						: 'urn:ietf:params:jmap:error:notRequest',
					status: 400,
					detail: `the ${refusal.part} is invalid`,
				});
	}
});

const app = alxia()
	.use(jmapErrors)
	.post('/jmap', validate({ body: JmapRequest }), ({ reply }) => reply(200, { methodResponses: [] }));

Its reply replaces the 400 and the 413 of every route after it. Branch on refusal.kind to answer each its own way, and rethrow the kinds you leave:

const refusals = defineMiddleware(async ({ reply }, next) => {
	try {
		return await next();
	} catch (error) {
		const refusal = refusalOf(error);
		if (refusal?.kind === 'validation') return reply(422, { detail: `the ${refusal.part} is invalid` });
		if (refusal?.kind === 'body_limit') return reply(413, { limit: refusal.limit });
		throw error; // anything else: the default answer
	}
});

alxia()
	.use(refusals)
	.post('/notes', { bodyLimit: 64 * 1024 }, validate({ body: z.object({ text: z.string() }) }), ({ reply }) =>
		reply(201, 'ok'),
	);
// POST /notes answers 201, 413 { limit: number }, 422 { detail: string } or 500

ip is the client's address — behind a proxy, alxia({ ip: forwardedIp({ trusted: 1 }) }) reads it (Serving) — and server the Bun server, when there is one. HEAD runs the GET route.

Behind a proxy that terminates TLS, declare the proxies once with proxy: ctx.ip is the client's, and originalUrl(ctx) the URL it asked for, scheme and host as your proxy said them, from a trusted connection alone; untrusted: 'refuse' answers 403 to forwarding headers from anywhere else (Behind a proxy):

import { alxia, originalUrl, trustProxy } from '@alxia/core';

alxia({ proxy: trustProxy({ trusted: ['10.0.0.0/8'], untrusted: 'refuse' }) }).get('/where', (ctx) =>
	ctx.reply(200, { ip: ctx.ip ?? null, url: originalUrl(ctx).href }), // https://example.com/where
);

untrusted: 'refuse-all' answers 403 to every request from a connection that is no proxy, headers or not, but for what allow lets through — a probe's path, a loopback peer (Only the proxies). refusal: () => new Response('forbidden', { status: 403 }) answers either one in your own body (Answering the refusal yourself):

alxia({
	proxy: trustProxy({
		trusted: ['10.0.0.0/8'],
		untrusted: 'refuse-all',
		allow: (request) => new URL(request.url).pathname === '/health', // the orchestrator's probe
	}),
});

Where an address cannot name your proxy, have it prove itself with a secret header: trustProxy({ trusted: 1, secret: { header: 'x-app-proxy-secret', value: env.PROXY_SECRET }, untrusted: 'refuse' }) (one hop: a BFF that writes a fresh X-Forwarded-For) believes the forwarding headers only when the secret is right, and @alxia/proxy never forwards it (Trusting a proxy by secret). Each proxy in front of the app that appends to X-Forwarded-For adds one hop: with a platform's load balancer between the BFF and the app, say, count trusted: 2.

ctx.ip is one text per address, from the socket or a header alike: an IPv4-mapped address as IPv4, IPv6 as RFC 5952 writes it (2001:db8::1), so a rate limit, a log line or an allow list sees one client once; canonicalIp(address) gives your own values the same form (One text per address).

query declares a QUERY route: a safe, idempotent read whose criteria are too long or too structured for a query string, so they travel in the body — validated like a POST's, a 400 when refused.

app.query(
	'/users/search',
	validate({ body: z.object({ name: z.string().min(1) }) }),
	responds({ 200: z.array(User) }),
	async ({ body, reply }) => reply.ok(await searchUsers(body.name)),
);

all declares one route for the methods its path has no route of its own for: a GET beside it still goes to the get route, a HEAD to the GET first, and the path never answers 405. It is listed in app.routes as ALL. Its last argument may be a middleware that answers, as @alxia/proxy's proxy(url), in place of a handler: app.all('/api/*', proxy('http://users.internal:8080')) (Every method: all).

app
	.get('/api/health', ({ reply }) => reply(200, { ok: true }))
	.all('/api/*', ({ request, url, reply }) => reply(200, `${request.method} ${url.pathname}`));
// GET /api/health → the get route; DELETE /api/users/7 → "DELETE /api/users/7"

route(operation, ...middlewares, handler) declares the same route from data — { method, path, schema? }, written once and shared, or generated from an OpenAPI document — with the same types and the same compile errors. A variable holding an operation needs as const, to keep its method and path literal. The operation's schema is a validate of its request and a responds of its responses, just before the handler, so a middleware such as auth answers before the body is read, and its 401 is its own, never checked by the operation's. A validate(operation) or a responds(operation) among the middlewares stands where it is given instead, once — a responds(operation) first checks every middleware's reply after it too:

const getUser = {
	method: 'GET',
	path: '/users/:id',
	schema: { params: z.object({ id: z.coerce.number().int() }), response: { 200: User } },
} as const;

const renameUser = {
	method: 'PATCH',
	path: '/users/:id',
	schema: {
		params: z.object({ id: z.coerce.number().int() }),
		body: z.object({ name: z.string().min(1) }),
		response: { 200: User },
	},
} as const;

app
	.route(getUser, async ({ params, reply }) => reply.ok(await findUser(params.id)))
	// auth answers 401 before the body is read; the handler reads it validated
	.route(renameUser, auth, async ({ params, body, reply }) => reply.ok(await rename(params.id, body.name)));
// validate(renameUser), auth: a bad body is a 400 before auth runs, and is validated once

Body size

bodyLimit, in bytes, caps a route's request body below the server's maxRequestBodySize. Set it in a route's options, or call bodyLimit(bytes) for every route declared after the call: on the app, for every later route; in a group, for the group's routes alone. A route's own limit wins.

const app = alxia()
	.bodyLimit(64 * 1024)
	.post('/notes', validate({ body: z.object({ text: z.string() }) }), ({ body, reply }) => reply.created(body))
	.post('/upload', { bodyLimit: 25 * 1024 * 1024 }, async ({ request, reply }) => {
		await Bun.write('upload.bin', new Response(request.body));
		return reply.noContent();
	});

A Content-Length over the limit is refused without reading the body. Without one, the bytes are counted as they arrive, and reading stops once they pass the limit, so a chunked upload is never buffered whole. The limit applies to JSON, forms, text, an app's own parsers, and a handler reading request.body as a stream. Either way the answer is a 413, which the route's operation in the OpenAPI document declares:

{ "error": "content_too_large", "limit": 65536 }

It is a refusal, as a 400 is: a ContentTooLargeError, which refusalOf reads as { kind: 'body_limit', limit }, so the middleware above answers it in its own format, JMAP's limit problem there, sent with 413 by that app's choice. A route with no limit reads its body as before.

Replies

A handler returns reply(status, body, init?). Behind a responds(…), only a declared status, with a body its schema accepts. Without, any status and any body. Shortcuts — reply.ok(body), reply.created(body), reply.noContent(), reply.notFound(body), reply.html(status, html), … — are the same replies; behind a responds, a route has one only for a status it declares.

The body sent is the output of the schema: an unknown key it strips — a password hash — never leaves the server. A reply its schema refuses is a 500, never an undeclared shape (validateResponses: false skips the check).

A string is text/plain, a Blob — a Bun.file — a stream or a buffer goes as it is, an async iterable is a stream of server-sent events, anything else is JSON. redirect(location, status?) needs no schema. set.headers and set.cookies (a Bun.CookieMap of the response's cookies, empty at first) apply to every reply.

Static files

Served through the app's pipeline: every middleware runs around them — headers, compression, telemetry — like any route.

const app = alxia()
	.static('/assets', './public', {
		cacheControl: (path) =>
			/\.[0-9a-f]{8}\./.test(path) ? 'public, max-age=31536000, immutable' : 'no-cache',
		precompressed: ['br', 'gzip'],    // app.js.br, app.js.gz beside app.js
	})
	.file('/favicon.ico', './static/favicon.ico')
	.static('/', './dist', { fallback: 'index.html' }); // a single-page app

static(path, source, options?) is a GET route at path/*. file(path, file, options?) serves one file. Both answer:

  • 304 to a client whose copy is current: a weak ETag and Last-Modified;
  • 206 to a Range — a video seeking — and 416 to one past the end; If-Range honored;
  • 404 { error: 'not_found' } to no file, a dotfile, or a path that leaves the source — .., an encoded slash, a backslash;
  • HEAD, as every GET route.

| option | default | | | --- | --- | --- | | index | 'index.html' | the file a directory serves: one, a list tried in order, or false | | extensions | none | tried for a path without one: ['html'] serves /about from about.html | | fallback | none | served with a 200 for a path that matches no file: a single-page app | | precompressed | none | br, zstd, gzip: a file stored compressed beside itself, to a client that accepts it | | cacheControl | public, max-age=0 | a value, false, or one per path | | headers | none | headers, or (path, file) => headers | | types | Bun's | content types by extension: { '.wasm': 'application/wasm' } | | etag, lastModified, ranges | on | | | dotfiles | false | whether .env and the like are served |

Any source. A directory, or a function from a path to a Blob — so files come from anywhere Bun reads them:

const files = new Map([['logo.svg', new File([svg], 'logo.svg', { type: 'image/svg+xml' })]]);
app.static('/memory', (path) => files.get(path));                 // files held in memory

app.static('/media', async (path) => {                             // an S3 bucket
	const file = Bun.s3.file(`media/${path}`);
	return (await file.exists()) ? file : null;                       // null is a 404
});
app.file('/sitemap.xml', async () => new Blob([await sitemap()], { type: 'application/xml' }));

Bun's HTML bundles

import dashboard from './dashboard/index.html';

app.page('/dashboard', dashboard).listen(3000);

page(path, bundle) hands Bun's full-stack bundling its route: the page's scripts and styles bundled by Bun, hot-reloaded under development. It is served by Bun.serve itself — so through listen only, and outside the app's middlewares.

Server-sent events

import { eventStream, responds } from '@alxia/core';

app.get('/ticks', responds({ 200: eventStream(Tick) }), ({ reply }) =>
	reply(200, (async function* () {
		for (let n = 0; ; n++) { yield { n }; await Bun.sleep(1000); }
	})()),
);

Each value is checked by the event's schema and sent as one data: line of JSON; a comment keeps an idle stream open, and the generator is closed when the client leaves. yield sseComment('text') sends a comment of your own (: text) between events: a marker, padding against a proxy, a heartbeat.

Name the events, each with its schema, and each is sent with its event: line, plus id: and retry: when given:

const Push = eventStream({ state: StateChange, ping: Ping });

app.get('/push', responds({ 200: Push }), ({ reply }) =>
	reply(200, (async function* () {
		yield Push.event('ping', { interval: 30 });
		yield Push.event('state', change, { id: 's42' });
	})()),
);
// event: ping
// data: {"interval":30}

Push.event(name, data, fields?) types each by its name's schema. A line break in an id, or a retry that is not a whole number, ends the stream before it is written.

WebSockets

app.ws('/rooms/:room', { message: Chat, send: Chat }, auth, validate({ query: z.object({ v: z.literal('1') }) }), {
	open: (socket) => socket.subscribe(socket.data.params.room),
	message: (socket, chat) => socket.publish(socket.data.params.room, chat), // socket.data.user: auth's
});

ws(path, options?, ...middlewares, handlers): the upgrade request runs the middlewares declared before the route, then its own — a 401 or a 400 never becomes a socket. The options are message, send and detail. Each message is parsed as JSON and checked by message (a refused one is answered with its issues, the socket kept open); each one sent is checked by send. socket.data holds what the middlewares added and validate gave back. A middleware that awaits next() gets an empty 200 stand-in once the socket is open; what it returns after is ignored (a header set on the stand-in is lost: set set.headers before). An upgrade(data, headers) handler runs after the middlewares and is awaited before the 101; a throw from it answers the upgrade request in the app's error format, and no socket opens. responds has no reply to check on a socket, and is refused there. Sockets need a server: listen, or Bun.serve({ fetch: app.fetch, websocket: app.websocket }).

A route that is not a socket reaches the subscribers through ctx.server, the Bun.Server serving the request:

app.post('/rooms/:room/notice', async ({ server, params, request, reply }) => {
	server?.publish(params.room, JSON.stringify({ notice: await request.text() })); // Bun's own: not checked by `send`
	return reply(202);
});

Context and lifecycle hooks

What every middleware and handler reads, whatever adds to it:

| ctx. | | | --- | --- | | request, url | the request, and its URL as it arrived (originalUrl(ctx) behind a proxy) | | server | the Bun.Server serving it: listen's, or the one given to app.fetch(request, server); undefined under app.request or app.fetch(request) alone. publish(topic, data) to the app's sockets, timeout(request, seconds) for a long request (Serving) | | ip | the client's address, as ip or proxy read it; prefer it to server.requestIP(request) | | route, pathParams | the route's declared path, undefined where no route matched, and the path's parameters as strings | | params, query, headers, cookies, body | the request's parts, as a validate gave them back or as they arrived (Requests) | | set, reply, redirect | the response's headers and cookies, and the answers | | error | the error the route failed with, once it has |

A middleware belongs to the routes it is given to; use gives it to the app. derive and decorate are the shape of a middleware that only adds to the context of every route after them:

const app = alxia()
	.decorate({ db })                                  // ctx.db, everywhere after
	.get('/health', ({ reply }) => reply(200, 'ok'))   // not guarded
	.derive(async ({ request, reply }) => {
		const user = await authenticate(request);
		return user ? { user } : reply(401, { error: 'unauthenticated' as const });
	})
	.get('/me', ({ user, reply }) => reply(200, user)); // ctx.user is typed

A reply a derive returns ends the request: /me answers the 401 to a stranger. Like a use, a derive and a decorate run on a request no route matches, so GET /missing answers 401 to a stranger too.

The middlewares before a validate, and derive, read the request as it arrived: pathParams holds the path's parameters as strings, and cookies the request's cookies, parsed on first read. A validate({ cookies }) gives what follows it the validated ones. set.cookies is the response's — its get reads what the response set, so it is null in a middleware for a cookie the request sent:

.derive(({ cookies }) => ({ user: sessions.get(cookies['sid'] ?? '') ?? null }))

Which one to reach for — a middleware, derive, a group, a plugin — where each applies and the order a request runs them in: Middleware: which way to use.

onStart(server), onStop(server) (the server that stopped, or undefined before listen) and parser(type, parse) apply to the whole app: they run with listen and its shutdown, and a body parser is tried before the built-in ones. The request hooks of 0.3 were removed in 0.5: what each did is a middleware given to use (upgrading).

Groups

app.group('/admin', (admin) =>
	admin.use(requireAdmin).get('/stats', ...),   // the guard, and what it adds, apply here only
);

A group's routes are under its prefix and keep the middlewares declared before it; the ones it adds stay inside: its routes, and a request no route matches under its prefix, before the 404 or 405; never a route declared after the group, nor a request outside the prefix. group(build), without a prefix, is a scope alone: it adds nothing to a 404, and its guard still answers a 405 at its routes' paths, before the Allow that would name them.

Plugins

A plugin is an app, or a function given the app that returns it, mounted by app.plugin(…); a middleware is given to use. What the other packages call plugins — logger(), cors(), bearer(…) — are middlewares: app.use(logger()).

// an app: its routes, its context, its replies — all typed
const auth = alxia().derive(async ({ request }) => ({ user: await authenticate(request) }));
const users = alxia({ prefix: '/users' }).get('/:id', ...);

const app = alxia({ prefix: '/api' }).plugin(auth).plugin(users); // GET /api/users/:id

// a function: lifecycle hooks and parsers, the app's type unchanged
const closing = (pool: { end(): Promise<void> }): Plugin => (app) =>
	app.onStop(() => pool.end());

alxia().plugin(auth).plugin(closing(pool));
// a middleware needs no plugin: export it, and give it to `use`

plugin(app) mounts its routes under this app's prefix and behind this app's middlewares; its derives and middlewares then apply to the routes declared after it, and become this app's, so they run on a request no route matches too. plugin(fn) calls fn with the app and returns what it returns, which must be an app: anything else throws plugin(): the plugin function returned …, not an app.

A plugin that reads what an earlier one added names it with definePlugin, and an app that does not give it cannot mount it:

import { alxia, definePlugin } from '@alxia/core';

const tenants = new Map<string, { name: string }>();
const auth = alxia().derive(({ request, reply }) => {
	const tenantId = request.headers.get('x-tenant');
	return tenantId ? { user: { tenantId } } : reply(401, { error: 'unauthenticated' as const });
});

const tenant = definePlugin<{ user: { tenantId: string } }>()((app) =>
	app.derive(({ user }) => ({ tenant: tenants.get(user.tenantId) ?? null })),
);

alxia().plugin(auth).plugin(tenant); // ok: auth adds a user, or answers 401
alxia().plugin(tenant);              // compile error: the plugin reads "user", which this app's context does not give

Route files: Register and defineRoutes

Register the chain that builds the context once, and a file of routes reads it with no import of the app:

// src/context.ts — the base: what every route reads
import { alxia } from '@alxia/core';

export const base = alxia()
	.decorate({ db })
	.derive(async ({ request, reply }) => {
		const user = await session(request);
		return user ? { user } : reply(401, { error: 'unauthorized' as const });
	});

declare module '@alxia/core' {
	interface Register {
		context: typeof base;
	}
}

// src/routes/todos.ts — imports @alxia/core, not the app
import { defineRoutes } from '@alxia/core';

export const todos = defineRoutes('/todos')
	.get('/', ({ db, user, reply }) => reply(200, db.todos.of(user.id)));

// src/app.ts
import { alxia } from '@alxia/core';
import { base } from './context';
import { todos } from './routes/todos';

export const app = base.plugin(todos);
alxia().plugin(todos); // compile error: the plugin reads "db" | "user", which this app's context does not give

defineRoutes(prefix?) is alxia({ prefix }) typed with the registered context, which it requires of the app that mounts it. AppContext is that context, for a service or a resolver. Register base, never the app that mounts the routes: their type reads Register, so the app would be typed by itself (TS7022). Nothing registered, AppContext is BaseContext. A defineAppMiddleware(fn) reads the registered context too, and is checked where it is given: a route whose context does not give it is a compile error. defineMiddleware(fn) reads BaseContext alone, as an inline middleware does: write the middlewares the registered base is itself built with that way.

import { defineAppMiddleware } from '@alxia/core';

// src/audit.ts: db and user typed from the registered base, no import of the app
export const audit = defineAppMiddleware(async ({ db, user }, next) => {
	await db.audit.add(user.id);
	return next();
});

Several apps on one base: fork(). Every method declares on the app it is called on, base included: base.plugin(todos) mounts the routes on base itself, so a second app built on it — a spec's, a variant — declares them again and throws GET /todos is declared twice. Build each app on a fork: a copy of the base, typed as it is, that shares nothing declared next.

export const app = base.fork().plugin(todos);
const fakeSession = defineMiddleware((_ctx, next) => next({ user: { id: 'ada' } }));
const testApp = base.fork().use(fakeSession).plugin(todos); // its own routes, middlewares and hooks

A tool that reads app.routes — a route check, a document — finds a path as the core declares and matches it with joinPath and shapeOf:

import { joinPath, shapeOf } from '@alxia/core';

joinPath('/api', '/pets/:petId');                    // '/api/pets/:petId'
joinPath('/api', '/');                               // '/api'
shapeOf('/pets/:id') === shapeOf('/pets/:petId');    // true: the router sends them the same requests

Writing a plugin covers all three kinds.

Errors as problem details

What alxia answers on its own — an HttpError no middleware catches, a refused request's 400, a 413, a 500, the router's 404, 405 and 426 — is a { error: … } body by default. errors: 'problem' makes each an RFC 9457 problem, sent as application/problem+json, with type, title, status, detail and instance:

import { alxia, HttpError } from '@alxia/core';

const app = alxia({ errors: 'problem' }).get('/users/:id', ({ params }) => {
	throw new HttpError(404, { error: 'no_user' }, { detail: `No user ${params.id}` });
});
// GET /users/7 → 404 {"type":"about:blank","title":"Not Found","status":404,
//                     "detail":"No user 7","instance":"/users/7"}
// a refused body → 400 { …, "detail":"The request's body is invalid", "issues": [ … ] }

An HttpError takes type, title, detail and extensions; errorFormat(ctx) and problemOf(ctx, init) let a middleware answer its own errors in the app's format, as @alxia/jwt and @alxia/janus do. A GraphQL error stays GraphQL's, inside a 200. The Problem schema for the OpenAPI document, and the validationErrors setting of a generated client: Errors and problem details.

Health and shutdown

health() adds the probes, and listen shuts down gracefully on SIGTERM and SIGINT: readiness turns 503, new connections are refused, sockets close with 1001, the requests in flight finish within shutdownTimeout (10 s), the onStop hooks run within stopTimeout (5 s; past it the hung hook is named and the shutdown fails: on a signal, the exit code is 1; under stop(), the promise rejects), and the process exits. exit: false shuts down without process.exit, and a process with other listeners for the signal is left to them to exit. listen on an app that already listens throws; the onStop hooks run once per listen.

import { alxia, health } from '@alxia/core';
import { bearer } from '@alxia/jwt';

const app = alxia()
	.plugin(health({ checks: { redis: () => redis.ping(), db: () => sql`select 1` } }))
	.use(bearer({ jwt })) // the probes need no token
	.onStop(() => sql.end());

app.listen({ port: 3000, shutdownTimeout: 15_000, stopTimeout: 5_000 });
// GET /health → 200 { status: 'ok' }
// GET /ready  → 200, or 503 { status: 'down', checks: {} }
//   in dev, or with details: true → checks: { db: { status: 'down', duration: 1000, reason: 'timeout' } }

Each check has a timeout, and the report is cached for cache ms so probes do not hammer the dependencies; a check still running from an earlier probe is not started again. details, whether /ready names each check with its status and duration, follows the serving app's dev switch: shown in dev, hidden elsewhere, since a probe is often reachable from outside. shutdownSignal(ctx) ends a long response when the shutdown starts; streams of events end by themselves. @alxia/openapi's matchesSpec leaves the probes out by itself: Health and shutdown.

Development

alxia({ dev }), on only when NODE_ENV is development, helps the developer running the app: listen prints its URL and its routes, a 404 or a 405 the router answers carries a hint, and a 500 shows its error — a page to a browser, its stack to any other client. Outside dev, each is off and costs nothing.

import { alxia, defineMiddleware } from '@alxia/core';
import { cors } from '@alxia/cors';
import { logger } from '@alxia/logger';

const todos = new Map([['7', { id: '7', title: 'Write the docs' }]]);
const auth = defineMiddleware((_ctx, next) => next({ user: 'ada' }));

const app = alxia() // dev: NODE_ENV=development bun --hot src/server.ts
	.use(cors(), logger())
	.get('/todos/:id', auth, function getTodo({ params, reply }) {
		return reply(200, todos.get(params.id) ?? null);
	});

app.listen(3000);
// alxia listening on http://localhost:3000/ (dev)
//   GET  /todos/:id  cors › logger › auth → getTodo

// GET /todo/7          → 404 { "error": "not_found", "hint": "did you mean GET /todos/:id?" }
// POST /todos/7        → 405 { "error": "method_not_allowed", "hint": "/todos/7 allows GET" }
// a throw, to a browser → 500, an HTML page: the error, its stack, the app's own lines marked
// a throw, to fetch     → 500 { "error": "internal", "stack": "Error: …" }

listen({ onListen }) is told the URL, the routes and the table in every mode, in place of the print. The page loads nothing and brings its own Content-Security-Policy, which @alxia/secure-headers keeps; a GraphQL error stays in Yoga's errors[]. Declared on any mode: a factory given uncalled throws (use(): argument 1 looks like a factory (cors): call it, use(cors())) — mark your own with markFactory(fn) — and compose(...) joins middlewares past the 8 a call types: Development.

API

| export | | | --- | --- | | forwardedIp({ header?, trusted, canonical? }), ForwardedIpOptions, TrustedProxies | the ip option for an app behind proxies: the client from X-Forwarded-For or Forwarded, read from the right past trusted hops or CIDR ranges, never the first entry the client writes; the connection's address when the header does not name it; canonical unless canonical: false | | trustProxy({ trusted, secret?, header?, untrusted?, allow?, refusal?, canonical? }), proxySecretHeader(ctx), ProxySecret, TrustProxyOptions, StrictProxyOptions, SecretStrictProxyOptions, ProxyAllow, ProxyTrust, Forwarded, ProxyRefusal, ProxyRefusalAnswer, RefusedRequest, Origin | the proxy option: the proxies in front of the app, declared once, for ctx.ip and the scheme, host and port (X-Forwarded-Port) the client asked for, read from a trusted connection alone and checked (http/https, a bare host[:port]); untrusted: 'refuse' answers 403 to forwarding headers from any other connection, 'refuse-all' (StrictProxyOptions) to every request from one but what allow (CIDR ranges or a (request, peer) => boolean) lets through; refusal (ProxyRefusalAnswer, given a RefusedRequest) answers that 403 as the app says, a Response that stays a 403 | | canonicalIp(address) | an address's one text, as ctx.ip gives it: brackets and port dropped, IPv4-mapped as IPv4, IPv6 as RFC 5952 writes it, a zone id kept; what is no address as given | | originalUrl(ctx) | a copy of ctx.url with the scheme and host the trusted proxy said, under proxy; ctx.url's own otherwise | | alxia(options?), AlxiaOptions | a new app: prefix, validateResponses, ip, proxy, errors, dev (on only when NODE_ENV is development: the route table, the 404 hint, the dev error page) | | Alxia<Ctx, Prefix> | get post put patch delete options head query all route ws, static file page, use derive decorate bodyLimit onStart onStop parser, group plugin fork, fetch websocket request listen stop, routes sockets server; Ctx is what a route declared next reads, Prefix the app's prefix | | eventStream(schema), EventStreamSchema | the response schema of a stream of events | | isEventStreamSchema(schema) | whether a schema is one eventStream(schema) made | | eventStream({ name: schema }), NamedEventStreamSchema, EventSchemas | the response schema of a stream of named events, its event(name, data, fields?) builder, its schemas by name under ~events | | EventInput<Of>, EventOutput<Of>, EventFields | what a handler yields on a named stream, what is sent of it, and an event's id and retry | | sseComment(text), SseComment | a comment line to yield between a stream's events, as : text (one : line per line of the text, no line break can start an event), unchecked by the schema | | isNamedEventStreamSchema(schema) | whether a schema is one eventStream({ … }) made | | FileSource, StaticOptions, FileOptions, StaticReply, parseRange | static files | | Precompressed, FileNotFoundBody, RangeNotSatisfiableBody | a coding stored beside a file, the bodies of the 404 and 416 | | Reply, HttpError, ResponseValidationError | what a handler returns or throws | | HttpErrorOptions | an HttpError's third argument, or its message alone: message, type, title, detail, extensions, cause — what its problem is made of under errors: 'problem' | | ErrorFormat, errorFormat(ctx) | 'json' \| 'problem', and the format of the app serving the request: what a middleware reads to answer its own error as alxia would | | Problem<Status, Extensions>, ValidationProblem, ContentTooLargeProblem, ProblemInit, problemOf(ctx, init) | a problem as alxia sends it — type, title, status, detail, instance, then its extensions — the 400's and the 413's, and the problem init describes on a request, its defaults filled as alxia fills them | | health(options?), HealthOptions, HealthCheck, CheckResult, ReadinessReport, LivenessReport | the probes as a plugin app: GET /health, 200 while the process is up; GET /ready, the checks run at once with a timeout, cached for cache ms, 200 or 503, and 503 from the moment shutdown starts | | isHealthRoute(route) | whether a route is one of health()'s, wherever mounted: what @alxia/openapi's matchesSpec leaves out | | shutdownSignal(ctx) | an AbortSignal aborted as soon as the app serving the request starts shutting down: what a long response ends on | | isDev(ctx) | whether the app serving the request is in dev (alxia({ dev }), else NODE_ENV=development): what a plugin reads to help the developer there alone, as graphql()'s GraphiQL and health()'s details do | | ValidationError, refusalOf(error) | what validate throws — an HttpError of the 400, its refusal { kind: 'validation', part, issues } and its default body — and what reads the Refusal of it or of a ContentTooLargeError, else undefined: how a middleware before a validate answers a refusal | | reportOperation(ctx, { type, name? }), operationOf(ctx) | the operation a query-language endpoint ran, for the observers around it: @alxia/graphql reports each operation it executes, @alxia/logger and @alxia/telemetry read one summary ({ type, name }, or type: 'batch' and every name, joined by commas, for several). undefined when none was reported; a socket's operations go to startOperation. Types OperationReport, OperationSummary | | onOperation(ctx, observer), startOperation(ctx, report) | the operations a socket runs after its upgrade, for the observers around that upgrade: an observer subscribes before next() (true on a socket's upgrade, false and nothing subscribed elsewhere), observer(report) is called as each starts and may return (outcome) => void, told 'ok' or 'errors' once it ends; the plugin serving the socket calls startOperation(socket.data, report) and the function it returns once the operation ended. @alxia/graphql tells them over ws, @alxia/logger and @alxia/telemetry subscribe. An observer that throws is logged and skipped. Types OperationObserver, OperationOutcome | | settle(ctx, next()) | for a middleware that must see the final response: resolves to what next() resolved to or, when it rejected, to the answer the route boundary would give — an HttpError with its status and body, a 500 — with the error on ctx.error | | ContentTooLargeError, ContentTooLargeBody | what reading a body past its route's bodyLimit throws — a body_limit refusal — and the body of its default 413: { error: 'content_too_large', limit } | | ReplyInit | a reply's options: headers | | AnyReply, FreeReplyFunction, TypedReplyFunction, DeclaredReply, RedirectFunction | any reply, reply without and behind a responds, every reply a route with schemas may return, redirect | | FreeShortcuts, TypedShortcuts, SHORTCUTS, Shortcuts | reply's shortcuts without and with schemas, and the status of each | | Plugin, AnyAlxia | a function plugin, any app | | compose(...middlewares), Composed<Ms>, Composable, ComposedReads<Ms> | one middleware standing for several, typed for any number: its members spliced into the chain where it stands, what they add passed on, what they read and no member before adds required where it is given (ComposedReads); a validate or responds among them refused by use | | markFactory(factory, kind?), FactoryKind | marks a function that makes a middleware ('middleware', the default) or a plugin ('plugin'): given uncalled to use, a route, ws or plugin, it throws at declaration, naming it; every package's factory is marked | | TooMany, TooManyMiddlewares | the overload a call with a ninth middleware meets, and its message: at most 8 middlewares per route: group them with compose(...). Exported so an app's type can be named in a declaration file | | defineMiddleware(fn), defineMiddleware<Requires>()(fn), NoMiddlewareYet | a middleware, (ctx, next) => …, typed and returned, to be shared: next(added) adds added to the context after it, next.behind(added?) runs the rest behind a reply it returns at once, a reply ends the request, a Response is sent as it is, nothing once next() was called is the rest's response. defineMiddleware(fn) reads BaseContext alone; Requires is what it reads beyond it. Either way the route must give it where the middleware is placed. NoMiddlewareYet is what defineMiddleware<Requires>() infers before it is given the middleware. A plain (ctx, next) function needs none of it: given inline, it reads the context in force | | validate(schemas), validate(operation), RequestSchemas, Validated<Schemas>, ValidateRequires<Schemas> | the middleware that validates params, query, headers, cookies and body, each with any Standard Schema — or the request parts of an operation's schema, which its route then validates nowhere else; what it takes, what it passes on, the path parameters its params schema must read | | responds(responses), responds(operation) | the middleware that types the handler's reply by the statuses it declares, and checks the replies after it of those statuses against their schemas; given an operation, its schema.response, which its route then checks nowhere else — it throws for an operation that declares none | | BuiltinMark<Kind> | the mark validate and responds carry in their type, '~builtin': 'validate' \| 'responds'; at runtime Symbol.for('alxia.builtin'), so one made by another copy of @alxia/core is still recognised, and use refuses it | | Middleware<Requires, Result>, MiddlewareContext<Requires>, MiddlewareResult, MiddlewareReturn, Next<Added, Schema>, NextFunction | a middleware, what it reads (BaseContext & Requires), what it may return, and next: called once at most, it resolves to the rest of the route's Response, branded by what was added. A middleware typed Middleware alone adds nothing: its result's brand is Next, never any | | RouteOptions, SocketOptions | a route's options, bodyLimit and detail; a socket's, message, send and detail | | RouteMethod's MiddlewareForms and OptionsForms; SocketMethod's SocketForms and SocketOptionsForms; RouteApp, AppWithRoute | the forms of a route method and of ws: without and with options; the app's types and the method, as those forms read them; the app a call returns, unchanged in type. Exported so an app's type can be named in a declaration file | | AllMethod<Ctx, Prefix>, AllEnd<Ctx, Prefix, Path> | all: the route method's forms, and the two that end the route with a middleware that answers (path, options?, end); that middleware, read from the route's context, its next() a 404 | | PluginMethod | the type of plugin: an app, or a function given the app that returns it, checked against what the plugin requires. Exported so an app's type can be named in a declaration file | | UseForms, ScopeMiddleware, PathMiddleware, AddingNothing, ScopePathAt, AppAfterUse | the forms of use — middlewares, with a path or without — what each middleware form takes, the check that a middleware given a path adds nothing (Invalid middleware: …), the check of that path (a route's, with no trailing /), and the app after them. Exported so an app's type can be named in a declaration file | | defineAppMiddleware(fn) | defineMiddleware<RegisteredContext>()(fn): a shared middleware reading the registered context, refused where the route's context does not give it | | Register, AppContext, RegisteredContext | the interface an app augments with context: typeof base, that base's context — BaseContext when nothing is registered — and what the base adds to BaseContext, which defineAppMiddleware(fn) reads | | defineRoutes(prefix?) | an app plugin built on the registered context, requiring it of the app that mounts it with plugin: a file of routes with no import of the app | | RegisteredOf<R>, RegisteredBase, InvalidRegister, RoutesContext | the app a Register-shaped interface names (a fresh app when it names none), the one Register names, what a context that is not an app reads as (every key of the app's own a compile error), and the context defineRoutes starts from, its requirement in it | | RequiringContext<Requires>, RequiredIn<PluginCtx>, Mounted<PluginCtx>, MountedIn<Ctx, PluginCtx, PluginPrefix> | the requirement a defineRoutes plugin carries in its context (a function type, never set, so a route reading it gets nothing usable), what it requires of the app that mounts it — checked by plugin(app), plugin((app) => plugin) and a group returning it (plugin-method.ts) — and what it adds to it: nothing when the plugin has a prefix of its own (MountedIn). Exported so an app's type can be named in a declaration file | | definePlugin<Requires>()(build) | an app plugin built on an app whose context has Requires; plugin refuses it on an app that does not give them | | Requiring<Requires>, ProvidedBy<Ctx, Requires> | the marker on a definePlugin plugin, and the check plugin makes of it | | RequiresOf<Ctx, Callback?> | what a callback annotated Ctx reads beyond BaseContext — { user: User } for BaseContext & { user: User }, Empty for nothing more: the Requires of a plugin that infers it from a callback it is given. A callback annotated any is refused on every app, with a message naming Callback | | ListenOptions | the options of listen: port, hostname, development, idleTimeout, maxRequestBodySize, tls, shutdownTimeout (10 000 ms), stopTimeout (5 000 ms, the onStop hooks' bound), signals (['SIGINT', 'SIGTERM'], or false), exit (false: no process.exit on a signal), onListen | | ListenInfo, RouteRow | what onListen is told once the server listens, in every mode: server, url, routes, table, dev; a route as the table shows it: method (WS, PAGE), path, middlewares, handler (ws, static, file, page, or its name) | | StartHook, StopHook, BodyParser | the hooks of onStart and onStop, and a body parser | | joinPath(prefix, path) | a path under a prefix, as the app joins them: joinPath('/api', '/') is '/api'; typed JoinPath | | shapeOf(path) | the path with its parameter names erased, as the router compares them: shapeOf('/pets/:id') === shapeOf('/pets/:petId'); throws a TypeError for a path no route may be declared at | | withHeaders, vary, check | for plugins: edit a response's headers (copied when immutable; an error of the edit leaves the body unread), add to Vary, run a schema | | Checked | what check returns: the value, or its issues | | Jsonify<T> | what a value is on the wire | | ContextOf<App> | what a route declared next on App reads: to type a GraphQL schema, a service | | RequestContext, BaseContext, Context, ResponseSettings, HandlerResult | what every middleware reads (BaseContext.cookies: the request's; BaseContext.route: the route's path as declared, undefined on a request no route matches), what a handler reads, what a route sets on its response, what a handler may return | | ResponseCookies | set.cookies: Bun's CookieMap of the cookies the response sets, whose get and has read those, never the request's | | RouteSchema, ResponseSchemas, RouteDetail, ValidSchema, RouteMethod (path, options?, ...middlewares, handler), RouteDefinition, SocketDefinition | a route: what it validates, its detail (summary, operationId, …), the checks its schema's type cannot express, a route method, a route (method: 'ALL' for an all route) and a socket as the app runs them | | RouteOperation, OperationSchema, OperationMethod, CheckedOperation | a route as data for route: { method, path, schema? }, its schema (or Empty), the type of route — OperationForms — and the check it makes of the operation | | OperationForms, OperationApp, OperationParts, OperationOptions, OperationResponds, OperationValidate | route(operation, ...middlewares, handler), up to 8 middlewares; the app it reads; the operation's request parts, its options (bodyLimit, detail), and the implicit validate and responds it threads, just before the handler. Exported so an app's type can be named in a declaration file | | StaticMethod, FileMethod, PageMethod, DecorateMethod, DeriveMethod, BodyLimitMethod, StartHookMethod, StopHookMethod, ParserMethod, GroupMethod, UseMethod, PluginMethod, RequestMethod, ListenMethod | the types of the app's other methods, each holding its overloads and their documentation: static, file, page; decorate, derive, bodyLimit; onStart, onStop, parser; group, use and plugin; request and listen. Exported so an app's type can be named in a declaration file | | SocketMethod, SocketSchema, SocketContext, Socket, SocketHandlers, SocketSend, SocketMessage | sockets: the type of ws (path, options?, ...middlewares, handlers), the parts of a socket route as the app reads them, what its handlers read, send and receive | | StandardSchemaV1, StandardResult, StandardIssue, InferInput, InferOutput | the Standard Schema types | | ValidationErrorBody, InternalErrorBody, RoutingErrorBody | the bodies of the 400, 500, 404, 405 and 426, under errors: 'json'; in dev alone, the 500's stack and the 404's and 405's hint | | ValidationIssue, ValidationTarget | one issue of a 400, and where the refused value was read from | | Refusal, ValidationRefusal, BodyLimitRefusal, RequestPart | what refusalOf reads: the refusal by kind — validation, with the part that failed first and its issues, or body_limit, with the route's limit | | RefusalKind, RefusalOfKind<Kind> | the kinds of refusal, 'validation' \| 'body_limit', and the refusal of one kind: refusalOf(error) narrowed by its kind | | problem(details, init?), ProblemDetails | a reply whose body is an RFC 9457 problem — type, title, status, detail, instance and typed extension members — sent with its status as application/problem+json | | RoutePath, JoinPath, PathParams, PathParamName | paths: an absolute path, a prefix joined to a path, the parameters a path declares | | PathAt<Prefix, Path, Route?>, CheckedPath<Path>, StaticPath<Path> | the check a route method makes on a literal path: Path, or Invalid path: … with the TypeError the app would throw; the same for a path alone; the route static(path) declares. A wrapper forwarding a path generic in P types its parameter PathAt<'', P> | | StatusCode, InformationalStatus, SuccessStatus, RedirectStatus, ClientErrorStatus, ServerErrorStatus | every status a route may declare, and each class of them | | Method, Empty, MaybePromise, Simplify | an HTTP method, no properties, a value or its promise, an object type with its intersections flattened |

Documentation

  • Guide: a page per area — routes and validation, replies, errors and problem details, middleware, lifecycle hooks, groups and plugins, writing a plugin, static files, server-sent events, WebSockets,