server
API routes, webhooks from a payment provider, a realtime channel for order updates: anything that runs outside the browser needs a server underneath. @kitr/server is that server. It boots your providers in order, gives every request an id and a logger, turns thrown errors into clean JSON, and waits for in-flight requests before it shuts down.
It is built on h3 and served by srvx, so the same server runs on Node, Bun, Deno and Cloudflare Workers, standalone or inside TanStack Start. It never imports React, so a pure backend built with kitr ships none.
@kitr/server runs on the server only. It is also a platform: server() in kitrrc.ts tells kitr dev, kitr build and kitr start how to run it. To define routes and controllers on top of it, add API routes; for functions your own React UI calls, use Actions.
The examples on this page build the Acme order service at app.acme.com: a server that answers /api/orders, logs each request with its id, and closes its database pool on shutdown.
Install
Add the server as a platform named api:
kitr add server apiFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
server is the platform type and api is the name you address it by (kitr dev api). kitr add server with no name uses api, the usual name for a server platform, and says so.
What kitr add server api does:
- Adds Logger first if it is missing, because the server requires it: installs
@kitr/logger, registers its provider and writesconfig/logger.ts. - Installs
@kitr/server,h3andsrvx, plustsxas a dev dependency on thenoderuntime orwrangleronworkers. - Adds
@kitr/servertopackagesinkitrrc.tsand writesapi: server()intoplatforms, with theserverimport. - Writes
config/server.ts(shown under Configure) and adds it toconfig/index.ts. - Adds
LOG_LEVEL=infounder a# Servercomment to.env.example, and to.envwhen that file exists. - Adds
dev,buildandstartscripts topackage.json(kitr dev,kitr build,kitr start) when they are missing. - Writes
server/main.ts, the server entry (shown in the first example). - With API routes installed, writes
server/routes.tsif it is missing and mounts it inserver/main.ts. - Writes
server/routes.tswith an empty route table when no package created one yet, and passes it tocreateServer({ routes }), sokitr add apiandkitr add realtimeonly extend the table. - In a React project with a web platform, writes
app/server.ts, TanStack Start's server entry, which hands requests for the table's paths to the same server inside the Start process. - On the
workersruntime, writesplatforms/api/wrangler.toml. - Adds a
kitr: serversection and akitr: platform apisection toAGENTS.md.
Platform options are flags on the same command:
kitr add server api --runtime bun --port 4000First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
| Flag | Default | Effect |
|---|---|---|
--runtime |
node |
node, bun, deno or workers. |
--port |
3001 |
Port for kitr dev api and kitr start api. |
--host |
Interface to bind. | |
--entry |
server/main.ts |
The entry file the platform runs. |
--no-scaffold |
Register the platform without writing files. |
The fullstack, server, saas, site and realtime kits already include a server platform named api. kitr platforms remove api takes the platform out of kitrrc.ts; kitr remove server reverses the package steps: it removes @kitr/server, h3 and srvx from package.json and kitrrc.ts, deletes config/server.ts and drops the AGENTS.md section. It leaves server/main.ts, server/routes.ts and app/server.ts in place, because you have likely edited them, and lists the files that still import @kitr/server.
Configure
kitr add server api writes this file:
// config/server.ts
import { defineConfig } from '@kitr/server'
export default defineConfig({
healthPath: '/healthz',
bodyLimit: 1024 * 1024,
})
server/main.ts passes every file in config/ to createServer({ config }), so the server key reaches the server without more wiring.
| Option | Default | Description |
|---|---|---|
port |
3000 |
Port listen() uses when you call it without one. The scaffolded server/main.ts always passes a port, so the platform's port wins. |
host |
'0.0.0.0' |
Interface listen() binds when you call it without one. |
healthPath |
'/healthz' |
Path of a route that answers { ok: true } for load balancers. false removes the route. |
bodyLimit |
1048576 (1 MiB) |
Largest request body in bytes. Larger requests get a 413. |
bodyLimits |
{} |
A different limit under a path prefix, such as { '/api/uploads': 10 * 1024 * 1024 }. The longest matching prefix wins; other paths keep bodyLimit. |
trustProxy |
false |
How many proxies in front of the server append to X-Forwarded-For: true for one (a load balancer), or a number. The client IP in ctx.request.ip(), rate limits and request logs is then the address the outermost trusted proxy saw; addresses a client puts in the header itself are ignored. With false, the socket's address. |
requestIdHeader |
'x-request-id' |
Header the request id is read from and echoed in. |
shutdownTimeout |
10 |
Seconds to wait for in-flight requests after SIGINT or SIGTERM before the process exits with code 1. |
websocket |
false |
Accept WebSocket upgrades through crossws. Realtime sets it for you. |
You can also pass server options in code, which overrides config/server.ts:
// server/main.ts
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
export const server = createServer({
providers: rc.providers,
config,
server: { shutdownTimeout: 30, requestIdHeader: 'x-acme-trace' },
})
| Variable | Used for |
|---|---|
LOG_LEVEL |
The server logger's level (debug, info, warn, error) when no logger provider is bound. config/logger.ts reads it too. |
PORT |
The port server/main.ts listens on. kitr dev and kitr start set it from the platform options. |
HOST |
The interface server/main.ts binds. kitr dev sets localhost, kitr start sets 0.0.0.0. |
NODE_ENV |
production hides the message and data of 5xx responses. The app's mode (kitr build, Vite's production mode) does the same, so hosts that never set NODE_ENV are covered. |
Platform options
The platform lives in kitrrc.ts. Its options decide how kitr dev, kitr build and kitr start run the entry.
// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
import { server } from '@kitr/server'
export default defineKitrConfig({
packages: ['@kitr/logger', '@kitr/server', '@kitr/api'],
providers: [() => import('@kitr/logger')],
platforms: {
api: server({ runtime: 'node', port: 3001 }),
},
})
| Option | Default | Description |
|---|---|---|
runtime |
'node' |
node (tsx in dev, node --import tsx in production), bun, deno, or workers (Cloudflare, through wrangler). |
port |
3001 |
Port for kitr dev and kitr start, passed to the entry as PORT. Must be between 0 and 65535. |
host |
Interface to bind, passed as HOST. Defaults to localhost in dev and 0.0.0.0 in production. |
|
entry |
'server/main.ts' |
The file the platform runs: createServer({...}) plus routes and realtime. |
An unknown runtime fails when kitrrc.ts loads with runtime must be one of node, bun, deno, workers, and a bad port with port must be a number between 0 and 65535.
Change an option without opening the file, or override it for one run:
kitr config api port 4000
kitr config api runtime bun
KITR_API_PORT=4000 kitr dev api
kitr dev api --port 4000
The environment form is KITR_<NAME>_<OPTION>, with the platform name upper-cased and anything that is not a letter or digit turned into _.
Start your first server
kitr add server api writes the entry. It creates the server from your providers and config, and listens when you run the file directly:
// server/main.ts
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
/** The kitr server: providers from kitrrc.ts, config from config/. What `kitr dev api` runs, what the web platform's /api route delegates to, what tests call. */
export const server = createServer({ providers: rc.providers, config })
/** Fetch handler for runtimes that call it (Cloudflare Workers, Deno Deploy). */
export default server
const isMain = (import.meta as { main?: boolean }).main ?? process.argv[1]?.endsWith("main.ts")
if (isMain) {
server.listen({
port: Number(process.env.PORT) || 3001,
host: process.env.HOST,
})
}
Add a route on the underlying h3 app, below createServer:
// server/main.ts
// ...
export const server = createServer({ providers: rc.providers, config })
server.h3.get('/api/orders/latest', () => ({ id: 'ord_1042', customer: 'Ada', total: 129 }))
// ...
Start it:
kitr dev apiFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
The log shows listening on http://localhost:3001. Ask for the order:
curl -i http://localhost:3001/api/orders/latest
HTTP/1.1 200 OK
content-type: application/json;charset=UTF-8
x-request-id: 3106a213-994f-4363-b099-eb8c19dc7299
{"id":"ord_1042","customer":"Ada","total":129}
The returned object became JSON, and the response carries a fresh request id. For more than a couple of routes, use API routes: controllers, validation and a typed client on top of this server.
Run the server in development and production
kitr dev api, kitr build api and kitr start api run the entry with the platform's runtime. With no name they run every platform in kitrrc.ts.
| Runtime | kitr dev api |
kitr build api |
kitr start api |
|---|---|---|---|
node |
tsx watch server/main.ts |
tsc --noEmit -p tsconfig.json |
node --import tsx server/main.ts |
bun |
bun --watch server/main.ts |
tsc --noEmit -p tsconfig.json |
bun server/main.ts |
deno |
deno run --watch -A server/main.ts |
tsc --noEmit -p tsconfig.json |
deno run -A server/main.ts |
workers |
wrangler dev --port 3001 in platforms/api/ |
wrangler deploy --dry-run --outdir dist/api |
wrangler dev in platforms/api/ |
On Node, Bun and Deno the build step typechecks; the server runs from source. kitr start binds 0.0.0.0 unless you set host, so the server is reachable from outside a container.
kitr build api
kitr start api --port 8080First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
Choose a runtime
Every runtime runs the same server/main.ts. Only the platform option changes.
Node
The default. kitr add server api installs tsx as a dev dependency to run TypeScript directly.
// kitrrc.ts
// ...
platforms: { api: server({ runtime: 'node' }) },
Bun
kitr config api runtime bunFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
Bun runs TypeScript itself, so no extra package is installed. import.meta.main tells the entry it is the main module.
Deno
kitr config api runtime denoFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
The platform runs Deno with -A (all permissions). See Deno's permissions guide to narrow them in production.
Cloudflare Workers
kitr add server edge --runtime workersFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
The platform installs wrangler and writes platforms/edge/wrangler.toml:
# platforms/edge/wrangler.toml
name = "acme-edge"
main = "../../server/main.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
Workers call the default export's fetch, which server/main.ts already exports. kitr build edge bundles into dist/edge; deploy with wrangler deploy from platforms/edge. See the Wrangler docs for secrets and routes.
Serve the API inside TanStack Start
In a React project with a web platform, the server platform also writes Start's server entry. A request for a path the kitr server has a route for (an endpoint, a socket, the health check) goes to the same server object; everything else renders a page:
// app/server.ts
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
import { server } from '#server/main'
/** Requests for paths the kitr server has routes for (endpoints, sockets, /healthz) go to it; pages go to Start. */
export default createServerEntry({
fetch(request, options) {
if (server.handles(request)) return server.fetch(request)
return handler.fetch(request, options)
},
})
server.handles(request) matches the paths of the table passed to createServer({ routes }), so adding an endpoint or a socket needs no change here. The web platform in ssr mode answers /api/orders itself, with no second process. kitr dev api still runs the server alone when you want it on its own port, for a mobile app or a partner integration.
Call the server from any fetch runtime
server.fetch is a standard (request: Request) => Promise<Response> handler. Anything that speaks fetch can call it:
// server/worker.ts
import { server } from '#server/main'
export default { fetch: (request: Request) => server.fetch(request) }
// scripts/check-order.ts
import { server } from '#server/main'
const response = await server.fetch(new Request('http://app.acme.com/api/orders/latest'))
console.log(response.status, await response.json())
// => 200 { id: 'ord_1042', customer: 'Ada', total: 129 }
The first call to fetch() boots the providers, once. Outside tests it also runs each provider's ready hook, so job workers and schedulers start the same way they do under listen().
Add routes and middleware
server.h3 is the h3 app. Register routes on it directly when you don't need controllers:
// server/main.ts
// ...
server.h3.get('/api/invoices/:id', (event) => {
const id = event.context.params?.id
return { id, status: 'paid' }
})
server.use() adds h3 middleware that runs for every request. Return nothing to continue; return a value or a Response to answer early:
// server/main.ts
// ...
server.use((event) => {
event.res.headers.set('x-powered-by', 'acme')
})
server.mount() puts another h3 app, or any object with a fetch method, under a base path:
// server/main.ts
import { H3 } from 'h3'
// ...
const billing = new H3().get('/invoices', () => [{ id: 'INV-1042', total: 129 }])
server.mount('/billing', billing)
server.mount('/legacy', { fetch: (request) => fetch(`https://legacy.acme.com${new URL(request.url).pathname}`) })
GET /billing/invoices now answers from the billing app. mount and use return the server, so you can chain them; the table from createServer({ routes }) is mounted first.
Read the current request anywhere
Every request runs inside a context you can read from any function on the server, without passing it down. useRequest() returns it:
// server/services/payments.ts
import { useRequest } from '@kitr/server'
export async function chargeOrder(orderId: string, amount: number) {
const { logger, requestId } = useRequest()
logger.info('charging order', { orderId, amount })
return { orderId, amount, idempotencyKey: requestId }
}
| Field | What it holds |
|---|---|
event |
The h3 event for this request. |
app |
A fork of the server app: same providers and config, its own container instances. |
requestId |
The id from the x-request-id header, or a new UUID. |
logger |
A child logger that adds requestId to every line. |
startedAt |
performance.now() when the request arrived. |
locals |
A free-form object for middleware. Auth puts user here. |
useRequest() outside a request throws [@kitr/server] useRequest() called outside a request. In code that runs both inside and outside requests, such as a job or a script, use tryUseRequest(), which returns undefined instead:
// server/services/audit.ts
import { tryUseRequest } from '@kitr/server'
export function auditLabel() {
return tryUseRequest()?.requestId ?? 'background'
}
Use request-scoped services
Each request gets its own fork of the app container. A service bound per request, such as a database transaction or a cache of the current user, never leaks into another request. Read it with useRequestApp():
// server/services/orders.ts
import { useRequestApp } from '@kitr/server'
export async function findOrder(id: string) {
const db = useRequestApp().container.use('db')
return db.orders.find(id)
}
Outside a request useRequestApp() throws [@kitr/server] useRequestApp() called outside a request and no fallback given. Pass the server's app as a fallback for code that also runs in jobs:
// server/services/orders.ts
import { useRequestApp } from '@kitr/server'
import { server } from '#server/main'
export function ordersDb() {
return useRequestApp(server.app).container.use('db')
}
Don't import services from module scope into request handlers when they hold per-user state; take them from useRequestApp() so each request sees its own.
Log with the request id
The server's logger comes from the Logger provider when it is registered, and from a built-in logger named kitr otherwise. Each request gets a child logger that adds requestId:
// server/main.ts
// ...
server.h3.post('/api/orders', async (event) => {
const { logger } = useRequest()
const order = await event.req.json()
logger.info('order received', { customer: order.customer })
return { id: 'ord_1043', ...order }
})
The server logs three kinds of lines for you:
listening on http://localhost:3001atinfowhenlisten()starts.- One line per request at
debug:POST /api/orderswithstatus,ipanddurationMs. - Every
5xxaterror, with the error,method,path,statusand the request id.4xxresponses are not logged as errors.
Set LOG_LEVEL=debug in .env to see the request lines in development.
Trace a request across services
The server reads the request id from the x-request-id header when the caller sends one, and makes a UUID otherwise. It echoes the id back on the response, so a support ticket that quotes it leads to the matching log lines.
curl -i -H 'x-request-id: ticket-8812' http://localhost:3001/api/orders/latest
HTTP/1.1 200 OK
x-request-id: ticket-8812
Pass the id on when you call another Acme service, so both logs share it:
// server/services/invoices.ts
import { useRequest } from '@kitr/server'
export async function fetchInvoice(id: string) {
const { requestId } = useRequest()
const response = await fetch(`https://billing.acme.com/invoices/${id}`, {
headers: { 'x-request-id': requestId },
})
return response.json()
}
Set requestIdHeader when your proxy or tracing system uses another header name.
Return errors as JSON
Anything thrown in a handler becomes JSON in kitr's error shape with the matching status. Throw a KitrError to choose the code:
// server/main.ts
import { KitrError } from 'kitr/app'
// ...
server.h3.post('/api/invoices/:id/pay', () => {
throw new KitrError('CONFLICT', 'Invoice INV-1042 is already paid')
})
HTTP/1.1 409 Conflict
content-type: application/json; charset=utf-8
{"code":"CONFLICT","message":"Invoice INV-1042 is already paid","status":409}
The helpers KitrError.notFound('Order'), .unauthorized(), .forbidden() and .validation({ email: ['Required'] }) cover the common cases. A route that doesn't exist answers in the same shape:
{ "code": "NOT_FOUND", "message": "Cannot find any route matching [GET] http://localhost:3001/api/nope", "status": 404 }
A plain Error becomes INTERNAL with status 500. In development its message comes through so you can debug it. In production it does not:
{ "code": "INTERNAL", "message": "Internal server error", "status": 500 }
Warning Only a production app (
kitr build, orNODE_ENV=production) hides5xxmessages anddata. A staging server started from source in development mode sends internal error messages, such as database errors, to the client.
4xx errors keep their message in every mode, so write those messages for the user.
To build the same response yourself, in custom middleware or a mounted handler, use errorToResponse():
// server/main.ts
import { KitrError } from 'kitr/app'
import { errorToResponse } from '@kitr/server'
// ...
server.use((event) => {
if (event.url.pathname.startsWith('/api/admin') && !event.req.headers.get('authorization'))
return errorToResponse(KitrError.unauthorized(), event)
})
normaliseServerError() turns anything (an h3 HTTPError, a KitrError wrapped as a cause, a plain error) into a KitrError without building a response.
Limit request bodies
bodyLimit caps request bodies in bytes. A request that declares a larger content-length is rejected before it is read, and the connection is closed so the upload stops:
// config/server.ts
import { defineConfig } from '@kitr/server'
export default defineConfig({
healthPath: '/healthz',
bodyLimit: 10 * 1024 * 1024, // 10 MiB for invoice PDFs
})
{ "code": "PAYLOAD_TOO_LARGE", "message": "Request body is larger than 10485760 bytes", "status": 413 }
To raise the limit for one part of the API only, set bodyLimits by path prefix. The longest prefix that matches the request path wins, and every other path keeps bodyLimit:
// config/server.ts
import { defineConfig } from '@kitr/server'
export default defineConfig({
healthPath: '/healthz',
bodyLimit: 1024 * 1024,
bodyLimits: {
'/api/invoices/import': 20 * 1024 * 1024, // Grace's monthly CSV import
'/api/invoices': 5 * 1024 * 1024,
},
})
A POST /api/invoices/import may send 20 MiB, POST /api/invoices/inv_1042/attachments 5 MiB, and POST /api/orders 1 MiB. Uploads adds its own entry for its upload path from config/uploads.ts, so you do not raise the global limit for it.
Chunked uploads without a content-length are counted as they stream and get a 413 with the message Request body size exceeds the limit of 10485760 bytes. For large files, use Uploads, which streams to storage with its own limits.
Check health from a load balancer
The server answers GET /healthz with { "ok": true } and status 200 as soon as it is up. Point your load balancer or container health check at it. Move it or turn it off in config/server.ts:
// config/server.ts
import { defineConfig } from '@kitr/server'
export default defineConfig({
healthPath: '/up', // or false to remove it
bodyLimit: 1024 * 1024,
})
The route only says the process is serving. For a deeper check, such as the database being reachable, add your own route.
Run code on start and shutdown
The server runs the provider lifecycle from kitrrc.ts. register and boot run before the first request, ready once the server serves, and shutdown when it stops. Put the work in a provider, not in server/main.ts:
// app/providers/orders-db-provider.ts
import { defineProvider } from 'kitr/app'
import { createPool } from '#server/db/pool'
export default defineProvider({
name: 'orders-db',
register(app) {
app.container.singleton('ordersDb', () => createPool(process.env.DATABASE_URL))
},
ready(app) {
app.container.use('logger').info('orders database ready')
},
async shutdown(app) {
await app.container.use('ordersDb').end()
},
})
// kitrrc.ts
// ...
providers: [() => import('@kitr/logger'), () => import('#app/providers/orders-db-provider')],
kitr make:provider orders-db writes the provider skeleton and lists it in kitrrc.ts. Each step happens once:
| Call | Runs |
|---|---|
server.boot() |
register and boot of every provider. listen() and the first fetch() call it for you. |
server.listen() |
boot(), then starts listening, then ready. |
server.fetch() |
boot(), then ready once, except when the app environment is test. |
server.close() |
shutdown of every provider. |
Shut down without dropping requests
On SIGINT or SIGTERM, a server started with listen():
- Logs
SIGTERM received, shutting down. - Stops accepting connections and ends open WebSocket connections.
- Waits for in-flight requests to finish.
- Runs every provider's
shutdown(closing database pools, flushing logs). - Exits with code
0.
If that takes longer than shutdownTimeout seconds, the process exits with code 1. Give it more time when requests run long, such as invoice exports:
// config/server.ts
import { defineConfig } from '@kitr/server'
export default defineConfig({
healthPath: '/healthz',
bodyLimit: 1024 * 1024,
shutdownTimeout: 30,
})
Keep the timeout below your orchestrator's grace period (30 seconds by default on Kubernetes), or the process is killed before it finishes.
listen() returns a close() function that does steps 2 to 4 without exiting, which is what tests and scripts use:
// scripts/smoke.ts
import { server } from '#server/main'
const { url, close } = await server.listen({ port: 0, host: '127.0.0.1' })
const response = await fetch(`${url}/healthz`)
console.log(await response.json())
// => { ok: true }
await close()
Port 0 picks a free port; url tells you which one.
Accept WebSocket connections
Set websocket: true to accept WebSocket upgrades through crossws. Realtime sets this for you when the route table has a .ws() link.
// server/main.ts
// ...
export const server = createServer({ providers: rc.providers, config, server: { websocket: true } })
Without the crossws package installed, listen() throws [@kitr/server] websocket: true needs the crossws package. Run kitr add realtime, which installs it.
Commands
@kitr/server adds no generators of its own. The server platform answers these commands:
| Command | What it does |
|---|---|
kitr add server <name> |
Adds a server platform: installs the packages, writes platforms.<name> in kitrrc.ts, runs the server and logger setup, adds scripts and scaffolds server/main.ts. |
kitr dev <name> |
Runs the entry in watch mode for the runtime (see the table under "Run the server in development and production"). |
kitr build <name> |
Typechecks on Node, Bun and Deno; bundles into dist/<name> on Workers. |
kitr start <name> |
Runs the production server. kitr serve does the same. |
kitr config <name> [option] [value] |
Reads or sets a platform option in kitrrc.ts. --unset removes one. |
kitr platforms |
Lists platforms and their options. |
kitr platforms scaffold <name> |
Rewrites the platform's files (server/main.ts, app/server.ts, wrangler.toml; server/routes.ts only when missing). |
kitr platforms remove <name> |
Takes the platform out of kitrrc.ts. |
kitr add server api prints api: kitr dev api · kitr build api · kitr config api when it finishes.
kitr doctor reports an error when:
- The entry file (
server/main.ts) is missing. Fix:kitr platforms scaffold api. h3orsrvxis not installed.tsxis not installed on thenoderuntime.wrangleris not installed on theworkersruntime.- The project has a server platform but no
server/main.ts.
Errors
| Code | When | What to do |
|---|---|---|
NOT_FOUND (404) |
No route matches the method and path. | Check the path and method; with API routes, run kitr routes:list. |
PAYLOAD_TOO_LARGE (413) |
The body is larger than bodyLimit. |
Raise bodyLimit or send large files through Uploads. |
INTERNAL (500) |
A handler threw something that is not a KitrError. |
Read the error log line with the request id; throw a KitrError for expected failures. |
any KitrError code |
A handler threw a KitrError. |
The status follows the code: VALIDATION 422, UNAUTHORIZED 401, FORBIDDEN 403, CONFLICT 409, RATE_LIMITED 429. |
These are thrown as plain errors, not JSON responses:
| Message | When | What to do |
|---|---|---|
[@kitr/server] useRequest() called outside a request. |
useRequest() ran in a job, script or module scope. |
Use tryUseRequest(), or move the call into the handler. |
[@kitr/server] useRequestApp() called outside a request and no fallback given. |
Same, for useRequestApp(). |
Pass a fallback: useRequestApp(server.app). |
[@kitr/server] websocket: true needs the crossws package. |
websocket: true without crossws. |
kitr add realtime. |
Testing
Tests call server.fetch() with a Request. No port is opened. Import the server from server/main.ts and use createTestClient from Testing for short calls:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('returns the latest order', async () => {
const client = createTestClient(server)
const res = await client.get<{ id: string; customer: string }>('/api/orders/latest')
expect(res.status).toBe(200)
expect(res.body).toEqual({ id: 'ord_1042', customer: 'Ada', total: 129 })
expect(res.headers.get('x-request-id')).toBeTruthy()
})
To test server behaviour in isolation, build a server in the test. Pass silentLogger to keep the output clean and server options to change config:
// test/server.test.ts
import { KitrError } from 'kitr/app'
import { silentLogger } from '@kitr/logger'
import { createServer, useRequest } from '@kitr/server'
import { createTestClient } from '@kitr/testing'
import { describe, expect, it } from 'vitest'
function buildServer() {
const server = createServer({ logger: silentLogger, server: { bodyLimit: 1024 } })
server.h3.get('/api/orders/:id', (event) => {
const id = event.context.params?.id
if (id !== 'ord_1042') throw KitrError.notFound('Order')
return { id, requestId: useRequest().requestId }
})
server.h3.post('/api/orders', async (event) => ({ received: (await event.req.text()).length }))
return server
}
describe('order service', () => {
it('answers with the order and echoes the request id', async () => {
const client = createTestClient(buildServer(), { headers: { 'x-request-id': 'ticket-8812' } })
const res = await client.get<{ id: string; requestId: string }>('/api/orders/ord_1042')
expect(res.status).toBe(200)
expect(res.body).toEqual({ id: 'ord_1042', requestId: 'ticket-8812' })
expect(res.headers.get('x-request-id')).toBe('ticket-8812')
})
it('returns a kitr error for a missing order', async () => {
const res = await createTestClient(buildServer()).get('/api/orders/ord_9999')
expect(res.status).toBe(404)
expect(res.body).toEqual({ code: 'NOT_FOUND', message: 'Order not found', status: 404 })
})
it('rejects bodies over the limit', async () => {
const res = await createTestClient(buildServer()).post('/api/orders', { note: 'x'.repeat(2048) })
expect(res.status).toBe(413)
expect(res.body).toMatchObject({ code: 'PAYLOAD_TOO_LARGE' })
})
it('answers the health check', async () => {
const res = await createTestClient(buildServer()).get('/healthz')
expect(res.body).toEqual({ ok: true })
})
})
Under Vitest the app environment is test, so fetch() boots providers but does not run their ready hooks: job workers and schedulers stay off. To test lifecycle hooks, call server.listen({ port: 0 }) and close() as shown under "Shut down without dropping requests".
Next steps
- API reference
- API routes: routes, controllers, validation and a typed client on this server
- Logger: levels, formats and the request-scoped logger
- Realtime: WebSocket channels attached to this server
- Build an API server without React
- h3 documentation