@mateosuarezdev/brpc
v1.0.52
Published
A Type-Safe, Flexible Web application framework for Bun
Downloads
174
Maintainers
Readme
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, and more.
npm install @mateosuarezdev/brpc zodTable 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>({
headers: async () => ({
Authorization: `Bearer ${getToken()}`,
}),
prefix: "/api",
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 |
| debug | boolean | Enable client-side debug logging |
Cache Keys
Every procedure exposes helpers for use with query libraries like TanStack Query:
client.routes.users.getById.getStringKey({ id: "123" }); // "users.getById:{'id':'123'}"
client.routes.users.getById.getArrayKey({ id: "123" }); // ["users", "getById", { id: "123" }]
client.routes.users.list.getNoInputsArrayKey(); // ["users", "list"]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 |
