Extend

Create an Adapter

Build and ship your own ts-kizuna adapter for any HTTP runtime using createAdapter from @ts-kizuna/core/adapter.

@ts-kizuna/core/adapter exposes createAdapter so third parties can build and ship their own adapters independently. ts-kizuna ships Express, Fastify, Hono, and Next.js, and everything else is up to the community.

New first-party adapter PRs will be declined, because we can't maintain what we don't know well. The adapter API is public so you can build your own.

Overview

An adapter translates between a native HTTP runtime (Bun, Cloudflare Workers, Deno, etc.) and ts-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

import { createAdapter, renderJsonResult, parseFetchBody, headersToObject, type AdapterRequest } from '@ts-kizuna/core/adapter';

Minimal example (fetch-based runtime)

This example targets any runtime that uses the standard Request/Response APIs, such as Bun, Cloudflare Workers, and Deno.

adapter.ts
import {
    createAdapter,
    renderJsonResult,
    parseFetchBody,
    headersToObject,
    type AdapterRequest,
    type Routes,
    type Router,
} from '@ts-kizuna/core/adapter';

export interface HandlerContext {
    request: Request;
}

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

    respond: (result) => {
        if (result.kind === 'raw-response') return result.response as Response;
        const rendered = renderJsonResult(result);
        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 ts-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: Route; params: Record<string, string> }; // Express-style: adapter already routed
    query: unknown;
    headers: unknown;
    readBody: (route: Route) => 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 ts-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

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