Skip to content
rest-rpc
Esc
navigateopen⌘Jpreview
On this page

Building Server Adapters

Learn how first-party adapters use @rest-rpc/server and how you could build your own

Most applications should use a framework adapter such as @rest-rpc/express, @rest-rpc/fastify, @rest-rpc/hono, @rest-rpc/next, or @rest-rpc/web. The @rest-rpc/server package is for adapter authors or for cases where your application does not use a supported framework and you still want to use @rest-rpc on the server.

This page walks through trimmed source from the first-party adapters. The goal is not to make a fake framework adapter from scratch. It is to show where the shared server primitives are used in real adapters, and which parts remain framework-specific.

Registration Adapters

Express, Fastify, and Hono register each contract route with the host framework. The Express adapter is a useful reference because the boundary is explicit: Express owns the request and response objects, while @rest-rpc/server owns contract execution.

Public Entry

The public Express entry point accepts a framework router, a typed implementation tree, and adapter options.

import type { IncomingMessage } from "node:http";
import type { RouteDeclaration } from "@rest-rpc/core/contract";
import type { ImplementationTree, ServerErrorHandlers } from "@rest-rpc/server";
import { registerRouteImplementations } from "@rest-rpc/server";
import type { IRouter, Request } from "express";

export type RegisterRoutesOptions = {
	errorHandlers?: ServerErrorHandlers<
		| { kind: "http"; req: Request; signal: AbortSignal }
		| { kind: "websocket"; req: IncomingMessage; signal: AbortSignal }
	>;
	middleware?: ExtendedExpressMiddleware[];
	webSocket?: ExpressWebSocketOptions;
};

export const registerRoutes = (
	app: IRouter,
	implementations: ImplementationTree<RouteDeclaration>,
	options: RegisterRoutesOptions = {},
) =>
	registerRouteImplementations(
		implementations,
		(routes) =>
			registerExpressHttpRoutes(
				app,
				routes,
				options.middleware,
				options.errorHandlers,
			),
		(routes) =>
			options.webSocket &&
			registerExpressWebSocketRoutes(
				options.webSocket,
				routes,
				options.errorHandlers,
			),
	);

This function does not execute any route. It delegates the implementation tree to registerRouteImplementations from @rest-rpc/server, then sends HTTP and WebSocket implementations to Express-specific registration functions. The options show both sides of the adapter boundary: middleware and webSocket are Express adapter features, while errorHandlers use the shared server error hook type.

Splitting Implementations

The shared registerRouteImplementations helper flattens the implementation tree and splits route kinds.

import {
	flattenRouteImplementations,
	isHttpRouteImplementation,
	isWebSocketRouteImplementation,
} from "@rest-rpc/server";
import type {
	ImplementationTree,
	RouteImplementation,
} from "@rest-rpc/server";

export const registerRoutes = (
	implementations: ImplementationTree<RouteDeclaration>,
	registerHttpRoutes: (
		routes: RouteImplementation<HttpRouteDeclaration>[],
	) => void,
	registerWebSocketRoutes?: (
		routes: RouteImplementation<WebSocketRouteDeclaration>[],
	) => void,
) => {
	const implementationsList = flattenRouteImplementations(implementations);
	const routes = implementationsList.filter(isHttpRouteImplementation);
	const webSocketRoutes = implementationsList.filter(
		isWebSocketRouteImplementation,
	);

	registerHttpRoutes(routes);
	registerWebSocketRoutes?.(webSocketRoutes);
};

This is still adapter-independent. It knows how to traverse rest-rpc implementation trees, but it does not know how Express, Fastify, Hono, or any other framework registers routes.

Registering HTTP Routes

The Express-specific HTTP adapter receives a flat list of HTTP route implementations. It loops over them and registers each one with Express.

import {
	handleHttpRoute,
	handleHttpRouteResult,
	type RouteImplementation,
	type ServerErrorHandlers,
} from "@rest-rpc/server";

