brpc
A type-safe, batteries-included RPC framework for Bun. End-to-end type safety like tRPC, with built-in support for WebSocket subscriptions, file serving, media streaming, form uploads, server-side rendering, automatic image optimization, on the fly optimized image serving, robust and simple storage api to use with S3, Auth service with passord, OTP (email, phone), Oauth, Cookies management, Client side headers management, and much more.
npm install @mateosuarezdev/brpc zod
Table of Contents
- Quick Start
- Context
- Procedures
- Middlewares
- Router
- Client
- Schemas
- Errors
- Streaming Media
- Type Inference Utilities
Quick Start
// context.ts
import { createContext } from "@mateosuarezdev/brpc";
import { db } from "./db";
export const context = createContext(async (req) => ({
db,
}));
// procedure.ts
import { createProcedure } from "@mateosuarezdev/brpc";
import { context } from "./context";
export const procedure = createProcedure(context);
// routes.ts
import { z } from "zod";
import { procedure } from "./procedure";
export const routes = {
hello: procedure
.input(z.object({ name: z.string() }))
.query(async ({ ctx, input }) => {
return `Hello, ${input.name}`;
}),
};
// index.ts
import { createRouter } from "@mateosuarezdev/brpc";
import { context } from "./context";
import { routes } from "./routes";
const router = createRouter({ context, routes });
router.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});Context
createContext defines what data is available in every procedure handler. Return only your app-specific data — req, params, headers, and publishToProcedure are always injected automatically by the router.
import { createContext } from "@mateosuarezdev/brpc";
export const context = createContext(async (req) => ({
db: getDb(),
userId: getUserIdFromCookie(req),
}));If you need no custom data at all:
export const context = createContext(async (req) => {});Every handler receives a fully typed ctx with:
| Field | Type | Description |
|---|---|---|
req |
Request |
The raw Bun request |
params |
Record<string, string> |
URL path parameters |
headers |
Headers |
Response headers you can set |
publishToProcedure |
fn |
Publish to a subscription procedure |
| your fields | inferred | Whatever you return from createContext |
Procedures
createProcedure(context) creates a typed procedure builder. Pass your context to infer the full context type automatically.
import { createProcedure } from "@mateosuarezdev/brpc";
import { context } from "./context";
export const procedure = createProcedure(context);Chain .use(middleware) to add middleware, .input(schema) for input validation, then a handler method.
Query
GET request. Returns { data: result }.
const getUser = procedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.users.findById(input.id);
});Mutation
POST request with a JSON body. Returns { data: result }.
const createPost = procedure
.input(z.object({ title: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.posts.create(input);
});FormMutation
POST with multipart/form-data. Useful for file uploads. Pair with createFileSchema for typed file validation.
import { createFileSchema } from "@mateosuarezdev/brpc";
const uploadAvatar = procedure
.input(
z.object({
file: createFileSchema({ acceptedTypes: { image: "*" }, maxSize: 5 }),
}),
)
.formMutation(async ({ ctx, input }) => {
await saveFile(input.file);
return { success: true };
});File
GET request that serves a file. brpc automatically sets appropriate Content-Type and Cache-Control headers.
const logo = procedure.file(async ({ ctx }) => {
return Bun.file("./assets/logo.png");
});You can also return a Response directly for full control.
FileStream
GET request with HTTP Range support (206 Partial Content). Ideal for video and audio streaming.
import { streamMedia } from "@mateosuarezdev/brpc";
const video = procedure.fileStream(async ({ ctx }) => {
return streamMedia(Bun.file("./videos/intro.mp4"), ctx.req, {
maxChunkSize: 2 * 1024 * 1024, // 2MB chunks
cacheMaxAge: 3600,
acceptedExtensions: [".mp4", ".webm"],
});
});HTML
GET request that returns a raw HTML string with Content-Type: text/html.
const page = procedure.html(async ({ ctx }) => {
return `<!DOCTYPE html><html><body>Hello</body></html>`;
});Subscription
WebSocket-based pub/sub. Clients subscribe to a topic and receive messages when your server publishes.
const messages = procedure
.input(z.object({ roomId: z.string(), text: z.string() }))
.subscription(async ({ ctx, input }) => {
await saveMessage(input);
return input; // returned value is broadcast to all subscribers
});Publish from anywhere on the server:
router.publish(
messages,
{ roomId: "general", text: "hello" },
{ roomId: "general" },
);Parameters in the route path (e.g. ":roomId") scope the subscription topic — subscribers only receive messages matching their params.
Middlewares
createMiddleware
Define reusable, typed middlewares outside of the procedure chain. Return an object to extend the context type — the returned fields are merged into ctx for the rest of the chain.
import { createMiddleware, BRPCError } from "@mateosuarezdev/brpc";
import { context } from "./context";
// Pass context for automatic type inference
const authMiddleware = createMiddleware(context, async (ctx) => {
const session = await getSession(ctx.req);
if (!session)
throw new BRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
return { session };
});
// Or use an explicit generic when no context reference is available
const langMiddleware = createMiddleware<typeof context>(async (ctx) => {
const lang = ctx.req.headers.get("accept-language") ?? "en";
return { lang };
});Use middlewares on individual procedures:
const protectedProcedure = procedure.use(authMiddleware);
const getProfile = protectedProcedure.query(async ({ ctx }) => {
ctx.session.userId; // fully typed
});Chain multiple middlewares — each one's return type accumulates into ctx:
const localizedProtectedProcedure = procedure
.use(authMiddleware) // ctx gains { session }
.use(langMiddleware); // ctx gains { lang }
const getContent = localizedProtectedProcedure.query(async ({ ctx }) => {
ctx.session; // typed
ctx.lang; // typed
});Middlewares that only guard (no context extension) just don't return anything:
const adminMiddleware = createMiddleware(context, async (ctx) => {
if (ctx.session.role !== "admin") {
throw new BRPCError({ code: "FORBIDDEN", message: "Admins only" });
}
// no return — ctx type unchanged
});Built-in Middlewares
Rate Limiter
import { createRateLimiter } from "@mateosuarezdev/brpc";
const rateLimiter = createRateLimiter({
windowMs: 60_000, // 1 minute window
maxRequests: 100, // max 100 requests per window
maxEntries: 10_000, // max IPs to track
message: "Too many requests",
statusCode: 429,
headerPrefix: "X-RateLimit",
});Sets X-RateLimit-Remaining and X-RateLimit-Reset headers automatically.
Path Blocker
Blocks requests matching any of the given regex patterns with a 404.
import { createPathBlocker } from "@mateosuarezdev/brpc";
const blocker = createPathBlocker({
paths: ["/wp-admin", "\\.env", "/\\.git"],
});Profanity Filter
Scans request body, query params, and/or route params for profanity.
import { createProfanityMiddleware } from "@mateosuarezdev/brpc";
const profanityFilter = createProfanityMiddleware({
languages: ["en", "es"],
checkBody: true,
checkQuery: true,
checkParams: false,
message: "Inappropriate content detected",
customLanguages: {
custom: { badWords: ["forbidden"], badPhrases: ["bad phrase"] },
},
});Router
import { createRouter } from "@mateosuarezdev/brpc";
const router = createRouter({
context,
routes,
// Optional
globalMiddlewares: [blocker, rateLimiter],
prefix: "/api",
debug: false,
autoFileCacheControl: true,
websocket: {
onOpen: (ws, ctx) => console.log("connected"),
onClose: (ws, code, reason, ctx) => console.log("disconnected"),
},
integrations: {
betterAuth: {
handler: auth.handler,
},
rawRoutes: {
"/health": async (req) => new Response("ok"),
"/webhook": {
POST: async (req) => handleWebhook(req),
},
},
},
onError: (error, { req, route }) => {
console.error(`Error on ${route}:`, error);
},
});
router.listen(3000);Global Middlewares and Context
globalMiddlewares run before every request, in order. They receive and can mutate ctx at runtime exactly like procedure middlewares — returning an object merges it into ctx.
However, because C is fixed at router creation time, TypeScript cannot widen the context type from a global middleware's return value. If a global middleware needs to add typed fields to ctx, declare them in createContext so they are part of C from the start:
// context.ts — declare fields that global middlewares will populate
export const context = createContext(async (req) => ({
db: myDb,
session: null as Session | null, // global auth middleware fills this
}));
// router.ts
createRouter({
context,
routes,
globalMiddlewares: [
async (ctx) => {
ctx.session = (await getSession(ctx.req)) ?? null; // typed, works fine
},
],
});At runtime, returning an object from a global middleware also works and merges into ctx, but any extra fields added this way won't be reflected in the TypeScript type — use direct assignment on ctx instead for global middlewares.
RouterConfig Options
| Option | Type | Description |
|---|---|---|
context |
fn |
Context creator — receives Request, returns your custom data |
routes |
Routes |
Nested route object |
globalMiddlewares |
Middleware[] |
Run before every request |
prefix |
string |
Path prefix for all routes |
debug |
boolean |
Enable route debug logging |
autoFileCacheControl |
boolean |
Auto-set cache headers for file procedures |
websocket.onOpen |
fn |
Called when a WebSocket connection opens |
websocket.onClose |
fn |
Called when a WebSocket connection closes |
integrations.betterAuth |
{ handler } |
Delegate better-auth routes to its handler |
integrations.rawRoutes |
Record<path, fn> |
Escape hatch for raw Bun routes |
onError |
fn |
Global error handler |
Dynamic Route Parameters
Prefix a segment with : to capture it as a param:
const routes = {
users: {
":id": procedure.query(async ({ ctx }) => {
ctx.params.id; // the captured value
return getUser(ctx.params.id);
}),
},
};Per-Procedure Timeout
Override the default 30s request timeout on any procedure:
const slowQuery = procedure
.input(z.object({ q: z.string() }))
.timeout(120_000) // 2 minutes
.query(async ({ ctx, input }) => heavyComputation(input.q));Testing Without a Server
const response = await router.testRequest(
new Request("http://localhost/hello?input=%7B%22name%22%3A%22world%22%7D"),
);Client
Import from @mateosuarezdev/brpc/client:
import { createBrpcClient } from "@mateosuarezdev/brpc/client";
import type { AppRoutes } from "./routes";
const client = createBrpcClient<AppRoutes>("https://api.example.com", {
headers: async () => ({
Authorization: `Bearer ${getToken()}`,
}),
prefix: "/api",
s3Endpoint: "https://cdn.example.com",
nodeEnv: "production",
debug: false,
});
// Query
const user = await client.routes.users.getById.query({ id: "123" });
// Mutation
const post = await client.routes.posts.create.mutation({ title: "Hello" });
// FormMutation
const result = await client.routes.media.upload.formMutation({ file: myFile });
// File/FileStream — returns a URL string
const url = await client.routes.avatar.file();
// HTML — returns raw HTML string
const html = await client.routes.page.html();
// Subscription
const { unsubscribe, publish } = client.routes.messages[":roomId"].subscription(
(message) => console.log("received:", message),
);
publish({ roomId: "general", text: "hello" });
unsubscribe();BrpcClientOptions
| Option | Type | Description |
|---|---|---|
headers |
fn | Headers |
Default headers for all requests |
fetch |
typeof fetch |
Custom fetch implementation |
WebSocket |
typeof WebSocket |
Custom WebSocket implementation |
prefix |
string |
API path prefix |
apiPrefix |
string |
Additional API prefix |
s3Endpoint |
string |
Required. S3/CDN endpoint for resolving storage object URLs |
nodeEnv |
string |
Required. Current environment ("development" | "production") |
debug |
boolean |
Enable client-side debug logging |
Cache Keys
Every procedure exposes helpers for use with query libraries like TanStack Query or @mateosuarezdev/query:
// String key
client.routes.users.getById.getStringKey({ id: "123" });
// → "users/getById?{"id":"123"}"
// Array key — input from backend schema
client.routes.users.getById.getArrayKey({ id: "123" });
// → ["users/getById", "id", "123"]
// Array key — no backend inputs
client.routes.users.list.getArrayKey();
// → ["users/list"]
// No-inputs shorthand (always ignores any input)
client.routes.users.list.getNoInputsArrayKey();
// → ["users/list"]Client-side context keys
getArrayKey accepts an optional second context argument for keys that are meaningful on the client but not sent to the server — the most common case being scoping cached data by the current user so different users' data never overlaps in the same cache:
// Procedure has no backend input, but cache is scoped per user
client.routes.workout.getSessions.getArrayKey(undefined, { userId });
// → ["workout/getSessions", "userId", "abc123"]
// Procedure has backend input AND needs user scoping
client.routes.workout.getSession.getArrayKey({ sessionId }, { userId });
// → ["workout/getSession", "sessionId", "42", "userId", "abc123"]context keys are sorted and merged with input keys deterministically, so the resulting array is always stable. Invalidation works the same way:
// Invalidate all session queries for a specific user
queryService.invalidateQueries(["workout/getSessions", "userId", userId]);Utilities
// Update WebSocket auth token (e.g. after token refresh)
await client.utils.updateWsAuth();
// Update headers at runtime
await client.utils.setHeader("Authorization", `Bearer ${newToken}`);
await client.utils.setHeaders({ "X-Custom": "value" });BrpcClientError
import { BrpcClientError } from "@mateosuarezdev/brpc/client";
try {
await client.routes.auth.login.mutation({ email, password });
} catch (err) {
if (err instanceof BrpcClientError) {
err.status; // HTTP status code
err.code; // BRPCErrorCode string
err.clientCode; // custom client code if set
err.isUnauthorized(); // status === 401
err.isForbidden(); // status === 403
err.isNotFound(); // status === 404
err.isValidationError(); // status === 400
err.isClientError("INVALID_CREDS"); // clientCode === "INVALID_CREDS"
}
}React
Import from @mateosuarezdev/brpc/client/react:
import { useSubscription } from "@mateosuarezdev/brpc/client/react";
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const { publish } = useSubscription(
(callback) => client.routes.messages[":roomId"].subscription(callback),
(message) => setMessages((prev) => [...prev, message]),
);
return (
<button onClick={() => publish({ roomId, text: "hello" })}>
Send
</button>
);
}Schemas
createFileSchema
Validates File objects in formMutation inputs:
import { createFileSchema } from "@mateosuarezdev/brpc";
z.object({
avatar: createFileSchema({
acceptedTypes: {
image: ["image/jpeg", "image/png"],
},
maxSize: 5, // MB
minSize: 0.1, // MB
messages: {
type: "Only JPEG and PNG are allowed",
maxSize: "File must be under 5MB",
},
}),
document: createFileSchema({
acceptedTypes: { document: "*" },
maxSize: 20,
}),
audio: createFileSchema({
acceptedTypes: { audio: "*" },
}),
});Accepted type groups: image, video, audio, document
Pass "*" to accept all types in a group, or an array of specific MIME type strings.
Errors
import { BRPCError } from "@mateosuarezdev/brpc";
// Constructor
throw new BRPCError({
code: "UNAUTHORIZED",
message: "Token expired",
clientCode: "TOKEN_EXPIRED", // readable code for the client
data: { expiredAt: new Date() },
});
// Static factory shortcuts
throw BRPCError.unauthorized("Token expired", "TOKEN_EXPIRED");
throw BRPCError.badRequest("Invalid input", "INVALID_EMAIL");
throw BRPCError.forbidden("Admins only");
throw BRPCError.notFound("User not found");
throw BRPCError.conflict("Email already in use", "EMAIL_TAKEN");
throw BRPCError.tooManyRequests();
throw BRPCError.internalServerError();Error Codes and HTTP Status Mapping
| Code | Status |
|---|---|
BAD_REQUEST |
400 |
UNAUTHORIZED |
401 |
FORBIDDEN |
403 |
NOT_FOUND |
404 |
CONFLICT |
409 |
UNPROCESSABLE_CONTENT |
422 |
TOO_MANY_REQUESTS |
429 |
INTERNAL_SERVER_ERROR |
500 |
SERVICE_UNAVAILABLE |
503 |
Zod validation errors are automatically caught and returned as BAD_REQUEST (400) with field-level detail.
Streaming Media
Use streamMedia inside a fileStream procedure to handle HTTP Range requests automatically:
import { streamMedia } from "@mateosuarezdev/brpc";
const video = procedure.fileStream(async ({ ctx }) => {
const file = Bun.file(`./media/${ctx.params.filename}`);
return streamMedia(file, ctx.req, {
maxChunkSize: 2 * 1024 * 1024, // 2MB per chunk (default)
cacheMaxAge: 3600, // Cache-Control max-age in seconds
acceptedExtensions: [".mp4", ".webm", ".ogg"],
});
});Responds with 206 Partial Content when the client sends a Range header, enabling native browser seek/scrub for <video> and <audio> elements.
Type Inference Utilities
import type {
InferProcedureInput,
InferProcedureOutput,
InferRouterOutput,
} from "@mateosuarezdev/brpc";
// Infer types from a procedure
type CreatePostInput = InferProcedureInput<typeof routes.posts.create>;
type CreatePostOutput = InferProcedureOutput<typeof routes.posts.create>;
// Infer the full output shape of a routes object
type AppOutput = InferRouterOutput<typeof routes>;
type UserOutput = AppOutput["users"]["getById"];Environment Variables
| Variable | Default | Description |
|---|---|---|
MAX_WS_CONNECTIONS |
1000 |
Max simultaneous WebSocket connections |
WS_TIMEOUT |
30000 |
WebSocket idle timeout in ms |
MAX_REQUEST_SIZE |
10485760 |
Max request body size in bytes (10MB) |
REQUEST_TIMEOUT |
30000 |
Default procedure timeout in ms |
ROUTE_CACHE_SIZE |
1000 |
Route matcher cache size |