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

Published

A Type-Safe, Flexible Web application framework for Bun

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 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 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]          -> File

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

uploadMany(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, metadata

Use 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, metadata

Variants 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 directly

getImageMetadata(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.size

getOptimizedImage(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 AsyncStorage on app start
  • Proactive token refresh (checked every 5 minutes and on app init)
  • Setting the Authorization header 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-allowed

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