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:

src/request-context.ts
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:

kizuna.config.ts
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:

src/routes/notifications.ts
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:

src/request-context.ts
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:

src/request-context.ts
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:

ArgumentWhat it is
headersThe declared headers, typed by the schema. A declaration without one gets the adapter's raw header record
paramsThe matched route's path params, as strings, so a resolver can read params.workspaceId
native requestThe adapter's own request objects

The native objects differ per adapter:

AdapterArguments
Expressreq, res
Fastifyrequest, reply
Honoc
Next.jsrequest
src/request-context.ts
.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:

src/lib/api-client.ts
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:

APISetup.swift
let client = APIClient(
    baseURL: url,
    requestContext: .init(
        xPosthogSessionId: sessionId
    )
)
APISetup.kt
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 contextGuard
Runs onEvery routeThe routes whose auth names it
RunsBefore the guardsAfter every resolver has run
Can rejectNoYes, with deny({ status, body })
Handler argrequestContext.<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.

Reference

On this page