export const registerExpressHttpRoutes = (
	app: IRouter,
	routes: RouteImplementation<HttpRouteDeclaration>[],
	middleware: ExtendedExpressMiddleware[] = [],
	errorHandlers?: ServerErrorHandlers<{
		req: Request;
		signal: AbortSignal;
	}>,
) => {
	for (const implementation of routes) {
		const route: HttpRouteDeclaration = implementation.route;
		const method = route.method.toLowerCase() as Lowercase<HttpMethod>;
		const handler = implementation.handler;

		const serviceHandler = async (req: Request, res: ExpressResponse) => {
			const signal = createRequestSignal(req, res);
			const result = await handleHttpRoute(route, handler, {
				request: {
					body: req.body,
					query: req.query,
					pathParams: req.params,
					headers: req.headers,
				},
				context: { req, signal },
				errorContext: { kind: "http", req, signal },
				errorHandlers,
			});

			return handleHttpRouteResult(result, {
				setHeader: (name, value) => {
					if (value !== undefined) res.setHeader(name, value);
				},
				sendEmpty: (status) => {
					res.sendStatus(status);
				},
				sendJson: (status, body) => {
					res.status(status).json(body);
				},
				sendCustom: (status, body) => {
					res.status(status).send(body);
				},
				sendStream: ({ body, status, contentType, mode }) =>
					writeStreamResponse(body, res, status, contentType, mode),
			});
		};

		app[method](
			toColonPath(route.path),
			...middleware.map((handler) => {
				return (req: Request, res: ExpressResponse, next: NextFunction) =>
					handler(req, res, next, route);
			}),
			serviceHandler,
		);
	}
};

The route loop and app[method](...) call are Express integration. The call to handleHttpRoute is the handoff into @rest-rpc/server.

Inside that handoff, the adapter provides four request segments:

  • body from Express body parsing middleware
  • query from req.query
  • pathParams from req.params
  • headers from req.headers

The adapter also provides context. Route handlers can use this to access framework-specific values such as the raw request or an abort signal.

Writing The Result

handleHttpRoute does not return an Express response. It returns a normalized HttpRouteResult. The adapter turns that result into framework output with handleHttpRouteResult.

import { handleHttpRouteResult } from "@rest-rpc/server";

return handleHttpRouteResult(result, {
	setHeader: (name, value) => {
		if (value !== undefined) res.setHeader(name, value);
	},
	sendEmpty: (status) => {
		res.sendStatus(status);
	},
	sendJson: (status, body) => {
		res.status(status).json(body);
	},
	sendCustom: (status, body) => {
		res.status(status).send(body);
	},
	sendStream: ({ body, status, contentType, mode }) =>
		writeStreamResponse(body, res, status, contentType, mode),
});

This is the other side of the adapter boundary. The shared server package has already validated the route response and normalized it into one of the supported result kinds. The adapter decides how to express each kind through the framework’s response API.

Streams are the least portable response kind. In Express, the adapter writes to the Node response object and closes the async iterator when the client disconnects. A different framework may have a different stream API, but it still receives the same body, status, contentType, and mode.

Dispatch Adapters

Some integrations do not register each contract route with the framework. Instead, they receive every request through one handler and dispatch to the matching contract route themselves. @rest-rpc/web is the simplest first-party example.

The Web adapter creates a matcher from the implementation tree once.

import {
	createRouteMatcher,
	flattenRouteImplementations,
	type ImplementationTree,
} from "@rest-rpc/server";

export const createWebRouteMatcher = (
	implementations: ImplementationTree<HttpRouteDeclaration>,
) => {
	const routes = flattenRouteImplementations(implementations);
	const routeContract = Object.fromEntries(
		routes.map((implementation, index) => [
			String(index),
			implementation.route,
		]),
	);
	const matchRoute = createRouteMatcher(routeContract);
	const implementationsByRoute = new Map(
		routes.map((implementation) => [implementation.route, implementation]),
	);

	return (request: Request) => {
		const url = new URL(request.url);
		const match = matchRoute({
			method: request.method,
			path: url.pathname,
		});

		if (!match) return new Response(null, { status: 404 });
		if (match.type === "methodNotAllowed") {
			return new Response(null, { status: 405 });
		}

		const implementation = implementationsByRoute.get(
			match.route as HttpRouteDeclaration,
		);
		if (!implementation) return new Response(null, { status: 404 });

		return {
			type: "match" as const,
			implementation,
			params: match.params,
		};
	};
};

