@contract-kit/server
v0.1.1
Published
Framework-agnostic HTTP server runtime for contract-kit - extracted from @contract-kit/app
Maintainers
Readme
@contract-kit/server
Framework-agnostic server runtime that wires HTTP contracts to handlers or use cases. The runtime is intentionally light so adapters (like @contract-kit/next) can translate requests and responses without re-implementing routing or middleware.
This package provides the core server functionality that powers framework-specific adapters. If you're using Next.js, see @contract-kit/next instead.
Installation
npm install @contract-kit/server @contract-kit/corePeer Dependencies
@contract-kit/openapi: ^0.1.0 (optional, for OpenAPI spec generation)
TypeScript Requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Core Concepts
The server runtime is built around several key concepts:
- Contracts: Type-safe API definitions created with
@contract-kit/core - Handlers: Functions that implement contract endpoints
- Use Cases: Business logic functions that can be connected to contracts
- Middleware: Request/response interceptors for cross-cutting concerns
- Providers: Service implementations (database, cache, logger, etc.)
- Context: Per-request data available to all handlers
Quick Start
import { createServer } from "@contract-kit/server";
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";
// Define contracts
const todos = createContractGroup();
const getTodo = todos
.get("/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
}));
// Create server
const server = await createServer({
ports: {}, // Required - your ports/services
routes: [
{
contract: getTodo,
handle: async ({ path }) => ({
status: 200,
body: {
id: path.id,
title: "Example todo",
completed: false,
},
}),
},
],
createContext: async ({ req }) => ({
requestId: req.headers.get("x-request-id") || crypto.randomUUID(),
}),
onUnhandledError: (error) => ({
status: 500,
body: { message: "Internal server error" },
}),
});
// Use with any HTTP server
const request: HttpRequestLike = {
method: "GET",
url: "/todos/123",
headers: new Headers(),
json: async () => ({}),
text: async () => "",
};
const response = await server.api()(request);
// { status: 200, body: { id: "123", title: "Example todo", completed: false } }API Reference
createServer<Ctx, Ports>(options)
Creates a server instance with the given options.
Type Parameters:
Ctx: Type of the request contextPorts: Type of available ports (services)
Options:
ports: Required - Ports object defining available service interfacescreateContext: Function to create request contextonUnhandledError: Global error handlerroutes?: Array of route configurations (contract + handler)middleware?: Array of middleware functionsproviders?: Array of service providersproviderEnv?: Environment variables for providersproviderConfig?: Configuration overrides for providers
Returns: Promise<ServerInstance<Ctx>>
ServerInstance Methods
server.api()
Returns a handler function that routes requests based on method and path.
const handler = server.api();
const response = await handler(request);This is typically used with catch-all routing in framework adapters.
server.handle(contract)
Creates a handler for a specific contract without registering it globally.
const handler = server.handle(getTodo);
const response = await handler(request);server.route(contract)
Returns a route builder for creating handlers. Handlers created this way are NOT registered globally.
Returns: Route builder with:
handle(fn): Create a custom handleruseCase(useCase, maps): Connect a use case
const handler = server.route(getTodo).handle(async ({ ctx, path }) => {
// Implementation
return { status: 200, body: { id: path.id, title: "..." } };
});server.register(contract)
Returns a route builder (same as route()) but registers handlers globally for use with server.api().
const handler = server.register(getTodo).handle(async ({ ctx, path }) => {
// This handler is now available via server.api()
return { status: 200, body: { id: path.id, title: "..." } };
});server.stop()
Shuts down the server and cleans up resources (closes provider connections, etc.).
await server.stop();Route Configuration
Inline Route Configuration
Pass route configurations directly to createServer:
// Define contracts
const todos = createContractGroup();
const getTodo = todos
.get("/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, TodoSchema);
const createTodo = todos
.post("/todos")
.body(z.object({ title: z.string() }))
.response(201, TodoSchema);
const server = await createServer({
ports: {},
routes: [
{
contract: getTodo,
handle: async ({ ctx, path }) => {
const todo = await ctx.db.todos.findById(path.id);
return { status: 200, body: todo };
},
},
{
contract: createTodo,
useCase: createTodoUseCase,
mapInput: ({ body }) => body,
mapOutput: (result) => result,
},
],
createContext: async ({ ports }) => ({ db: ports.db }),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});Using Route Builders
Alternatively, use register() for more flexibility:
const server = await createServer({
ports: {},
createContext: async ({ ports }) => ({ db: ports.db }),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
// Register routes
server.register(getTodo).handle(async ({ ctx, path }) => {
const todo = await ctx.db.todos.findById(path.id);
return { status: 200, body: todo };
});
server.register(createTodo).useCase(createTodoUseCase, {
mapInput: ({ body }) => body,
mapOutput: (result) => result,
});Context Creation
The createContext function runs for every request and provides data to handlers:
const server = await createServer({
ports: {},
createContext: async ({ req, ports }) => {
// Access request
const token = req.headers.get("authorization");
// Access ports (from providers)
const user = token ? await ports.db.users.findByToken(token) : null;
// Return context
return {
user,
requestId: req.headers.get("x-request-id"),
};
},
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});The context is then available in all handlers:
server.register(getTodo).handle(async ({ ctx, path }) => {
// ctx.user and ctx.requestId are available
if (!ctx.user) {
return { status: 401, body: { message: "Unauthorized" } };
}
// ...
});Use Cases
Use cases are pure business logic functions that are separate from HTTP concerns:
// use-cases/create-todo.ts
export async function createTodoUseCase(
input: { title: string },
ports: { db: DbPort }
) {
return await ports.db.todos.create({
id: crypto.randomUUID(),
title: input.title,
completed: false,
});
}
// server.ts
server.register(createTodo).useCase(createTodoUseCase, {
mapInput: ({ body }) => ({ title: body.title }),
mapOutput: (todo) => todo,
});Benefits:
- Testable without HTTP infrastructure
- Reusable across different HTTP endpoints
- Clear separation of business logic and HTTP concerns
Middleware
Middleware intercepts requests and responses for cross-cutting concerns:
import { createServer } from "@contract-kit/server";
import {
loggingMiddleware,
errorMiddleware,
corsMiddleware,
rateLimitMiddleware
} from "@contract-kit/server/middleware";
const server = await createServer({
ports: {},
middleware: [
loggingMiddleware({
logger: console,
logBody: false
}),
corsMiddleware({
origin: "*",
credentials: true,
}),
rateLimitMiddleware({
windowMs: 60000,
max: 100,
}),
errorMiddleware(),
],
createContext: async () => ({}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});Built-in Middleware
The @contract-kit/server/middleware export provides:
- loggingMiddleware: Request/response logging
- errorMiddleware: Error handling and transformation
- corsMiddleware: CORS headers
- rateLimitMiddleware: Rate limiting (requires rate limit port)
Custom Middleware
Create your own middleware:
import type { Middleware } from "@contract-kit/server";
const authMiddleware: Middleware<MyContext> = async (req, next) => {
const token = req.headers.get("authorization");
if (!token) {
return {
status: 401,
body: { message: "Missing authorization" },
};
}
// Continue to next middleware/handler
return await next(req);
};
const server = await createServer({
ports: {},
middleware: [authMiddleware],
createContext: async () => ({}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});Providers
Providers are adapters that implement ports (service interfaces):
import { createServer } from "@contract-kit/server";
import { drizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import { pinoLoggerProvider } from "@contract-kit/provider-logger-pino";
import { redisProvider } from "@contract-kit/provider-redis";
const server = await createServer({
ports: {},
providers: [
drizzleTursoProvider,
pinoLoggerProvider,
redisProvider,
],
providerEnv: process.env,
providerConfig: {
// Override provider defaults
logger: {
level: "debug",
},
},
createContext: async ({ ports }) => ({
db: ports.db,
logger: ports.logger,
cache: ports.cache,
}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});Available Providers
- @contract-kit/provider-drizzle-turso: Database (Drizzle ORM + Turso)
- @contract-kit/provider-logger-pino: Logging (Pino)
- @contract-kit/provider-redis: Caching (Redis via ioredis)
- @contract-kit/provider-rate-limit-upstash: Rate limiting (Upstash)
- @contract-kit/provider-inngest: Background jobs (Inngest)
- @contract-kit/provider-auth-better-auth: Authentication (Better Auth)
- @contract-kit/provider-mail-resend: Email (Resend)
- @contract-kit/provider-mail-smtp: Email (SMTP)
- @contract-kit/provider-event-bus-memory: Event bus (in-memory)
Error Handling
Global Error Handler
const server = await createServer({
ports: {},
createContext: async () => ({}),
onUnhandledError: (error, context) => {
// Log error
console.error("Unhandled error:", error);
// Access context
console.error("Request:", context.req.method, context.req.url);
// Return error response
return {
status: 500,
body: {
message: "Internal server error",
...(process.env.NODE_ENV === "development" && {
error: error.message,
stack: error.stack,
}),
},
};
},
});Route-Level Error Handling
server.register(getTodo).handle(async ({ ctx, path }) => {
try {
const todo = await ctx.db.todos.findById(path.id);
return { status: 200, body: todo };
} catch (error) {
if (error.code === "NOT_FOUND") {
return { status: 404, body: { message: "Todo not found" } };
}
if (error.code === "FORBIDDEN") {
return { status: 403, body: { message: "Access denied" } };
}
// Re-throw to be caught by global error handler
throw error;
}
});Using Error Middleware
import { errorMiddleware } from "@contract-kit/server/middleware";
import { HttpError } from "@contract-kit/errors/http";
const server = await createServer({
ports: {},
middleware: [
errorMiddleware({
includeStack: process.env.NODE_ENV === "development",
}),
],
createContext: async () => ({}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
// Throw HTTP errors in handlers
server.register(getTodo).handle(async ({ ctx, path }) => {
const todo = await ctx.db.todos.findById(path.id);
if (!todo) {
throw new HttpError(404, "Todo not found");
}
return { status: 200, body: todo };
});Framework-Agnostic Types
The server uses framework-agnostic request/response types:
HttpRequestLike
interface HttpRequestLike {
method: string; // "GET", "POST", etc.
url: string; // Full URL or path
headers: Headers; // Web API Headers object
json(): Promise<unknown>; // Parse JSON body
text(): Promise<string>; // Get raw body
}HttpResponseLike
interface HttpResponseLike {
status: number; // HTTP status code
headers?: Record<string, string>; // Response headers
body?: unknown; // Response body (serialized to JSON)
}These types allow the server runtime to work with any HTTP server or framework.
Handler Function Context
Handler functions receive a context object with:
{
ctx: Ctx, // Your custom context from createContext
path: PathParams, // Validated path parameters
query: QueryParams, // Validated query parameters
body: Body, // Validated request body
headers: Headers, // Request headers (Web API Headers)
req: HttpRequestLike, // Raw request object
ports: Ports, // Available ports from providers
contract: Contract, // The contract being handled
}Examples
REST API with Database
import { createServer } from "@contract-kit/server";
import { createContractGroup } from "@contract-kit/core";
import { drizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import { z } from "zod";
// Define contracts
const todos = createContractGroup();
const TodoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
});
const listTodos = todos
.get("/todos")
.response(200, z.array(TodoSchema));
const getTodo = todos
.get("/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, TodoSchema);
const createTodo = todos
.post("/todos")
.body(z.object({ title: z.string() }))
.response(201, TodoSchema);
const server = await createServer({
ports: {},
providers: [drizzleTursoProvider],
providerEnv: process.env,
routes: [
{
contract: listTodos,
handle: async ({ ports }) => {
const todos = await ports.db.todos.findAll();
return { status: 200, body: todos };
},
},
{
contract: getTodo,
handle: async ({ ports, path }) => {
const todo = await ports.db.todos.findById(path.id);
if (!todo) {
return { status: 404, body: { message: "Not found" } };
}
return { status: 200, body: todo };
},
},
{
contract: createTodo,
handle: async ({ ports, body }) => {
const todo = await ports.db.todos.create(body);
return { status: 201, body: todo };
},
},
],
createContext: async ({ ports }) => ({ db: ports.db }),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});With Authentication
const server = await createServer({
ports: {},
createContext: async ({ req, ports }) => {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
return { user: null };
}
try {
const user = await ports.auth.validateToken(token);
return { user };
} catch {
return { user: null };
}
},
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
const protectedRoute = createContractGroup()
.get("/me")
.response(200, z.object({ userId: z.string() }));
server.register(protectedRoute).handle(async ({ ctx }) => {
if (!ctx.user) {
return { status: 401, body: { message: "Unauthorized" } };
}
// User is authenticated
return { status: 200, body: { userId: ctx.user.id } };
});With Background Jobs
import { createServer } from "@contract-kit/server";
import { createContractGroup } from "@contract-kit/core";
import { inngestProvider } from "@contract-kit/provider-inngest";
import { z } from "zod";
const api = createContractGroup();
const sendEmail = api
.post("/send-email")
.body(z.object({ email: z.string().email(), subject: z.string() }))
.response(202, z.object({ message: z.string() }));
const server = await createServer({
ports: {},
providers: [inngestProvider],
providerEnv: process.env,
routes: [
{
contract: sendEmail,
handle: async ({ ports, body }) => {
// Queue background job
await ports.jobs.send({
name: "send-email",
data: { to: body.email, subject: body.subject },
});
return { status: 202, body: { message: "Email queued" } };
},
},
],
createContext: async ({ ports }) => ({ jobs: ports.jobs }),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});Integration with Framework Adapters
This package provides the core runtime. For framework-specific integrations:
Next.js
Use @contract-kit/next:
import { createNextServer } from "@contract-kit/next";
const server = await createNextServer({
ports: {},
createContext: async ({ req }) => ({ /* ... */ }),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
// app/api/[...contract-kit]/route.ts
export const GET = server.api();
export const POST = server.api();Other Frameworks
Create your own adapter by translating framework requests/responses to HttpRequestLike/HttpResponseLike:
import { createServer } from "@contract-kit/server";
const runtime = await createServer({ /* ... */ });
const handler = runtime.api();
// Express example
app.all("*", async (req, res) => {
const request: HttpRequestLike = {
method: req.method,
url: req.url,
headers: new Headers(req.headers as Record<string, string>),
json: async () => req.body,
text: async () => JSON.stringify(req.body),
};
const response = await handler(request);
if (response.headers) {
Object.entries(response.headers).forEach(([key, value]) => {
res.setHeader(key, value);
});
}
res.status(response.status).json(response.body);
});Testing
The framework-agnostic design makes testing easy:
import { createServer } from "@contract-kit/server";
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";
import { expect, test } from "bun:test";
test("get todo returns 200", async () => {
const todos = createContractGroup();
const getTodo = todos
.get("/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
}));
const server = await createServer({
ports: {},
routes: [
{
contract: getTodo,
handle: async ({ path }) => ({
status: 200,
body: { id: path.id, title: "Test", completed: false },
}),
},
],
createContext: async () => ({}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
const handler = server.api();
const response = await handler({
method: "GET",
url: "/todos/123",
headers: new Headers(),
json: async () => ({}),
text: async () => "",
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
id: "123",
title: "Test",
completed: false,
});
});Related Packages
- @contract-kit/next - Next.js adapter
- @contract-kit/core - Contract definitions
- @contract-kit/application - Use case builders
- @contract-kit/ports - Port definitions
- @contract-kit/errors - Error utilities
License
MIT
