npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mateosuarezdev/brpc

v1.0.52

Published

A Type-Safe, Flexible Web application framework for Bun

Downloads

174

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 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>({
  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 |