@mateosuarezdev/brpc
v1.0.121
Published
A Type-Safe, Flexible Web application framework for Bun
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, automatic image optimization, on the fly optimized image serving, robust and simple storage api to use with S3, Auth service with password, OTP (email, phone), OAuth, Cookies management, Client side headers management, and much more.
npm install @mateosuarezdev/brpc zodTable of Contents
- Quick Start
- Context
- Procedures
- Middlewares
- Router
- Client
- Schemas
- Errors
- Streaming Media
- Storage
- Auth
- 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 type { CustomContext } from "./context";
export const procedure = createProcedure<CustomContext>();
// 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 your app-specific data plus a headers instance — the router preserves it so anything you set on it (cookies, custom response headers) flows into the final response.
import { createContext } from "@mateosuarezdev/brpc";
export const context = createContext(async (req) => ({
db: getDb(),
userId: getUserIdFromCookie(req),
headers: new Headers(),
}));The headers object you return is the same one used for the response. Anything written to it in the context creator, middlewares, or handlers will be included — including Set-Cookie headers from token refreshes.
Every handler receives a fully typed ctx with:
| Field | Type | Description |
| -------------------- | ------------------------ | -------------------------------------------------- |
| req | Request | The raw Bun request |
| res | Response | The response object |
| params | Record<string, string> | URL path parameters |
| headers | Headers | Response headers — create in context, write anywhere |
| allowedOrigins | string[] \| undefined | CORS origins for this request — override in middlewares |
| publishToProcedure | fn | Publish to a subscription procedure |
| your fields | inferred | Whatever you return from createContext |
Procedures
createProcedure<YourContext>() creates a typed procedure builder. Pass your context type as a generic to get full type inference.
import { createProcedure } from "@mateosuarezdev/brpc";
import type { CustomContext } from "./context";
export const procedure = createProcedure<CustomContext>();Alternatively, pass the context creator function directly and brpc will infer the type:
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. Use it when the request contains files;
ordinary .mutation() routes accept JSON instead.
Multipart is a strict strings-and-files transport. Text fields remain strings
and files remain File; a route opts into conversions at its Zod boundary
(for example, z.coerce.number()). Arrays use indexed bracket paths and
objects use named bracket paths. This gives one unambiguous shape for the BRPC
client and direct callers:
voucher -> File
files[0], files[1] -> File[]
attachments[0][label] -> string
attachments[0][file] -> FileRepeated unindexed names and append-style files[] are intentionally not
accepted. They cannot distinguish a one-item array from a single file; direct
callers use indexed keys just like the BRPC client.
Typed context in a form mutation
formMutation is also useful when a file upload carries current UI context.
The router rebuilds its nested shape, but every non-file leaf is still text.
Describe that context at the procedure input boundary and coerce only the
fields that need their native type before passing it to application code or an
LLM prompt:
const dayContextSchema = z.object({
targets: z.object({
calories: z.coerce.number(),
protein: z.coerce.number(),
}),
proteinProtection: z.stringbool(), // "true" / "false"
meals: z.array(z.object({ calories: z.coerce.number() })).default([]),
});
procedure.input(z.object({
image: createFileSchema({ acceptedTypes: { image: "*" }, maxSize: 15 }),
day: dayContextSchema.optional(),
}));Do not use z.coerce.boolean() for multipart text: JavaScript treats the
non-empty string "false" as truthy. Use z.stringbool() instead. Empty
arrays emit no multipart fields, so use .default([]) when an absent array
means an empty one. Send a nested value as one JSON string only when it must
retain every native primitive before its route-specific parsing; otherwise the
typed Zod input is the normal path.
import { createFileSchema } from "@mateosuarezdev/brpc";
const uploadDocuments = procedure
.input(
z.object({
title: z.string(),
attachments: z.array(
z.object({
label: z.string(),
file: createFileSchema({
acceptedTypes: { document: "*" },
maxSize: 5,
}),
}),
),
}),
)
.formMutation(async ({ ctx, input }) => {
await saveDocuments(input.title, input.attachments);
return { success: true };
});The BRPC client creates these paths recursively:
await client.routes.documents.upload.formMutation({
title: "Tax documents",
attachments: [
{ label: "Invoice", file: invoiceFile },
{ label: "Receipt", file: receiptFile },
],
});In React Native, pass the descriptor produced from an Expo picker asset. The
client serializes it as the same multipart file leaf; the route still receives
a normal server-side File.
import type { NativeFileDescriptor } from "@mateosuarezdev/brpc/client";
const voucher: NativeFileDescriptor = {
uri: asset.uri,
name: asset.fileName ?? "voucher.pdf",
type: asset.mimeType ?? "application/pdf",
};
await client.routes.documents.upload.formMutation({
title: "Tax documents",
attachments: [{ label: "Invoice", file: voucher }],
});Any JavaScript caller can send the same request without the BRPC client:
const formData = new FormData();
formData.append("title", "Tax documents");
formData.append("attachments[0][label]", "Invoice");
formData.append("attachments[0][file]", invoiceFile);
formData.append("attachments[1][label]", "Receipt");
formData.append("attachments[1][file]", receiptFile);
await fetch("/api/documents/upload", {
method: "POST",
body: formData,
});File
GET request that serves a file. Handler returns BunFile or a raw Response. When returning a BunFile, brpc automatically sets Content-Type and Cache-Control. When returning a Response, it is passed through untouched — giving you full control for streaming, SSE, HTML, downloads, or anything Bun's Response supports.
// Serve a static file — brpc sets Content-Type and Cache-Control
const logo = procedure.file(async ({ ctx }) => {
return Bun.file("./assets/logo.png");
});
// Stream video/audio directly — Bun handles it natively
const media = procedure.file(async ({ ctx }) => {
const file = Bun.file(`./media/${ctx.params.filename}`);
return new Response(file, {
headers: { "Content-Type": file.type },
});
});
// Server-Sent Events
const events = procedure.file(async ({ ctx }) => {
const encoder = new TextEncoder();
const body = new ReadableStream({
start(controller) {
const send = (data: string) =>
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
const interval = setInterval(() => send(JSON.stringify({ tick: Date.now() })), 1000);
setTimeout(() => { clearInterval(interval); controller.close(); }, 30_000);
},
});
return new Response(body, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});
});
// File download with custom headers
const report = procedure
.input(z.object({ id: z.string() }))
.file(async ({ ctx, input }) => {
const csv = await generateReport(input.id);
return new Response(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${input.id}.csv"`,
},
});
});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.
Quiet subscriptions stay connected without application-level heartbeat messages.
BRPC relies on Bun's native WebSocket ping/pong and transport timeout handling;
it does not close sockets merely because no application messages arrive.
The former WS_TIMEOUT environment variable is no longer used. Normal socket
closure still removes the connection and its topic subscriptions.
Run yarn workspace @mateosuarezdev/brpc test tests/subscriptions.test.ts
from the monorepo root for real-socket lifecycle coverage, including a 65-second
quiet production subscription followed by publish delivery and normal closure.
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. Pass raw parameter values to router.publish() and ctx.publishToProcedure(); BRPC URI-encodes only those dynamic topic segments, while subscription handlers receive decoded ctx.params.
Use .onSubscribe() for admission checks. It runs after dynamic topic params are resolved and before the socket joins the topic. Normal .use() middleware runs only when a client publishes to a subscription; server-originated router.publish() calls remain trusted.
const userChanges = procedure
.onSubscribe(async (ctx) => {
if (!ctx.session || ctx.params.userId !== ctx.session.sub) {
throw BRPCError.unauthorized("Unauthorized");
}
})
.subscription(async ({ ctx }) => ({ changed: true, userId: ctx.params.userId }));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 type { CustomContext } from "./context";
// Pass context creator 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 with your context type
const langMiddleware = createMiddleware<CustomContext>(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
});Per-Procedure CORS
By default the router uses allowedOrigins from the router config for every request. A middleware can override this for a specific procedure or group of procedures by setting ctx.allowedOrigins:
const mobileOnlyMiddleware = createMiddleware(context, async (ctx) => {
return { allowedOrigins: ["https://mobile.myapp.com"] };
});
const adminOnlyMiddleware = createMiddleware(context, async (ctx) => {
return { allowedOrigins: ["https://admin.myapp.com"] };
});
export const routes = {
// open to all configured origins
public: procedure.query(async () => "hello"),
// only reachable from the mobile app
mobileOnly: procedure
.use(mobileOnlyMiddleware)
.query(async ({ ctx }) => "mobile data"),
// only reachable from the admin dashboard
admin: {
stats: procedure
.use(adminOnlyMiddleware)
.query(async ({ ctx }) => getStats()),
},
};ctx.allowedOrigins takes priority over the global allowedOrigins. OPTIONS preflight always uses the global list since context and middlewares haven't run yet at that point.
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,
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 |
| allowedOrigins | string[] | Global CORS origin allowlist — empty allows all (dev default) |
| debug | boolean | Enable route debug logging |
| 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
router.testRequest() executes a real HTTP request through the complete BRPC
pipeline without opening a port: route matching, context creation, global and
procedure middlewares, input validation, handlers, and BRPC error responses.
It returns the standard Response, so tests can assert both status codes and
response bodies with Bun's built-in test runner.
import { describe, expect, test } from "bun:test";
import { createRouter } from "@mateosuarezdev/brpc";
import { context } from "./context";
import { routes } from "./routes";
const router = createRouter({ context, routes });
describe("posts.create", () => {
test("creates a post", async () => {
const response = await router.testRequest(
new Request("http://localhost/posts/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Hello", body: "World" }),
}),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: expect.objectContaining({ title: "Hello" }),
});
});
});Use ordinary Request objects. Queries read URL search parameters, mutations
require a JSON Content-Type, and form mutations require multipart/form-data.
// Dynamic route parameters
const response = await router.testRequest(
new Request("http://localhost/users/usr_123"),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ data: expect.any(Object) });Test failures through the router too. This confirms the externally visible status and BRPC error shape, rather than only testing an implementation detail.
const response = await router.testRequest(
new Request("http://localhost/posts/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "" }),
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: { code: "BAD_REQUEST" },
});For webhook tests, build a real signed Request in a test mock, then pass it
to router.testRequest(). This tests the raw-body/signature middleware and the
route together without starting a server.
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" });
// Mutation on a procedure that takes no input — call it with no argument
const account = await client.routes.profile.ensureBillingAccount.mutation();
// 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(
(error, message) => {
if (error) return console.error(error.type, error.message);
console.log("received:", message);
},
{ params: { roomId: "general" } },
);
publish({ roomId: "general", text: "hello" });
unsubscribe();Subscriptions wait for WebSocket authentication by default. Public feeds can subscribe immediately while auth is refreshing with { auth: "none" }.
The React useSubscription options also accept enabled. Set it to false to skip subscription setup; changing it back to true attaches the topic again, and disabling or unmounting cleans it up.
Dynamic Client Route Params
Pass dynamic route values through the second options object. Client inputs stay separate from path params, and the resolved path is used for transport and cache-key identity.
The client infers every :param segment in the route and requires the complete params object for dynamic routes.
await client.routes.users[":userId"].query(
{ includePosts: true },
{ params: { userId: "usr_123" } },
);
client.routes.users[":userId"].getArrayKey(
{ includePosts: true },
{ params: { userId: "usr_123" } },
);
client.routes.billingChanges[":userId"].subscription(
(error, message) => {},
{ params: { userId: "usr_123" }, auth: "required" },
);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 scoping keys
Both array helpers take a keys object in their options for values that are meaningful on the client but never sent to the server. The usual case is scoping cached data by the current user, so two accounts never read each other's cached result:
// Procedure has no backend input, but cache is scoped per user
client.routes.workout.getSessions.getNoInputsArrayKey({ keys: { userId } });
// → ["workout/getSessions", "userId", "abc123"]
// Procedure has backend input AND needs user scoping
client.routes.workout.getSession.getArrayKey({ sessionId }, { keys: { userId } });
// → ["workout/getSession", "sessionId", "42", "userId", "abc123"]
// Alongside dynamic route params
client.routes.teams[":teamId"].getMembers.getArrayKey(undefined, {
params: { teamId },
keys: { userId },
});Scoping keys are sorted and appended after the input segments. Partial key matching compares from the front, so the bare procedure key still matches every scope:
// Invalidate that procedure for every user
queryService.invalidateQueries(["workout/getSessions"]);
// Invalidate it for one user only
queryService.invalidateQueries(["workout/getSessions", "userId", userId]);Scoping by a value that can be absent
A key expression evaluates on every render, including the one where the session is already gone — so session!.user.id throws on sign-out even when the query is disabled. Use an empty fallback instead and let enabled gate the request. An empty, null, or undefined value contributes no segment, so the key simply shortens while the query is idle rather than caching under a placeholder identity:
const billingAccount = useQuery({
key: brpc.routes.profile.ensureBillingAccount.getNoInputsArrayKey({
keys: { userId: session?.user.id ?? "" },
}),
get: () => brpc.routes.profile.ensureBillingAccount.mutation(),
enabled: !!session,
});Because the key changes with the session, signing in as somebody else reads that account's own entry, while the previous user's entry stays cached and is still there if they sign back in. That is the reason to scope keys rather than clear the cache on sign-out: clearing would throw away the offline data persistence exists to keep.
Utilities
// Update WebSocket auth token (e.g. after token refresh)
await client.utils.updateWsAuth();
// Heal client-side state when the socket opens after a close or a failed attempt.
// A first attempt that succeeds does not call this; the returned function removes
// this listener.
const unsubscribe = client.utils.onWsReconnect(() => {
invalidateLiveData();
});
// 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
Create one subscription service, then bind the React hook once:
import { SubscriptionService } from "@mateosuarezdev/brpc/client";
import { createUseSubscription } from "@mateosuarezdev/brpc/client/react";
const subscriptionService = new SubscriptionService();
const useSubscription = createUseSubscription(subscriptionService);
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const { publish } = useSubscription({
procedure: client.routes.messages[":roomId"],
options: { params: { roomId } },
callback: (error, message) => {
if (error) return;
setMessages((prev) => [...prev, message]);
},
});
return (
<button onClick={() => publish({ roomId, text: "hello" })}>
Send
</button>
);
}For ordinary React state that needs to heal after a missed WebSocket publish, bind the reconnect listener once to the client instance. The hook retains the latest callback across renders and removes its listener on unmount:
import { createUseOnWsReconnect } from "@mateosuarezdev/brpc/client/react";
const useOnWsReconnect = createUseOnWsReconnect(client);
function BillingState() {
useOnWsReconnect(() => {
subscriptionsInfo.invalidate();
});
}Reconnect listeners are client lifecycle signals, not subscription messages: they fire after a connection following a disconnection, including when no topic is currently subscribed. Use them to refetch/invalidate local cache; server authorization must continue to read authoritative state.
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.
Storage
All storage utilities are exported from the main package:
import {
uploadOne,
uploadMany,
uploadSingleObject,
generateAndUploadImage,
uploadGeneratedVariants,
deleteOne,
deleteManyKeys,
deleteManyObjects,
extractObjectVariantKeys,
generateVariants,
getOptimizedImage,
getImageMetadata,
} from "@mateosuarezdev/brpc";Requires Bun's S3 client to be configured via environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ENDPOINT, AWS_BUCKET).
S3 is the only persisted-object provider. Every stored upload has a non-empty
variants object: default for a single representation, or named image sizes
from xs through xl. A variant always has its S3 key, stored name, and
typed metadata. Keys are canonical: derive public URLs from the configured
endpoint and authorize/presign private URLs in the consuming application's
domain route; do not store either URL on a variant.
S3
uploadOne(path, file, opts?)
Uploads a single File or pre-built buffer object to S3. Returns a RawUploadResult with the key, metadata, and provider info ready to insert into the database.
// Plain file upload
const result = await uploadOne("images/avatars", userFile);
// With options
const result = await uploadOne("images/avatars", userFile, {
acl: "private",
uploadedBy: ctx.session.sub,
});
// result.key — S3 key
// result.metadata — type, extension, size, dimensions (for images)
// result.isPrivateuploadMany(path, files, opts?)
Uploads multiple files in parallel. Returns { data, errors }.
const { data, errors } = await uploadMany("documents", files, {
acl: "public-read",
throwIf: "any", // "any" | "all" | "ignore"
});uploadSingleObject(path, file, opts?)
Uploads one file and returns it in the stored object shape — the counterpart to generateAndUploadImage for anything with a single representation: a PDF, a CSV, a text file. There is no size ladder to generate, so the file is stored as one variant keyed default.
const upload = await uploadSingleObject("/payouts", pdfFile, {
acl: "private",
uploadedBy: ctx.session.sub,
});
// upload.shared — name, resolvedType ("document" for a PDF), acl, isPrivate
// upload.variants.default — key, name, metadataUse this rather than uploadOne when the result is going into storage. uploadOne returns a RawUploadResult, which is what S3 returned; UploadResult is what an application stores, and this performs the conversion between them.
Image metadata is carried through when present, so uploading an image this way keeps its dimensions rather than dropping them — useful when you want the file stored without generating a variant ladder.
generateAndUploadImage(path, file, opts?)
The main image upload function. Automatically generates optimized variants (original, compressed, thumbnail, etc.) and uploads them all to S3 in one call.
const uploadResult = await generateAndUploadImage("/exercises", imageFile, {
acl: "public-read",
uploadedBy: ctx.session.sub,
quality: 80,
});
// uploadResult.shared — name, resolvedType, provider, acl, isPrivate
// uploadResult.variants — keyed object with each variant's key, name, metadataVariants are keyed by VariantName, which is an image size (xs, sm, md, lg, xl) or default for a single-representation file. An upload result is sparse: it contains only variants it actually wrote.
uploadGeneratedVariants settles all variant writes. If one fails, it deletes
every fulfilled sibling key before rethrowing the original failure, so a failed
generated upload does not leave partial S3 objects behind.
uploadGeneratedVariants(path, generated, opts?)
Lower-level — uploads pre-generated variants returned by generateVariants. Use generateAndUploadImage unless you need to modify variants before uploading.
deleteOne(key, opts?)
Deletes a single S3 file by key. Returns { key, deleted, error } — never throws, so caller decides how to handle failure.
const { deleted, error } = await deleteOne("images/avatars/abc.webp");
if (!deleted) console.error("Failed to delete:", error);deleteManyKeys(keys, opts?)
Deletes multiple keys in parallel. Returns a per-key result array.
const results = await deleteManyKeys(variantKeys);extractObjectVariantKeys(objects) and deleteManyObjects(objects, opts?)
Stored objects hold sparse variants: an image may have xs through xl, while
a document normally has only default. extractObjectVariantKeys reads every
real variant key and removes duplicates. deleteManyObjects is the convenience
form that extracts those keys and deletes them.
const results = await deleteManyObjects([voucher, previousAvatar]);
if (results.some((result) => !result.deleted)) {
throw new Error("Could not delete every stored representation");
}These functions delete S3 data only; an application owns its database records and domain authorization. When removing a domain record and its objects, run the object delete plus database deletes in its database transaction and abort the transaction on any failed result. S3 is not transactional, so a partial S3 failure cannot restore already-deleted siblings; log and reconcile that rare case rather than claiming distributed atomicity.
Images
generateVariants(file, opts?)
Generates optimized image variants from a File, BunFile, or local path using sharp. Returns a GeneratedImageVariants object with each variant as an UploadableObjectBuffer.
const generated = await generateVariants(imageFile, {
quality: 80,
acl: "public-read",
});
// Pass to uploadGeneratedVariants or inspect variants directlygetImageMetadata(buffer)
Extracts metadata from an image buffer using sharp.
const meta = await getImageMetadata(buffer);
// meta.width, meta.height, meta.aspectRatio, meta.aspectRatioStr
// meta.type, meta.extension, meta.sizegetOptimizedImage(options)
Returns a cached optimized version of an image, creating one on the fly if not yet cached. Useful for serving resized images without re-processing on every request.
const file = await getOptimizedImage({
key: "images/photo.jpg",
width: 800,
quality: 75,
});
if (!file) throw BRPCError.notFound();
return file;Auth
Built-in auth service for SurrealDB. Supports email/phone sign-up and sign-in with password or OTP (passwordless), JWT sessions with refresh tokens, and cookie management.
Import from the auth subpath:
import { createAuth, setAuthCookies, clearAuthCookies, parseCookies, COOKIE_NAMES } from "@mateosuarezdev/brpc/auth";Requires the migrations/0000_brpc.surql baseline to be applied (sets up users, user_identities, sessions, otp_codes tables).
For the complete, copyable BRPC route layer — including OTP, refresh, password-reset, sign-out, and restore/reconnect session validation — see Auth routes.
Server Setup
// services/auth/index.ts
import { createAuth } from "@mateosuarezdev/brpc/auth";
import { CacheService } from "@mateosuarezdev/brpc/cache";
import { db } from "../db";
export const auth = createAuth({
db,
secrets: {
jwt: process.env.JWT_SECRET!,
jwtRefresh: process.env.JWT_REFRESH_SECRET!,
},
defaultSignupRoles: ["user"],
isProd: process.env.NODE_ENV === "production",
debug: process.env.NODE_ENV === "development",
cache: CacheService, // in-memory session cache
otpProvider: {
sendEmailOTP: async ({ email, code }) => {
await mailer.send({ to: email, subject: "Your code", text: code });
},
sendSMSOTP: async ({ phone, code }) => {
await sms.send({ to: phone, body: code });
},
},
});createAuth Config
| Option | Type | Description |
| --------------------- | --------------- | -------------------------------------------------------- |
| db | Surreal | Connected SurrealDB instance |
| secrets.jwt | string | JWT signing secret for access tokens |
| secrets.jwtRefresh | string | JWT signing secret for refresh tokens |
| defaultSignupRoles | string[] | Roles assigned to new users on sign-up |
| cache | CacheService | Optional in-memory session cache to reduce DB reads |
| otpProvider | OTPProvider | Callbacks for delivering OTP codes |
| accessTokenExpiry | string | JWT expiry string (default: "24h") |
| sessionIdleDays | number | Days without a refresh before a session lapses (default: 60) |
| isProd | boolean | Enables Secure flag on cookies |
| debug | boolean | Logs auth events |
| disableCleanupCron | boolean | Disables the nightly cleanup of invalidated/idle sessions and used OTPs |
Auth Routes
// routes/auth.ts
import { auth } from "../services/auth";
export const authRoutes = {
signUpEmail: apiProcedure
.input(z.object({
email: z.email(),
password: z.string().optional(),
passwordless: z.boolean().optional(),
userMetadata: z.object({ name: z.string().nullable() }).optional(),
}))
.mutation(async ({ input, ctx }) => {
// Returns AuthResponse on password sign-up, OTPResponse on passwordless
return auth.signUp({ type: "email", ...input }, ctx.req);
}),
signInEmail: apiProcedure
.input(z.object({
email: z.string(),
password: z.string().optional(),
passwordless: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
return auth.signIn({ type: "email", ...input }, ctx.req);
}),
signUpPhone: apiProcedure
.input(z.object({ phone: z.string(), passwordless: z.boolean().optional() }))
.mutation(async ({ input, ctx }) => {
return auth.signUp({ type: "phone", ...input }, ctx.req);
}),
signInPhone: apiProcedure
.input(z.object({ phone: z.string(), passwordless: z.boolean().optional() }))
.mutation(async ({ input, ctx }) => {
return auth.signIn({ type: "phone", ...input }, ctx.req);
}),
verifyOTP: apiProcedure
.input(z.object({ otpId: z.string(), code: z.string().length(6) }))
.mutation(async ({ input, ctx }) => {
// Always returns AuthResponse — OTP verification always completes sign-in
return auth.verifyOTP(input.otpId, input.code, ctx.req);
}),
signOut: apiUserProcedure.mutation(async ({ ctx }) => {
await auth.signOut(ctx.session!.sessionId);
return { success: true };
}),
refreshToken: apiProcedure
.input(z.object({ refreshToken: z.string() }))
.mutation(async ({ input }) => {
return auth.refreshTokens(input.refreshToken);
}),
};Context Integration
Verify sessions in context so ctx.session is available to all procedures:
// router/context/index.ts
import { createAuth } from "@mateosuarezdev/brpc/auth";
import {
parseCookies,
COOKIE_NAMES,
setAuthCookies,
} from "@mateosuarezdev/brpc/auth";
import { auth } from "../services/auth";
export const context = createContext(async (req) => {
const authHeader = req.headers.get("authorization");
const cookieHeader = req.headers.get("cookie");
const headers = new Headers();
let session = null;
// Header-based auth (API / mobile clients)
if (authHeader) {
session = auth.getCachedSession(authHeader)
?? await auth.fastVerifyToken(authHeader);
if (session) auth.cacheSession(authHeader, session);
}
// Cookie-based auth (SSR / web clients)
else if (cookieHeader) {
const cookies = parseCookies(cookieHeader);
const accessToken = cookies[COOKIE_NAMES.ACCESS_TOKEN];
if (accessToken) {
const bearer = `Bearer ${accessToken}`;
session = auth.getCachedSession(bearer)
?? await auth.fastVerifyToken(bearer);
if (session) auth.cacheSession(bearer, session);
// Refresh if near expiry
if (session && auth.isTokenNearExpiry(session.exp)) {
const refresh = cookies[COOKIE_NAMES.REFRESH_TOKEN];
if (refresh) {
try {
const result = await auth.refreshTokens(refresh);
auth.invalidateCachedSession(bearer);
setAuthCookies(headers, result, isProd);
const newBearer = `Bearer ${result.accessToken}`;
session = await auth.fastVerifyToken(newBearer);
if (session) auth.cacheSession(newBearer, session);
} catch {}
}
}
}
}
return { session, headers };
});fastVerifyToken() intentionally verifies only the JWT signature for this
request-time context. Applications validate a restored or reconnected client
session with verifyToken() and clear local auth state when it reports an
invalid reason other than expired (refresh that one); this is what makes
deleted-account session revocation take effect without adding a database read
to every API call.
Session Type (DecodedJWT)
| Field | Type | Description |
| -------------- | -------------- | --------------------------------- |
| sub | string | User ID |
| sessionId | string | Session record ID |
| appMetadata | AppMetadata | Roles and custom app metadata |
| userMetadata | UserMetadata | Name, theme, preferred language |
| exp | number | Token expiry (Unix timestamp) |
Cookie Helpers
import { setAuthCookies, clearAuthCookies, parseCookies, COOKIE_NAMES } from "@mateosuarezdev/brpc/auth";
// Set auth cookies on the response (after sign-in)
setAuthCookies(headers, { accessToken, refreshToken, sessionId }, isProd);
// Clear auth cookies (on sign-out)
clearAuthCookies(headers, isProd);
// Parse cookie header into a plain object
const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies[COOKIE_NAMES.ACCESS_TOKEN];
// Cookie names
COOKIE_NAMES.ACCESS_TOKEN // "access_token"
COOKIE_NAMES.REFRESH_TOKEN // "refresh_token"
COOKIE_NAMES.SESSION_ID // "session_id"Client-Side Auth
For React Native / React apps, wrap your app in AuthProvider and use the useAuth hook.
// auth/provider.tsx
import { AuthProvider } from "./provider";
export default function App() {
return (
<AuthProvider debug={__DEV__}>
<RootNavigator />
</AuthProvider>
);
}// Anywhere inside the tree
import { useAuth } from "./auth/context";
function ProfileScreen() {
const { session, signOut, checkUserRoles } = useAuth();
const isAdmin = checkUserRoles("admin");
return (
<View>
<Text>{session?.user.userMetadata.name}</Text>
{isAdmin && <AdminPanel />}
<Button onPress={signOut} title="Sign out" />
</View>
);
}AuthProvider manages:
- Restoring session from
AsyncStorageon app start - Proactive token refresh (checked every 5 minutes and on app init)
- Setting the
Authorizationheader on the brpc client after sign-in/refresh - Clearing local session and headers on sign-out
useAuth() returns:
| Field | Type | Description |
| ---------------- | ----------------------------------- | --------------------------------------------- |
| status | "pending" \| "settled" \| "error" | Auth initialization state |
| session | AuthResponse \| null | Current session including user and tokens |
| otpResponse | OTPResponse \| null | Pending OTP state (set after passwordless req) |
| signUpEmailOTP | fn | Start email passwordless sign-up |
| signInEmailOTP | fn | Start email passwordless sign-in |
| verifyOTP | fn | Complete OTP flow with 6-digit code |
| signOut | fn | Sign out and clear local session |
| checkUserRoles | fn | Check if current user has required roles |
checkUserRoles(roles, options?)
checkUserRoles("admin"); // single role
checkUserRoles(["admin", "staff"]); // any of these (default)
checkUserRoles(["admin", "staff"], { requireAll: true }); // must have all
checkUserRoles("editor", { adminBypass: false }); // admins not auto-allowedType 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 |
| 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 |
