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:
bodyfrom Express body parsing middlewarequeryfromreq.querypathParamsfromreq.paramsheadersfromreq.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
routerorroutefrom the server package when your adapter needs to expose typed implementation helpers. - Use
registerRouteImplementationswhen your framework registers routes and you want the shared HTTP/WebSocket split. - Use
flattenRouteImplementationsandcreateRouteMatcherwhen your adapter dispatches requests through one handler. - Use
handleHttpRoutefor HTTP validation, handler execution, response validation, and error hooks. - Use
handleHttpRouteResultwhen writing to framework response objects. - Use
createWebResponseonly when your adapter returns a standard WebResponse. - Use
prepareWebSocketUpgradebefore accepting a WebSocket route. - Use
handleWebSocketRouteafter 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.