This replaces the framework router. Once a request is matched, the dispatch adapter has the same thing a registration adapter had inside its route handler: a route implementation and path params.

The public createRouteHandler wires matching, middleware, context creation, and route execution together.

import type { ServerErrorHandlers } from "@rest-rpc/server";

export type CreateWebHandlerOptions = {
	errorHandlers?: ServerErrorHandlers<Record<never, never>>;
	parseBody?: WebRouteParseBody;
};

export const createRouteHandler = <
	TRuntimeContext extends Record<string, unknown> = Record<never, never>,
	TContext extends Record<string, unknown> = Record<string, unknown>,
	TRequest extends Request = Request,
>(
	implementations: WebImplementationTree,
	options: CreateWebHandlerOptions = {},
): ((request: TRequest, runtime: TRuntimeContext) => Promise<Response>) => {
	const matchRoute = createWebRouteMatcher(implementations);
	const usesDefaultParseBody = options.parseBody === undefined;
	const parseBody = options.parseBody ?? defaultParseBody;

	return async (request: TRequest, runtime: TRuntimeContext) => {
		const match = matchRoute(request);
		if (match instanceof Response) return match;
		const implementation = match.implementation;
		const middlewareResult = await implementation.middleware?.({
			request,
			route: implementation.route,
			runtime,
		});
		if (middlewareResult instanceof Response) return middlewareResult;

		const context = {
			...(middlewareResult ?? {}),
			request,
		};

		return handleWebRoute(
			request,
			context,
			implementation,
			match.params,
			parseBody,
			usesDefaultParseBody,
			options.errorHandlers,
		);
	};
};

The dispatch-specific work is matching the request and building context from runtime input. After that, handleWebRoute uses the same HTTP execution primitive as the Express adapter.

import { createWebResponse, handleHttpRoute } from "@rest-rpc/server";

const result = await handleHttpRoute(
	implementation.route,
	implementation.handler,
	{
		request: {
			body,
			query: readQuery(url),
			pathParams: params,
			headers: readHeaders(request.headers),
		},
		context,
		errorHandlers,
	},
);

return createWebResponse(result);

createWebResponse is a convenience for adapters that return the standard Web Response object. Frameworks with their own response API should usually use handleHttpRouteResult directly, like the Express adapter does.

WebSocket Routes

WebSocket adapters have the same split: the framework owns the upgrade and socket lifecycle, while @rest-rpc/server owns contract validation and typed message handling.

Fastify is a smaller reference than Express here because @fastify/websocket adds WebSocket support to route registration. The adapter does not need to listen to the raw Node HTTP upgrade event itself.

The adapter option exposes the shared beforeUpgrade hook. The hook receives the already matched WebSocket route, the request data, and the adapter context.

import type { BeforeWebSocketUpgrade } from "@rest-rpc/server";

export type FastifyWebSocketOptions = {
	beforeUpgrade?: BeforeWebSocketUpgrade<{
		req: FastifyRequest;
		signal: AbortSignal;
	}>;
};

The framework socket is adapted to the small WebSocketLike interface expected by @rest-rpc/server.

import type { WebSocketLike } from "@rest-rpc/server";

const adaptWebSocket = (socket: FastifyWebSocket): WebSocketLike => ({
	send(data) {
		socket.send(data);
	},
	close(code, reason) {
		socket.close(code, reason);
	},
	onMessage(callback) {
		const onMessage = (data: unknown) => callback(data);
		socket.on("message", onMessage);
		return () => socket.off("message", onMessage);
	},
	onClose(callback) {
		const onClose = (code: number, reason: Buffer) =>
			callback({ code, reason: reason.toString() });
		socket.on("close", onClose);
		return () => socket.off("close", onClose);
	},
});

