npm.io
1.0.80 • Published 3d ago

@mateosuarezdev/brpc

Licence
MIT
Version
1.0.80
Deps
1
Size
161 kB
Vulns
0
Weekly
0

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.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

Keywords