Request Context
Declare request-scoped values every handler receives, typed, resolved once per request.
A request context is a value every handler receives, resolved once per request: an analytics session, a logger, the caller's locale.
Declare the value and how it resolves
k.requestContext takes the schema of what handlers receive, and .handler() takes the resolver that fills it. Add headers when the value comes off the request:
import { z } from 'zod';
import { getHeaderValue } from 'kizunajs';
import { k } from './k';
export const analytics = k
.requestContext({
headers: z.object({
'x-posthog-session-id': z.string().optional(),
}),
context: z.object({
sessionId: z.string().nullable(),
}),
})
.handler(({ headers }) => ({
sessionId: getHeaderValue(headers['x-posthog-session-id']) ?? null,
}));The resolver's headers are typed by the schema right above it, and its return is checked against context.
Name it on your config
Name it under what handlers will read it by:
import { defineConfig } from 'kizunajs';
import { analytics } from './src/request-context';
import { routes } from './src/routes';
export default defineConfig({
adapter: expressAdapter(),
routes,
requestContext: {
analytics,
},
});Read it in the handler
Every handler receives the resolved value under requestContext, keyed by the name:
listEvents: k
.route({
method: 'GET',
path: '/events',
responses: {
200: EventListSchema,
},
})
.handler(({ query, requestContext }) => {
track(requestContext.analytics.sessionId, 'listEvents');
return {
status: 200,
body: {
events: findEvents(query),
},
};
}),Two kinds of declaration
A value the server derives on its own takes the context schema alone:
export const logger = k
.requestContext(
z.object({
requestId: z.string(),
})
)
.handler(() => ({
requestId: randomUUID(),
}));A value that comes from the caller declares the headers it reads. Every client types them, so a caller sets them once and the resolver reads them by name:
export const locale = k
.requestContext({
headers: z.object({
'accept-language': z.string().optional(),
}),
context: z.object({
language: z.enum(['en', 'nb']),
}),
})
.handler(({ headers }) => ({
language: negotiateLanguage(headers['accept-language']) ?? 'en',
}));headers is what arrives on the wire, context is what handlers get, and the resolver is the step between them.
Resolvers
A resolver receives one object:
| Argument | What it is |
|---|---|
headers | The declared headers, typed by the schema. A declaration without one gets the adapter's raw header record |
params | The matched route's path params, as strings, so a resolver can read params.workspaceId |
| native request | The adapter's own request objects |
The native objects differ per adapter:
| Adapter | Arguments |
|---|---|
| Express | req, res |
| Fastify | request, reply |
| Hono | c |
| Next.js | request |
.handler(({ headers, req }) => ({
sessionId: getHeaderValue(headers['x-posthog-session-id']) ?? req.cookies.posthogSessionId ?? null,
}));A resolver may be async, and its return is typed against the declaration's context schema. Each one gets the request and nothing else, so two resolvers that need the same lookup each do it.
A throw inside a resolver fails the request the way a throw inside a handler does, answering 500 unless the adapter's onError says otherwise. Resolvers run on every route, so keep them cheap and let them fall back rather than throw.
Sending the headers from a client
The fetch client takes the declared headers under requestContext and sends them with every request:
import { createClient } from './api-client.generated';
export const apiClient = createClient({
baseUrl: 'https://api.example.com',
requestContext: {
'x-posthog-session-id': sessionId,
},
});The Swift and Kotlin clients take a RequestContext in their initializer:
let client = APIClient(
baseURL: url,
requestContext: .init(
xPosthogSessionId: sessionId
)
)val client = APIClient(
baseUrl = baseUrl,
requestContext = APIClient.RequestContext(
xPosthogSessionId = sessionId
)
)The values are typed from the header schemas, so a required header is required here too.
Where it runs
Resolvers run on every route, public ones included, before the guards, so a guard reads the resolved values under requestContext the way a handler does. The job handlers take only their input, so they see none of it. MCP tool calls run the resolvers the same way the HTTP pipeline does, reading the headers of the transport request.
Request context or a guard
Both run before the handler and both put a typed value in its args. They answer different questions:
| Request context | Guard | |
|---|---|---|
| Runs on | Every route | The routes whose auth names it |
| Runs | Before the guards | After every resolver has run |
| Can reject | No | Yes, with deny({ status, body }) |
| Handler arg | requestContext.<name> | auth.<identity> |
If the answer decides whether the request may proceed, it belongs in a guard. If it is something the handler wants to have on hand, it belongs here.