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 runs on the server only and is also a platform: server() in kitrrc.ts tells kitr dev, kitr build and kitr start how to run it.
For routes and controllers add API routes; for functions your 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/kitr once. Inside a project, the global kitr runs your project's own version.
server is the platform type and api the name you address it by (kitr dev api); without a name, kitr add server uses api.
Everything kitr add changes
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/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. |
What kitr remove does
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,
})
| 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 |
{} |
Limits per path: { '/api/uploads': 10 * 1024 * 1024 }. |
trustProxy |
false |
How many proxies sit in front of the server, so ctx.request.ip() is the real client: true for one load balancer, or a number. |
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. |
Options passed in code override config/server.ts:
// server/main.ts
import { loadRc } from '@kitr/kitr/app'
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
export const server = createServer({
...loadRc(rc, 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 details of 5xx errors. A production build does too. |
Platform options
The platform lives in kitrrc.ts; its options decide how the CLI runs the entry.
// kitrrc.ts
import { defineKitrConfig } from '@kitr/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. |
Change an option without opening the file, or override it for one run (the environment form is KITR_<NAME>_<OPTION>):
kitr config api port 4000
kitr config api runtime bun
KITR_API_PORT=4000 kitr dev api
kitr dev api --port 4000
Start your first server
kitr add server api writes the entry, which creates the server from your providers and config and listens when run 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/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.
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.
kitr build api
kitr start api --port 8080First time? Run npm i -g @kitr/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/kitr once. Inside a project, the global kitr runs your project's own version.
Bun runs TypeScript itself, so nothing extra is installed.
Deno
kitr config api runtime denoFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
The platform runs Deno with -A; see Deno's permissions guide to narrow that in production.
Cloudflare Workers
kitr add server edge --runtime workersFirst time? Run npm i -g @kitr/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).
Serve the API inside TanStack Start
In a React project with a web platform, the server platform also writes Start's server entry. Paths the kitr server has routes for (endpoints, sockets, the health check) go 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'
/** Every request goes through the kitr server's middleware (request context, shield's headers and CSP nonce); paths it has routes for (endpoints, sockets, /healthz) are answered there, pages are rendered by Start inside it. */
export default createServerEntry({
fetch(request, options) {
return server.fetch(request, {
pages: (request) => {
return handler.fetch(request, options)
},
})
},
})
server.fetch(request, { pages }) answers API routes itself and hands every other path to pages, Start's handler. The ssr web platform therefore serves /api/orders with no second process; kitr dev api runs the server on its own port.
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 fetch() boots the providers once and, outside tests, runs each provider's ready hook, as listen() does.
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.
Read the current request anywhere
Every request runs inside a context that any function can read with useRequest():
// 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. |
Outside a request useRequest() throws; in code that runs both inside and outside requests, such as a job, use tryUseRequest(), which returns undefined:
// 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, so a per-request service such as a database transaction 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 unless you 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')
}
Log with the request id
The server's logger comes from the Logger provider, or a built-in kitr logger without one. 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 listening on ... at info, one line per request at debug (POST /api/orders with status, ip and durationMs) and every 5xx at error with the request id. Set LOG_LEVEL=debug in .env to see request lines.
Trace a request across services
The server reads the request id from the x-request-id header, or makes a UUID, and echoes it 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 if your proxy 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/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}
KitrError.notFound('Order'), .unauthorized(), .forbidden() and .validation({ email: ['Required'] }) cover the common cases. A missing route 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; its message comes through in development but not in production:
{ "code": "INTERNAL", "message": "Internal server error", "status": 500 }
4xx messages show in every mode, so write them for the user. In custom middleware or a mounted handler, build the same response with errorToResponse():
// server/main.ts
import { KitrError } from '@kitr/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 any 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:
// 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 part of the API, set bodyLimits by path prefix; the longest matching prefix wins and other paths keep 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,
},
})
Chunked uploads without a content-length are counted as they stream and get a 413. 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 } once it is up; point your load balancer 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,
})
It only says the process is serving; add your own route to check the database.
Run code on start and shutdown
Providers in kitrrc.ts run register and boot 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/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.
Past shutdownTimeout seconds the process exits with code 1. Raise it 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,
})
listen() returns a close() function that does steps 2 to 4 without exiting, for tests and scripts:
// 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 it for you.
// server/main.ts
// ...
export const server = createServer({ providers: rc.providers, config, server: { websocket: true } })
Without crossws, listen() throws; kitr add realtime installs it.
Commands
The server platform answers these commands:
| Command | What it does |
|---|---|
kitr add server <name> |
Add a server platform and write 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 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:
// 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 in isolation, build a server in the test, with silentLogger and server options for config:
// test/server.test.ts
import { KitrError } from '@kitr/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 fetch() boots providers but skips their ready hooks, so job workers stay off. To test lifecycle hooks, call server.listen({ port: 0 }) and close() as shown under "Shut down without dropping requests".