TypeScript Server

The ts-server target generates a framework-agnostic server using the Web Standard Fetch API (Request/Response). It works with Bun, Deno, Express, Cloudflare Workers, and any runtime that supports the Fetch API.

Quick Start

1. Define your contract

If you ran xrpc init, you already have this. Otherwise, create a contract file (see API Contract):

// contract.ts
import { z } from 'zod';
import { createRouter, group, query, mutation } from 'xrpckit';

const example = group("example", {
  hello: query({
    input: z.object({ name: z.string().min(1).max(100) }),
    output: z.object({ message: z.string() }),
  }),
  echo: mutation({
    input: z.object({ text: z.string().max(1000) }),
    output: z.object({ echoed: z.string() }),
  }),
});

export const router = createRouter({ example });

2. Generate

xrpc generate --targets ts-server

This produces two files in <output>/xrpc/:

  • types.ts — Zod schemas and inferred TypeScript types
  • server.ts — a typed Handlers interface, createFetchHandler, validation, and dispatch logic

3. Implement handlers

Import the generated Handlers type and provide your business logic. The input/output types are already wired — you just fill in the functions:

// server.ts
import { createFetchHandler, type Handlers } from './xrpc/server';

const handlers: Handlers = {
  "example.hello": async (params) => ({
    message: `Hello, ${params.name}!`,
  }),
  "example.echo": async (params) => ({
    echoed: params.text,
  }),
};

const handler = createFetchHandler(handlers);

4. Serve it

Pick your runtime — the handler plugs in directly:

Bun.serve({
  port: 3000,
  fetch: handler,
});
import express from 'express';

const app = express();
app.use(express.json());

app.post('/api', async (req, res) => {
  const request = new Request(`http://localhost${req.url}`, {
    method: req.method,
    headers: req.headers as HeadersInit,
    body: JSON.stringify(req.body),
  });
  const response = await handler(request);
  res.status(response.status).json(await response.json());
});

app.listen(3000);
Deno.serve({ port: 3000 }, handler);

API Reference

createFetchHandler(handlers, options?)

Creates a Fetch API handler (Request → Response). This is the primary entry point:

const handler = createFetchHandler(handlers, {
  validateInputs: true,   // Validate inputs with Zod (default: true)
  validateOutputs: true,  // Validate outputs with Zod (default: true)
  getContext: async (request) => ({ /* ... */ }),
  formatError: ({ code, message, error }) => ({ code, message }),
});

The handler accepts POST requests with a JSON-RPC-like payload:

{ "method": "example.hello", "params": { "name": "World" } }

createRpcHandler(handlers, options?)

Lower-level handler that processes RpcRequest objects directly, without HTTP concerns. Useful for testing or custom transport layers.

Context

Use getContext to extract request context (authentication, logging, etc.) and pass it to all handlers:

type AppContext = { userId: string; role: string };

const handlers: Handlers<AppContext> = {
  "example.hello": async (params, ctx) => ({
    message: `Hello, ${params.name}! (user: ${ctx.userId})`,
  }),
  // ...
};

const handler = createFetchHandler(handlers, {
  getContext: async (request) => {
    const token = request.headers.get('authorization');
    return { userId: extractUserId(token), role: 'user' };
  },
});

Error Handling

The generated server uses JSON-RPC error codes mapped to HTTP status codes:

Error CodeHTTP StatusDescription
INVALID_REQUEST400Malformed request
INVALID_PARAMS400Input validation failed
METHOD_NOT_FOUND404Unknown method
METHOD_NOT_ALLOWED405Non-POST request
INVALID_RESPONSE500Output validation failed
INTERNAL_ERROR500Handler threw an error

Customize error formatting with formatError:

const handler = createFetchHandler(handlers, {
  formatError: ({ code, message, error }) => ({
    code,
    message,
    data: error instanceof ZodError ? error.flatten() : undefined,
  }),
});

Type Safety

TypeScript will catch errors if your handlers don't match the contract:

const handlers: Handlers = {
  "example.hello": async (params) => ({
    // Error: Property 'message' is missing
  }),
  // Error: Property 'example.echo' is missing
};

Related Target

The TypeScript client is generated separately via the ts-client target (see TypeScript Client).