npm package: @mateosuarezdev/brpc
Description: A Type-Safe, Flexible Web application framework for Bun
Author: Mateo Suarez
Latest version: 1.0.52

# 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](#quick-start)
- [Context](#context)
- [Procedures](#procedures)
  - [Query](#query)
  - [Mutation](#mutation)
  - [FormMutation](#formmutation)
  - [File](#file)
  - [FileStream](#filestream)
  - [HTML](#html)
  - [Subscription](#subscription)
- [Middlewares](#middlewares)
  - [createMiddleware](#createmiddleware)
  - [Built-in Middlewares](#built-in-middlewares)
- [Router](#router)
- [Client](#client)
  - [React](#react)
- [Schemas](#schemas)
- [Errors](#errors)
- [Streaming Media](#streaming-media)
- [Type Inference Utilities](#type-inference-utilities)

---

## Quick Start

```ts
// 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.

```ts
import { createContext } from "@mateosuarezdev/brpc";

export const context = createContext(async (req) => ({
  db: getDb(),
  userId: getUserIdFromCookie(req),
}));
```

If you need no custom data at all:

```ts
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.

```ts
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 }`.

```ts
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 }`.

```ts
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.

```ts
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.

```ts
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.

```ts
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`.

```ts
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.

```ts
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:

```ts
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.

```ts
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:

```ts
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`:

```ts
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:

```ts
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

```ts
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.

```ts
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.

```ts
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

```ts
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:

```ts
// 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:

```ts
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:

```ts
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

```ts
const response = await router.testRequest(
  new Request("http://localhost/hello?input=%7B%22name%22%3A%22world%22%7D")
);
```

---

## Client

Import from `@mateosuarezdev/brpc/client`:

```ts
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:

```ts
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

```ts
// 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

```ts
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`:

```ts
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:

```ts
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

```ts
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:

```ts
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

```ts
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 |