The route registration has two server-package handoffs. prepareWebSocketUpgrade validates query, path params, and headers before the socket is accepted. handleWebSocketRoute starts the typed route handler after Fastify gives the adapter a socket.

import {
	handleWebSocketRoute,
	prepareWebSocketUpgrade,
	type RouteImplementation,
	type ServerErrorHandlers,
} from "@rest-rpc/server";

const validatedWebSocketRequest = Symbol("validatedWebSocketRequest");

type ExtendedFastifyRequest = FastifyRequest & {
	[validatedWebSocketRequest]: Record<string, unknown>;
};

export const registerFastifyWebSocketRoutes = (
	app: FastifyInstance,
	options: FastifyWebSocketOptions,
	routes: RouteImplementation<WebSocketRouteDeclaration>[],
	preHandler: ExtendedFastifyPreHandler[] = [],
	errorHandlers?: ServerErrorHandlers<{
		req: FastifyRequest;
		signal: AbortSignal;
	}>,
) => {
	for (const implementation of routes) {
		app.get(
			toColonPath(implementation.route.path),
			{
				websocket: true,
				preValidation: [
					...preHandler.map((handler) => {
						return async (req: FastifyRequest, reply: FastifyReply) => {
							await handler(req, reply, implementation.route);
						};
					}),
					async (req: FastifyRequest, reply: FastifyReply) => {
						const controller = new AbortController();
						const abort = () => controller.abort();
						req.raw.once("aborted", abort);
						reply.raw.once("close", () => {
							if (!reply.raw.writableFinished) abort();
						});
						const request = {
							query: req.query,
							pathParams: req.params,
							headers: req.headers,
						};
						const upgrade = await prepareWebSocketUpgrade({
							implementation,
							request,
							context: { req, signal: controller.signal },
							beforeUpgrade: options.beforeUpgrade,
							errorHandlers,
						});

						if (!upgrade.ok) {
							await sendUpgradeRejection(reply, upgrade.rejection);
							return;
						}

						req.validatedWebSocketRequest = upgrade.validatedRequest;
					},
				],
			},
			(socket: FastifyWebSocket, req: FastifyRequest) => {
				const validatedRequest = req.validatedWebSocketRequest;

				handleWebSocketRoute(implementation.route, implementation.handler, {
					request: validatedRequest,
					context: { req },
					socket: adaptWebSocket(socket),
				});
			},
		);
	}
};

Since prepareWebSocketUpgrade runs during Fastify’s validation phase, but handleWebSocketRoute runs later when the socket handler receives the accepted socket. The adapter stores the validated request so the route handler receives parsed, validated request data rather than the raw framework request it would have to validate again.

Once handleWebSocketRoute starts, incoming messages are parsed and validated against the client message schema. Outgoing messages sent through the route socket are validated against the server message schema before they are serialized.

TL;DR

When building a local adapter, copy the shape rather than the whole file.

  • Use router or route from the server package when your adapter needs to expose typed implementation helpers.
  • Use registerRouteImplementations when your framework registers routes and you want the shared HTTP/WebSocket split.
  • Use flattenRouteImplementations and createRouteMatcher when your adapter dispatches requests through one handler.
  • Use handleHttpRoute for HTTP validation, handler execution, response validation, and error hooks.
  • Use handleHttpRouteResult when writing to framework response objects.
  • Use createWebResponse only when your adapter returns a standard Web Response.
  • Use prepareWebSocketUpgrade before accepting a WebSocket route.
  • Use handleWebSocketRoute after the runtime gives your adapter an accepted socket.

The adapter-owned parts are request parsing, route registration or request matching, context construction, middleware integration, response writing, and runtime-specific WebSocket upgrade handling.

Was this page helpful?