Extend

Create an Adapter

Build and ship your own Kizuna adapter for any HTTP runtime using createAdapter from kizunajs/adapter.

Beta

The adapter API is new and still settling. createAdapter and the AdapterResult shape may change while v2 is in beta, so pin your version if you depend on them.

kizunajs/adapter exposes createAdapter so third parties can build and ship their own adapters independently. Kizuna ships Express, Fastify, Hono, and Next.js.

Whether an adapter goes first-party comes down to adoption, not age. If a framework picks up real usage, we will very likely add it. What we will not take on is a framework a handful of people use, since we maintain everything we merge. The adapter API is public, so you can build and publish your own today.

Overview

An adapter translates between a native HTTP runtime (Bun, Cloudflare Workers, Deno, etc.) and Kizuna's handler pipeline. You provide three things:

  • buildHandlerContext builds the context object your handlers receive alongside validated inputs
  • respond translates an AdapterResult to a native response
  • onError (optional) intercepts unhandled handler errors

createAdapter

adapter.ts
import { createAdapter, renderJsonResult, parseFetchBody, headersToObject, type AdapterRequest } from 'kizunajs/adapter';

Minimal example (fetch-based runtime)

adapter.ts
import {
    createAdapter,
    renderJsonResult,
    parseFetchBody,
    headersToObject,
    type AdapterRequest,
    type Routes,
    type Router,
} from 'kizunajs/adapter';

export interface HandlerContext {
    request: Request;
}

const adapter = createAdapter<Request, Response, HandlerContext>({
    buildHandlerContext: (adapterRequest) => ({
        request: adapterRequest.request,
    }),

    respond: (result, { request }) => {
        if (result.kind === 'raw-response') return result.response as Response;
        const rendered = renderJsonResult(result);
        if (rendered.stream) {
            return new Response(rendered.stream({ signal: request.signal }), {
                status: rendered.status,
                headers: rendered.headers,
            });
        }
        return new Response(rendered.body === null || rendered.body === undefined ? null : JSON.stringify(rendered.body), {
            status: rendered.status,
            headers: rendered.headers,
        });
    },
});

export const handleRequest = <T extends Routes>(
    request: Request,
    routes: T,
    router: Router<T, HandlerContext>,
    options?: { basePath?: string }
): Promise<Response> => {
    const url = new URL(request.url);

    const adapterRequest: AdapterRequest<Request> = {
        request,
        method: request.method,
        resolution: {
            kind: 'core-match',
            path: url.pathname,
        },
        query: Object.fromEntries(url.searchParams),
        headers: headersToObject(request.headers),
        readBody: (route) => parseFetchBody(request, route),
    };

    return adapter.handle({
        routes,
        router,
        request: adapterRequest,
        responseContext: {},
        basePath: options?.basePath,
    });
};

AdapterDefinition

interface AdapterDefinition<NativeRequest, NativeResponse, HandlerContext, ResponseContext = Record<string, never>> {
    buildHandlerContext: (request: AdapterRequest<NativeRequest>, context: ResponseContext) => HandlerContext | Promise<HandlerContext>;
    respond: (result: AdapterResult, context: ResponseContext) => NativeResponse | Promise<NativeResponse>;
    onError?: (error: unknown, request: AdapterRequest<NativeRequest>) => AdapterResult | void | Promise<AdapterResult | void>;
    matcher?: RouteMatcher;
}
FieldDescription
buildHandlerContextReturns the context object each handler receives alongside params, query, body, headers.
respondTranslates an AdapterResult to a native response. Always handle kind: 'raw-response' first, as it carries a pre-built native response from onError.
onErrorCalled on unhandled handler errors. Return an AdapterResult to override the default 500, or return void to let it pass through.
matcherCustom route matcher. Defaults to Kizuna's built-in path matcher.

AdapterRequest

interface AdapterRequest<NativeRequest> {
    request: NativeRequest;
    method: string;
    resolution:
        | { kind: 'core-match'; path: string } // Next-style: core matches the path
        | { kind: 'pre-resolved'; routeKey: string; route: RouteDefinition; params: Record<string, string> }; // Express-style: adapter already routed
    query: unknown;
    headers: unknown;
    readBody: (route: RouteDefinition) => Promise<unknown> | unknown;
}

Use kind: 'core-match' for catch-all routing (one handler handles all paths). Use kind: 'pre-resolved' for per-route registration (Express-style, where the framework has already matched the route).

AdapterResult

respond receives one of these from the pipeline:

kindWhen
successHandler returned a valid response
not-foundNo route matched the path
method-not-allowedPath matched but method is not in the routes
validation-failedparams, query, headers, or body failed schema validation
handler-errorHandler threw an unhandled error
raw-responseonError returned an override, so cast result.response back to NativeResponse

Helpers

renderJsonResult

Translates any AdapterResult (except raw-response) to { status, headers, body } using Kizuna's defaults, such as 405 with an Allow header and validation errors as Problem Details (detail plus an errors array).

const rendered = renderJsonResult(result);
// rendered.status, rendered.headers, rendered.body

Pass the request's method as the fourth argument when your adapter writes the wire bytes itself: a HEAD request then keeps GET's status and headers, gains a Content-Length, and loses the content (RFC 9110 §9.3.2). Leave it unset when the framework discards HEAD content itself, as Express, Fastify, and Hono do.

const rendered = renderJsonResult(result, formatError, request, request.method);

A success result on a status declared with stream renders with body undefined and a stream function in its place. Check stream before body, hand it the signal that fires when the client goes away, and write the ReadableStream it returns. encodeStreamBody(result, { signal }) does the same for an adapter that renders results itself.

parseFetchBody

Content-type-aware body parsing for fetch-based runtimes (Request / Response API). Handles application/json, multipart/form-data, and application/x-www-form-urlencoded.

readBody: (route) => parseFetchBody(request, route),

headersToObject

Converts a Headers instance to Record<string, string>.

headers: headersToObject(request.headers),

On this page