@mateosuarezdev/brpc 1.0.80 → 1.0.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,744 +1,1312 @@
1
- # brpc
2
-
3
- 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.
4
-
5
- ```
6
- npm install @mateosuarezdev/brpc zod
7
- ```
8
-
9
- ---
10
-
11
- ## Table of Contents
12
-
13
- - [Quick Start](#quick-start)
14
- - [Context](#context)
15
- - [Procedures](#procedures)
16
- - [Query](#query)
17
- - [Mutation](#mutation)
18
- - [FormMutation](#formmutation)
19
- - [File](#file)
20
- - [FileStream](#filestream)
21
- - [HTML](#html)
22
- - [Subscription](#subscription)
23
- - [Middlewares](#middlewares)
24
- - [createMiddleware](#createmiddleware)
25
- - [Built-in Middlewares](#built-in-middlewares)
26
- - [Router](#router)
27
- - [Client](#client)
28
- - [React](#react)
29
- - [Schemas](#schemas)
30
- - [Errors](#errors)
31
- - [Streaming Media](#streaming-media)
32
- - [Type Inference Utilities](#type-inference-utilities)
33
-
34
- ---
35
-
36
- ## Quick Start
37
-
38
- ```ts
39
- // context.ts
40
- import { createContext } from "@mateosuarezdev/brpc";
41
- import { db } from "./db";
42
-
43
- export const context = createContext(async (req) => ({
44
- db,
45
- }));
46
-
47
- // procedure.ts
48
- import { createProcedure } from "@mateosuarezdev/brpc";
49
- import { context } from "./context";
50
-
51
- export const procedure = createProcedure(context);
52
-
53
- // routes.ts
54
- import { z } from "zod";
55
- import { procedure } from "./procedure";
56
-
57
- export const routes = {
58
- hello: procedure
59
- .input(z.object({ name: z.string() }))
60
- .query(async ({ ctx, input }) => {
61
- return `Hello, ${input.name}`;
62
- }),
63
- };
64
-
65
- // index.ts
66
- import { createRouter } from "@mateosuarezdev/brpc";
67
- import { context } from "./context";
68
- import { routes } from "./routes";
69
-
70
- const router = createRouter({ context, routes });
71
-
72
- router.listen(3000, () => {
73
- console.log("Server running on http://localhost:3000");
74
- });
75
- ```
76
-
77
- ---
78
-
79
- ## Context
80
-
81
- `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.
82
-
83
- ```ts
84
- import { createContext } from "@mateosuarezdev/brpc";
85
-
86
- export const context = createContext(async (req) => ({
87
- db: getDb(),
88
- userId: getUserIdFromCookie(req),
89
- }));
90
- ```
91
-
92
- If you need no custom data at all:
93
-
94
- ```ts
95
- export const context = createContext(async (req) => {});
96
- ```
97
-
98
- Every handler receives a fully typed `ctx` with:
99
-
100
- | Field | Type | Description |
101
- | -------------------- | ------------------------ | ---------------------------------------- |
102
- | `req` | `Request` | The raw Bun request |
103
- | `params` | `Record<string, string>` | URL path parameters |
104
- | `headers` | `Headers` | Response headers you can set |
105
- | `publishToProcedure` | `fn` | Publish to a subscription procedure |
106
- | _your fields_ | _inferred_ | Whatever you return from `createContext` |
107
-
108
- ---
109
-
110
- ## Procedures
111
-
112
- `createProcedure(context)` creates a typed procedure builder. Pass your context to infer the full context type automatically.
113
-
114
- ```ts
115
- import { createProcedure } from "@mateosuarezdev/brpc";
116
- import { context } from "./context";
117
-
118
- export const procedure = createProcedure(context);
119
- ```
120
-
121
- Chain `.use(middleware)` to add middleware, `.input(schema)` for input validation, then a handler method.
122
-
123
- ### Query
124
-
125
- GET request. Returns `{ data: result }`.
126
-
127
- ```ts
128
- const getUser = procedure
129
- .input(z.object({ id: z.string() }))
130
- .query(async ({ ctx, input }) => {
131
- return ctx.db.users.findById(input.id);
132
- });
133
- ```
134
-
135
- ### Mutation
136
-
137
- POST request with a JSON body. Returns `{ data: result }`.
138
-
139
- ```ts
140
- const createPost = procedure
141
- .input(z.object({ title: z.string(), body: z.string() }))
142
- .mutation(async ({ ctx, input }) => {
143
- return ctx.db.posts.create(input);
144
- });
145
- ```
146
-
147
- ### FormMutation
148
-
149
- POST with `multipart/form-data`. Useful for file uploads. Pair with `createFileSchema` for typed file validation.
150
-
151
- ```ts
152
- import { createFileSchema } from "@mateosuarezdev/brpc";
153
-
154
- const uploadAvatar = procedure
155
- .input(
156
- z.object({
157
- file: createFileSchema({ acceptedTypes: { image: "*" }, maxSize: 5 }),
158
- }),
159
- )
160
- .formMutation(async ({ ctx, input }) => {
161
- await saveFile(input.file);
162
- return { success: true };
163
- });
164
- ```
165
-
166
- ### File
167
-
168
- GET request that serves a file. brpc automatically sets appropriate `Content-Type` and `Cache-Control` headers.
169
-
170
- ```ts
171
- const logo = procedure.file(async ({ ctx }) => {
172
- return Bun.file("./assets/logo.png");
173
- });
174
- ```
175
-
176
- You can also return a `Response` directly for full control.
177
-
178
- ### FileStream
179
-
180
- GET request with HTTP Range support (206 Partial Content). Ideal for video and audio streaming.
181
-
182
- ```ts
183
- import { streamMedia } from "@mateosuarezdev/brpc";
184
-
185
- const video = procedure.fileStream(async ({ ctx }) => {
186
- return streamMedia(Bun.file("./videos/intro.mp4"), ctx.req, {
187
- maxChunkSize: 2 * 1024 * 1024, // 2MB chunks
188
- cacheMaxAge: 3600,
189
- acceptedExtensions: [".mp4", ".webm"],
190
- });
191
- });
192
- ```
193
-
194
- ### HTML
195
-
196
- GET request that returns a raw HTML string with `Content-Type: text/html`.
197
-
198
- ```ts
199
- const page = procedure.html(async ({ ctx }) => {
200
- return `<!DOCTYPE html><html><body>Hello</body></html>`;
201
- });
202
- ```
203
-
204
- ### Subscription
205
-
206
- WebSocket-based pub/sub. Clients subscribe to a topic and receive messages when your server publishes.
207
-
208
- ```ts
209
- const messages = procedure
210
- .input(z.object({ roomId: z.string(), text: z.string() }))
211
- .subscription(async ({ ctx, input }) => {
212
- await saveMessage(input);
213
- return input; // returned value is broadcast to all subscribers
214
- });
215
- ```
216
-
217
- Publish from anywhere on the server:
218
-
219
- ```ts
220
- router.publish(
221
- messages,
222
- { roomId: "general", text: "hello" },
223
- { roomId: "general" },
224
- );
225
- ```
226
-
227
- Parameters in the route path (e.g. `":roomId"`) scope the subscription topic — subscribers only receive messages matching their params.
228
-
229
- ---
230
-
231
- ## Middlewares
232
-
233
- ### createMiddleware
234
-
235
- 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.
236
-
237
- ```ts
238
- import { createMiddleware, BRPCError } from "@mateosuarezdev/brpc";
239
- import { context } from "./context";
240
-
241
- // Pass context for automatic type inference
242
- const authMiddleware = createMiddleware(context, async (ctx) => {
243
- const session = await getSession(ctx.req);
244
- if (!session)
245
- throw new BRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
246
- return { session };
247
- });
248
-
249
- // Or use an explicit generic when no context reference is available
250
- const langMiddleware = createMiddleware<typeof context>(async (ctx) => {
251
- const lang = ctx.req.headers.get("accept-language") ?? "en";
252
- return { lang };
253
- });
254
- ```
255
-
256
- Use middlewares on individual procedures:
257
-
258
- ```ts
259
- const protectedProcedure = procedure.use(authMiddleware);
260
-
261
- const getProfile = protectedProcedure.query(async ({ ctx }) => {
262
- ctx.session.userId; // fully typed
263
- });
264
- ```
265
-
266
- Chain multiple middlewares — each one's return type accumulates into `ctx`:
267
-
268
- ```ts
269
- const localizedProtectedProcedure = procedure
270
- .use(authMiddleware) // ctx gains { session }
271
- .use(langMiddleware); // ctx gains { lang }
272
-
273
- const getContent = localizedProtectedProcedure.query(async ({ ctx }) => {
274
- ctx.session; // typed
275
- ctx.lang; // typed
276
- });
277
- ```
278
-
279
- Middlewares that only guard (no context extension) just don't return anything:
280
-
281
- ```ts
282
- const adminMiddleware = createMiddleware(context, async (ctx) => {
283
- if (ctx.session.role !== "admin") {
284
- throw new BRPCError({ code: "FORBIDDEN", message: "Admins only" });
285
- }
286
- // no return — ctx type unchanged
287
- });
288
- ```
289
-
290
- ### Built-in Middlewares
291
-
292
- #### Rate Limiter
293
-
294
- ```ts
295
- import { createRateLimiter } from "@mateosuarezdev/brpc";
296
-
297
- const rateLimiter = createRateLimiter({
298
- windowMs: 60_000, // 1 minute window
299
- maxRequests: 100, // max 100 requests per window
300
- maxEntries: 10_000, // max IPs to track
301
- message: "Too many requests",
302
- statusCode: 429,
303
- headerPrefix: "X-RateLimit",
304
- });
305
- ```
306
-
307
- Sets `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers automatically.
308
-
309
- #### Path Blocker
310
-
311
- Blocks requests matching any of the given regex patterns with a 404.
312
-
313
- ```ts
314
- import { createPathBlocker } from "@mateosuarezdev/brpc";
315
-
316
- const blocker = createPathBlocker({
317
- paths: ["/wp-admin", "\\.env", "/\\.git"],
318
- });
319
- ```
320
-
321
- #### Profanity Filter
322
-
323
- Scans request body, query params, and/or route params for profanity.
324
-
325
- ```ts
326
- import { createProfanityMiddleware } from "@mateosuarezdev/brpc";
327
-
328
- const profanityFilter = createProfanityMiddleware({
329
- languages: ["en", "es"],
330
- checkBody: true,
331
- checkQuery: true,
332
- checkParams: false,
333
- message: "Inappropriate content detected",
334
- customLanguages: {
335
- custom: { badWords: ["forbidden"], badPhrases: ["bad phrase"] },
336
- },
337
- });
338
- ```
339
-
340
- ---
341
-
342
- ## Router
343
-
344
- ```ts
345
- import { createRouter } from "@mateosuarezdev/brpc";
346
-
347
- const router = createRouter({
348
- context,
349
- routes,
350
-
351
- // Optional
352
- globalMiddlewares: [blocker, rateLimiter],
353
- prefix: "/api",
354
- debug: false,
355
- autoFileCacheControl: true,
356
-
357
- websocket: {
358
- onOpen: (ws, ctx) => console.log("connected"),
359
- onClose: (ws, code, reason, ctx) => console.log("disconnected"),
360
- },
361
-
362
- integrations: {
363
- betterAuth: {
364
- handler: auth.handler,
365
- },
366
- rawRoutes: {
367
- "/health": async (req) => new Response("ok"),
368
- "/webhook": {
369
- POST: async (req) => handleWebhook(req),
370
- },
371
- },
372
- },
373
-
374
- onError: (error, { req, route }) => {
375
- console.error(`Error on ${route}:`, error);
376
- },
377
- });
378
-
379
- router.listen(3000);
380
- ```
381
-
382
- ### Global Middlewares and Context
383
-
384
- `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`.
385
-
386
- 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:
387
-
388
- ```ts
389
- // context.ts — declare fields that global middlewares will populate
390
- export const context = createContext(async (req) => ({
391
- db: myDb,
392
- session: null as Session | null, // global auth middleware fills this
393
- }));
394
-
395
- // router.ts
396
- createRouter({
397
- context,
398
- routes,
399
- globalMiddlewares: [
400
- async (ctx) => {
401
- ctx.session = (await getSession(ctx.req)) ?? null; // typed, works fine
402
- },
403
- ],
404
- });
405
- ```
406
-
407
- 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.
408
-
409
- ### RouterConfig Options
410
-
411
- | Option | Type | Description |
412
- | ------------------------- | ------------------ | -------------------------------------------------------------- |
413
- | `context` | `fn` | Context creator — receives `Request`, returns your custom data |
414
- | `routes` | `Routes` | Nested route object |
415
- | `globalMiddlewares` | `Middleware[]` | Run before every request |
416
- | `prefix` | `string` | Path prefix for all routes |
417
- | `debug` | `boolean` | Enable route debug logging |
418
- | `autoFileCacheControl` | `boolean` | Auto-set cache headers for file procedures |
419
- | `websocket.onOpen` | `fn` | Called when a WebSocket connection opens |
420
- | `websocket.onClose` | `fn` | Called when a WebSocket connection closes |
421
- | `integrations.betterAuth` | `{ handler }` | Delegate `better-auth` routes to its handler |
422
- | `integrations.rawRoutes` | `Record<path, fn>` | Escape hatch for raw Bun routes |
423
- | `onError` | `fn` | Global error handler |
424
-
425
- ### Dynamic Route Parameters
426
-
427
- Prefix a segment with `:` to capture it as a param:
428
-
429
- ```ts
430
- const routes = {
431
- users: {
432
- ":id": procedure.query(async ({ ctx }) => {
433
- ctx.params.id; // the captured value
434
- return getUser(ctx.params.id);
435
- }),
436
- },
437
- };
438
- ```
439
-
440
- ### Per-Procedure Timeout
441
-
442
- Override the default 30s request timeout on any procedure:
443
-
444
- ```ts
445
- const slowQuery = procedure
446
- .input(z.object({ q: z.string() }))
447
- .timeout(120_000) // 2 minutes
448
- .query(async ({ ctx, input }) => heavyComputation(input.q));
449
- ```
450
-
451
- ### Testing Without a Server
452
-
453
- ```ts
454
- const response = await router.testRequest(
455
- new Request("http://localhost/hello?input=%7B%22name%22%3A%22world%22%7D"),
456
- );
457
- ```
458
-
459
- ---
460
-
461
- ## Client
462
-
463
- Import from `@mateosuarezdev/brpc/client`:
464
-
465
- ```ts
466
- import { createBrpcClient } from "@mateosuarezdev/brpc/client";
467
- import type { AppRoutes } from "./routes";
468
-
469
- const client = createBrpcClient<AppRoutes>("https://api.example.com", {
470
- headers: async () => ({
471
- Authorization: `Bearer ${getToken()}`,
472
- }),
473
- prefix: "/api",
474
- s3Endpoint: "https://cdn.example.com",
475
- nodeEnv: "production",
476
- debug: false,
477
- });
478
-
479
- // Query
480
- const user = await client.routes.users.getById.query({ id: "123" });
481
-
482
- // Mutation
483
- const post = await client.routes.posts.create.mutation({ title: "Hello" });
484
-
485
- // FormMutation
486
- const result = await client.routes.media.upload.formMutation({ file: myFile });
487
-
488
- // File/FileStream — returns a URL string
489
- const url = await client.routes.avatar.file();
490
-
491
- // HTML — returns raw HTML string
492
- const html = await client.routes.page.html();
493
-
494
- // Subscription
495
- const { unsubscribe, publish } = client.routes.messages[":roomId"].subscription(
496
- (message) => console.log("received:", message),
497
- );
498
- publish({ roomId: "general", text: "hello" });
499
- unsubscribe();
500
- ```
501
-
502
- ### BrpcClientOptions
503
-
504
- | Option | Type | Description |
505
- | ------------ | ------------------ | --------------------------------------------------------------------- |
506
- | `headers` | `fn \| Headers` | Default headers for all requests |
507
- | `fetch` | `typeof fetch` | Custom fetch implementation |
508
- | `WebSocket` | `typeof WebSocket` | Custom WebSocket implementation |
509
- | `prefix` | `string` | API path prefix |
510
- | `apiPrefix` | `string` | Additional API prefix |
511
- | `s3Endpoint` | `string` | **Required.** S3/CDN endpoint for resolving storage object URLs |
512
- | `nodeEnv` | `string` | **Required.** Current environment (`"development"` \| `"production"`) |
513
- | `debug` | `boolean` | Enable client-side debug logging |
514
-
515
- ### Cache Keys
516
-
517
- Every procedure exposes helpers for use with query libraries like TanStack Query or `@mateosuarezdev/query`:
518
-
519
- ```ts
520
- // String key
521
- client.routes.users.getById.getStringKey({ id: "123" });
522
- // → "users/getById?{"id":"123"}"
523
-
524
- // Array key — input from backend schema
525
- client.routes.users.getById.getArrayKey({ id: "123" });
526
- // → ["users/getById", "id", "123"]
527
-
528
- // Array key — no backend inputs
529
- client.routes.users.list.getArrayKey();
530
- // → ["users/list"]
531
-
532
- // No-inputs shorthand (always ignores any input)
533
- client.routes.users.list.getNoInputsArrayKey();
534
- // → ["users/list"]
535
- ```
536
-
537
- #### Client-side context keys
538
-
539
- `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:
540
-
541
- ```ts
542
- // Procedure has no backend input, but cache is scoped per user
543
- client.routes.workout.getSessions.getArrayKey(undefined, { userId });
544
- // → ["workout/getSessions", "userId", "abc123"]
545
-
546
- // Procedure has backend input AND needs user scoping
547
- client.routes.workout.getSession.getArrayKey({ sessionId }, { userId });
548
- // → ["workout/getSession", "sessionId", "42", "userId", "abc123"]
549
- ```
550
-
551
- `context` keys are sorted and merged with input keys deterministically, so the resulting array is always stable. Invalidation works the same way:
552
-
553
- ```ts
554
- // Invalidate all session queries for a specific user
555
- queryService.invalidateQueries(["workout/getSessions", "userId", userId]);
556
- ```
557
-
558
- ### Utilities
559
-
560
- ```ts
561
- // Update WebSocket auth token (e.g. after token refresh)
562
- await client.utils.updateWsAuth();
563
-
564
- // Update headers at runtime
565
- await client.utils.setHeader("Authorization", `Bearer ${newToken}`);
566
- await client.utils.setHeaders({ "X-Custom": "value" });
567
- ```
568
-
569
- ### BrpcClientError
570
-
571
- ```ts
572
- import { BrpcClientError } from "@mateosuarezdev/brpc/client";
573
-
574
- try {
575
- await client.routes.auth.login.mutation({ email, password });
576
- } catch (err) {
577
- if (err instanceof BrpcClientError) {
578
- err.status; // HTTP status code
579
- err.code; // BRPCErrorCode string
580
- err.clientCode; // custom client code if set
581
- err.isUnauthorized(); // status === 401
582
- err.isForbidden(); // status === 403
583
- err.isNotFound(); // status === 404
584
- err.isValidationError(); // status === 400
585
- err.isClientError("INVALID_CREDS"); // clientCode === "INVALID_CREDS"
586
- }
587
- }
588
- ```
589
-
590
- ### React
591
-
592
- Import from `@mateosuarezdev/brpc/client/react`:
593
-
594
- ```ts
595
- import { useSubscription } from "@mateosuarezdev/brpc/client/react";
596
-
597
- function ChatRoom({ roomId }: { roomId: string }) {
598
- const [messages, setMessages] = useState<Message[]>([]);
599
-
600
- const { publish } = useSubscription(
601
- (callback) => client.routes.messages[":roomId"].subscription(callback),
602
- (message) => setMessages((prev) => [...prev, message]),
603
- );
604
-
605
- return (
606
- <button onClick={() => publish({ roomId, text: "hello" })}>
607
- Send
608
- </button>
609
- );
610
- }
611
- ```
612
-
613
- ---
614
-
615
- ## Schemas
616
-
617
- ### createFileSchema
618
-
619
- Validates `File` objects in `formMutation` inputs:
620
-
621
- ```ts
622
- import { createFileSchema } from "@mateosuarezdev/brpc";
623
-
624
- z.object({
625
- avatar: createFileSchema({
626
- acceptedTypes: {
627
- image: ["image/jpeg", "image/png"],
628
- },
629
- maxSize: 5, // MB
630
- minSize: 0.1, // MB
631
- messages: {
632
- type: "Only JPEG and PNG are allowed",
633
- maxSize: "File must be under 5MB",
634
- },
635
- }),
636
-
637
- document: createFileSchema({
638
- acceptedTypes: { document: "*" },
639
- maxSize: 20,
640
- }),
641
-
642
- audio: createFileSchema({
643
- acceptedTypes: { audio: "*" },
644
- }),
645
- });
646
- ```
647
-
648
- **Accepted type groups:** `image`, `video`, `audio`, `document`
649
- Pass `"*"` to accept all types in a group, or an array of specific MIME type strings.
650
-
651
- ---
652
-
653
- ## Errors
654
-
655
- ```ts
656
- import { BRPCError } from "@mateosuarezdev/brpc";
657
-
658
- // Constructor
659
- throw new BRPCError({
660
- code: "UNAUTHORIZED",
661
- message: "Token expired",
662
- clientCode: "TOKEN_EXPIRED", // readable code for the client
663
- data: { expiredAt: new Date() },
664
- });
665
-
666
- // Static factory shortcuts
667
- throw BRPCError.unauthorized("Token expired", "TOKEN_EXPIRED");
668
- throw BRPCError.badRequest("Invalid input", "INVALID_EMAIL");
669
- throw BRPCError.forbidden("Admins only");
670
- throw BRPCError.notFound("User not found");
671
- throw BRPCError.conflict("Email already in use", "EMAIL_TAKEN");
672
- throw BRPCError.tooManyRequests();
673
- throw BRPCError.internalServerError();
674
- ```
675
-
676
- ### Error Codes and HTTP Status Mapping
677
-
678
- | Code | Status |
679
- | ----------------------- | ------ |
680
- | `BAD_REQUEST` | 400 |
681
- | `UNAUTHORIZED` | 401 |
682
- | `FORBIDDEN` | 403 |
683
- | `NOT_FOUND` | 404 |
684
- | `CONFLICT` | 409 |
685
- | `UNPROCESSABLE_CONTENT` | 422 |
686
- | `TOO_MANY_REQUESTS` | 429 |
687
- | `INTERNAL_SERVER_ERROR` | 500 |
688
- | `SERVICE_UNAVAILABLE` | 503 |
689
-
690
- Zod validation errors are automatically caught and returned as `BAD_REQUEST` (400) with field-level detail.
691
-
692
- ---
693
-
694
- ## Streaming Media
695
-
696
- Use `streamMedia` inside a `fileStream` procedure to handle HTTP Range requests automatically:
697
-
698
- ```ts
699
- import { streamMedia } from "@mateosuarezdev/brpc";
700
-
701
- const video = procedure.fileStream(async ({ ctx }) => {
702
- const file = Bun.file(`./media/${ctx.params.filename}`);
703
-
704
- return streamMedia(file, ctx.req, {
705
- maxChunkSize: 2 * 1024 * 1024, // 2MB per chunk (default)
706
- cacheMaxAge: 3600, // Cache-Control max-age in seconds
707
- acceptedExtensions: [".mp4", ".webm", ".ogg"],
708
- });
709
- });
710
- ```
711
-
712
- Responds with `206 Partial Content` when the client sends a `Range` header, enabling native browser seek/scrub for `<video>` and `<audio>` elements.
713
-
714
- ---
715
-
716
- ## Type Inference Utilities
717
-
718
- ```ts
719
- import type {
720
- InferProcedureInput,
721
- InferProcedureOutput,
722
- InferRouterOutput,
723
- } from "@mateosuarezdev/brpc";
724
-
725
- // Infer types from a procedure
726
- type CreatePostInput = InferProcedureInput<typeof routes.posts.create>;
727
- type CreatePostOutput = InferProcedureOutput<typeof routes.posts.create>;
728
-
729
- // Infer the full output shape of a routes object
730
- type AppOutput = InferRouterOutput<typeof routes>;
731
- type UserOutput = AppOutput["users"]["getById"];
732
- ```
733
-
734
- ---
735
-
736
- ## Environment Variables
737
-
738
- | Variable | Default | Description |
739
- | -------------------- | ---------- | -------------------------------------- |
740
- | `MAX_WS_CONNECTIONS` | `1000` | Max simultaneous WebSocket connections |
741
- | `WS_TIMEOUT` | `30000` | WebSocket idle timeout in ms |
742
- | `MAX_REQUEST_SIZE` | `10485760` | Max request body size in bytes (10MB) |
743
- | `REQUEST_TIMEOUT` | `30000` | Default procedure timeout in ms |
744
- | `ROUTE_CACHE_SIZE` | `1000` | Route matcher cache size |
1
+ # brpc
2
+
3
+ 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.
4
+
5
+ ```
6
+ npm install @mateosuarezdev/brpc zod
7
+ ```
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Quick Start](#quick-start)
14
+ - [Context](#context)
15
+ - [Procedures](#procedures)
16
+ - [Query](#query)
17
+ - [Mutation](#mutation)
18
+ - [FormMutation](#formmutation)
19
+ - [File](#file)
20
+ - [FileStream](#filestream)
21
+ - [HTML](#html)
22
+ - [Subscription](#subscription)
23
+ - [Middlewares](#middlewares)
24
+ - [createMiddleware](#createmiddleware)
25
+ - [Built-in Middlewares](#built-in-middlewares)
26
+ - [Router](#router)
27
+ - [Client](#client)
28
+ - [React](#react)
29
+ - [Schemas](#schemas)
30
+ - [Errors](#errors)
31
+ - [Streaming Media](#streaming-media)
32
+ - [Storage](#storage)
33
+ - [S3](#s3)
34
+ - [Images](#images)
35
+ - [Local](#local)
36
+ - [Auth](#auth)
37
+ - [Server Setup](#server-setup)
38
+ - [Auth Routes](#auth-routes)
39
+ - [Context Integration](#context-integration)
40
+ - [Client Side](#client-side-auth)
41
+ - [Type Inference Utilities](#type-inference-utilities)
42
+
43
+ ---
44
+
45
+ ## Quick Start
46
+
47
+ ```ts
48
+ // context.ts
49
+ import { createContext } from "@mateosuarezdev/brpc";
50
+ import { db } from "./db";
51
+
52
+ export const context = createContext(async (req) => ({
53
+ db,
54
+ }));
55
+
56
+ // procedure.ts
57
+ import { createProcedure } from "@mateosuarezdev/brpc";
58
+ import type { CustomContext } from "./context";
59
+
60
+ export const procedure = createProcedure<CustomContext>();
61
+
62
+ // routes.ts
63
+ import { z } from "zod";
64
+ import { procedure } from "./procedure";
65
+
66
+ export const routes = {
67
+ hello: procedure
68
+ .input(z.object({ name: z.string() }))
69
+ .query(async ({ ctx, input }) => {
70
+ return `Hello, ${input.name}`;
71
+ }),
72
+ };
73
+
74
+ // index.ts
75
+ import { createRouter } from "@mateosuarezdev/brpc";
76
+ import { context } from "./context";
77
+ import { routes } from "./routes";
78
+
79
+ const router = createRouter({ context, routes });
80
+
81
+ router.listen(3000, () => {
82
+ console.log("Server running on http://localhost:3000");
83
+ });
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Context
89
+
90
+ `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.
91
+
92
+ ```ts
93
+ import { createContext } from "@mateosuarezdev/brpc";
94
+
95
+ export const context = createContext(async (req) => ({
96
+ db: getDb(),
97
+ userId: getUserIdFromCookie(req),
98
+ headers: new Headers(),
99
+ }));
100
+ ```
101
+
102
+ 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.
103
+
104
+ Every handler receives a fully typed `ctx` with:
105
+
106
+ | Field | Type | Description |
107
+ | -------------------- | ------------------------ | -------------------------------------------------- |
108
+ | `req` | `Request` | The raw Bun request |
109
+ | `res` | `Response` | The response object |
110
+ | `params` | `Record<string, string>` | URL path parameters |
111
+ | `headers` | `Headers` | Response headers — create in context, write anywhere |
112
+ | `allowedOrigins` | `string[] \| undefined` | CORS origins for this request — override in middlewares |
113
+ | `publishToProcedure` | `fn` | Publish to a subscription procedure |
114
+ | _your fields_ | _inferred_ | Whatever you return from `createContext` |
115
+
116
+ ---
117
+
118
+ ## Procedures
119
+
120
+ `createProcedure<YourContext>()` creates a typed procedure builder. Pass your context type as a generic to get full type inference.
121
+
122
+ ```ts
123
+ import { createProcedure } from "@mateosuarezdev/brpc";
124
+ import type { CustomContext } from "./context";
125
+
126
+ export const procedure = createProcedure<CustomContext>();
127
+ ```
128
+
129
+ Alternatively, pass the context creator function directly and brpc will infer the type:
130
+
131
+ ```ts
132
+ import { context } from "./context";
133
+
134
+ export const procedure = createProcedure(context);
135
+ ```
136
+
137
+ Chain `.use(middleware)` to add middleware, `.input(schema)` for input validation, then a handler method.
138
+
139
+ ### Query
140
+
141
+ GET request. Returns `{ data: result }`.
142
+
143
+ ```ts
144
+ const getUser = procedure
145
+ .input(z.object({ id: z.string() }))
146
+ .query(async ({ ctx, input }) => {
147
+ return ctx.db.users.findById(input.id);
148
+ });
149
+ ```
150
+
151
+ ### Mutation
152
+
153
+ POST request with a JSON body. Returns `{ data: result }`.
154
+
155
+ ```ts
156
+ const createPost = procedure
157
+ .input(z.object({ title: z.string(), body: z.string() }))
158
+ .mutation(async ({ ctx, input }) => {
159
+ return ctx.db.posts.create(input);
160
+ });
161
+ ```
162
+
163
+ ### FormMutation
164
+
165
+ POST with `multipart/form-data`. Useful for file uploads. Pair with `createFileSchema` for typed file validation.
166
+
167
+ ```ts
168
+ import { createFileSchema } from "@mateosuarezdev/brpc";
169
+
170
+ const uploadAvatar = procedure
171
+ .input(
172
+ z.object({
173
+ file: createFileSchema({ acceptedTypes: { image: "*" }, maxSize: 5 }),
174
+ }),
175
+ )
176
+ .formMutation(async ({ ctx, input }) => {
177
+ await saveFile(input.file);
178
+ return { success: true };
179
+ });
180
+ ```
181
+
182
+ ### File
183
+
184
+ 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.
185
+
186
+ ```ts
187
+ // Serve a static file — brpc sets Content-Type and Cache-Control
188
+ const logo = procedure.file(async ({ ctx }) => {
189
+ return Bun.file("./assets/logo.png");
190
+ });
191
+
192
+ // Stream video/audio directly — Bun handles it natively
193
+ const media = procedure.file(async ({ ctx }) => {
194
+ const file = Bun.file(`./media/${ctx.params.filename}`);
195
+ return new Response(file, {
196
+ headers: { "Content-Type": file.type },
197
+ });
198
+ });
199
+
200
+ // Server-Sent Events
201
+ const events = procedure.file(async ({ ctx }) => {
202
+ const encoder = new TextEncoder();
203
+ const body = new ReadableStream({
204
+ start(controller) {
205
+ const send = (data: string) =>
206
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
207
+ const interval = setInterval(() => send(JSON.stringify({ tick: Date.now() })), 1000);
208
+ setTimeout(() => { clearInterval(interval); controller.close(); }, 30_000);
209
+ },
210
+ });
211
+ return new Response(body, {
212
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
213
+ });
214
+ });
215
+
216
+ // File download with custom headers
217
+ const report = procedure
218
+ .input(z.object({ id: z.string() }))
219
+ .file(async ({ ctx, input }) => {
220
+ const csv = await generateReport(input.id);
221
+ return new Response(csv, {
222
+ headers: {
223
+ "Content-Type": "text/csv",
224
+ "Content-Disposition": `attachment; filename="${input.id}.csv"`,
225
+ },
226
+ });
227
+ });
228
+ ```
229
+
230
+ ### FileStream
231
+
232
+ GET request with HTTP Range support (206 Partial Content). Ideal for video and audio streaming.
233
+
234
+ ```ts
235
+ import { streamMedia } from "@mateosuarezdev/brpc";
236
+
237
+ const video = procedure.fileStream(async ({ ctx }) => {
238
+ return streamMedia(Bun.file("./videos/intro.mp4"), ctx.req, {
239
+ maxChunkSize: 2 * 1024 * 1024, // 2MB chunks
240
+ cacheMaxAge: 3600,
241
+ acceptedExtensions: [".mp4", ".webm"],
242
+ });
243
+ });
244
+ ```
245
+
246
+ ### HTML
247
+
248
+ GET request that returns a raw HTML string with `Content-Type: text/html`.
249
+
250
+ ```ts
251
+ const page = procedure.html(async ({ ctx }) => {
252
+ return `<!DOCTYPE html><html><body>Hello</body></html>`;
253
+ });
254
+ ```
255
+
256
+ ### Subscription
257
+
258
+ WebSocket-based pub/sub. Clients subscribe to a topic and receive messages when your server publishes.
259
+
260
+ ```ts
261
+ const messages = procedure
262
+ .input(z.object({ roomId: z.string(), text: z.string() }))
263
+ .subscription(async ({ ctx, input }) => {
264
+ await saveMessage(input);
265
+ return input; // returned value is broadcast to all subscribers
266
+ });
267
+ ```
268
+
269
+ Publish from anywhere on the server:
270
+
271
+ ```ts
272
+ router.publish(
273
+ messages,
274
+ { roomId: "general", text: "hello" },
275
+ { roomId: "general" },
276
+ );
277
+ ```
278
+
279
+ Parameters in the route path (e.g. `":roomId"`) scope the subscription topic — subscribers only receive messages matching their params.
280
+
281
+ ---
282
+
283
+ ## Middlewares
284
+
285
+ ### createMiddleware
286
+
287
+ 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.
288
+
289
+ ```ts
290
+ import { createMiddleware, BRPCError } from "@mateosuarezdev/brpc";
291
+ import type { CustomContext } from "./context";
292
+
293
+ // Pass context creator for automatic type inference
294
+ const authMiddleware = createMiddleware(context, async (ctx) => {
295
+ const session = await getSession(ctx.req);
296
+ if (!session)
297
+ throw new BRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
298
+ return { session };
299
+ });
300
+
301
+ // Or use an explicit generic with your context type
302
+ const langMiddleware = createMiddleware<CustomContext>(async (ctx) => {
303
+ const lang = ctx.req.headers.get("accept-language") ?? "en";
304
+ return { lang };
305
+ });
306
+ ```
307
+
308
+ Use middlewares on individual procedures:
309
+
310
+ ```ts
311
+ const protectedProcedure = procedure.use(authMiddleware);
312
+
313
+ const getProfile = protectedProcedure.query(async ({ ctx }) => {
314
+ ctx.session.userId; // fully typed
315
+ });
316
+ ```
317
+
318
+ Chain multiple middlewares — each one's return type accumulates into `ctx`:
319
+
320
+ ```ts
321
+ const localizedProtectedProcedure = procedure
322
+ .use(authMiddleware) // ctx gains { session }
323
+ .use(langMiddleware); // ctx gains { lang }
324
+
325
+ const getContent = localizedProtectedProcedure.query(async ({ ctx }) => {
326
+ ctx.session; // typed
327
+ ctx.lang; // typed
328
+ });
329
+ ```
330
+
331
+ Middlewares that only guard (no context extension) just don't return anything:
332
+
333
+ ```ts
334
+ const adminMiddleware = createMiddleware(context, async (ctx) => {
335
+ if (ctx.session.role !== "admin") {
336
+ throw new BRPCError({ code: "FORBIDDEN", message: "Admins only" });
337
+ }
338
+ // no return — ctx type unchanged
339
+ });
340
+ ```
341
+
342
+ ### Per-Procedure CORS
343
+
344
+ 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`:
345
+
346
+ ```ts
347
+ const mobileOnlyMiddleware = createMiddleware(context, async (ctx) => {
348
+ return { allowedOrigins: ["https://mobile.myapp.com"] };
349
+ });
350
+
351
+ const adminOnlyMiddleware = createMiddleware(context, async (ctx) => {
352
+ return { allowedOrigins: ["https://admin.myapp.com"] };
353
+ });
354
+
355
+ export const routes = {
356
+ // open to all configured origins
357
+ public: procedure.query(async () => "hello"),
358
+
359
+ // only reachable from the mobile app
360
+ mobileOnly: procedure
361
+ .use(mobileOnlyMiddleware)
362
+ .query(async ({ ctx }) => "mobile data"),
363
+
364
+ // only reachable from the admin dashboard
365
+ admin: {
366
+ stats: procedure
367
+ .use(adminOnlyMiddleware)
368
+ .query(async ({ ctx }) => getStats()),
369
+ },
370
+ };
371
+ ```
372
+
373
+ `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.
374
+
375
+ ### Built-in Middlewares
376
+
377
+ #### Rate Limiter
378
+
379
+ ```ts
380
+ import { createRateLimiter } from "@mateosuarezdev/brpc";
381
+
382
+ const rateLimiter = createRateLimiter({
383
+ windowMs: 60_000, // 1 minute window
384
+ maxRequests: 100, // max 100 requests per window
385
+ maxEntries: 10_000, // max IPs to track
386
+ message: "Too many requests",
387
+ statusCode: 429,
388
+ headerPrefix: "X-RateLimit",
389
+ });
390
+ ```
391
+
392
+ Sets `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers automatically.
393
+
394
+ #### Path Blocker
395
+
396
+ Blocks requests matching any of the given regex patterns with a 404.
397
+
398
+ ```ts
399
+ import { createPathBlocker } from "@mateosuarezdev/brpc";
400
+
401
+ const blocker = createPathBlocker({
402
+ paths: ["/wp-admin", "\\.env", "/\\.git"],
403
+ });
404
+ ```
405
+
406
+ #### Profanity Filter
407
+
408
+ Scans request body, query params, and/or route params for profanity.
409
+
410
+ ```ts
411
+ import { createProfanityMiddleware } from "@mateosuarezdev/brpc";
412
+
413
+ const profanityFilter = createProfanityMiddleware({
414
+ languages: ["en", "es"],
415
+ checkBody: true,
416
+ checkQuery: true,
417
+ checkParams: false,
418
+ message: "Inappropriate content detected",
419
+ customLanguages: {
420
+ custom: { badWords: ["forbidden"], badPhrases: ["bad phrase"] },
421
+ },
422
+ });
423
+ ```
424
+
425
+ ---
426
+
427
+ ## Router
428
+
429
+ ```ts
430
+ import { createRouter } from "@mateosuarezdev/brpc";
431
+
432
+ const router = createRouter({
433
+ context,
434
+ routes,
435
+
436
+ // Optional
437
+ globalMiddlewares: [blocker, rateLimiter],
438
+ prefix: "/api",
439
+ debug: false,
440
+
441
+ websocket: {
442
+ onOpen: (ws, ctx) => console.log("connected"),
443
+ onClose: (ws, code, reason, ctx) => console.log("disconnected"),
444
+ },
445
+
446
+ integrations: {
447
+ betterAuth: {
448
+ handler: auth.handler,
449
+ },
450
+ rawRoutes: {
451
+ "/health": async (req) => new Response("ok"),
452
+ "/webhook": {
453
+ POST: async (req) => handleWebhook(req),
454
+ },
455
+ },
456
+ },
457
+
458
+ onError: (error, { req, route }) => {
459
+ console.error(`Error on ${route}:`, error);
460
+ },
461
+ });
462
+
463
+ router.listen(3000);
464
+ ```
465
+
466
+ ### Global Middlewares and Context
467
+
468
+ `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`.
469
+
470
+ 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:
471
+
472
+ ```ts
473
+ // context.ts — declare fields that global middlewares will populate
474
+ export const context = createContext(async (req) => ({
475
+ db: myDb,
476
+ session: null as Session | null, // global auth middleware fills this
477
+ }));
478
+
479
+ // router.ts
480
+ createRouter({
481
+ context,
482
+ routes,
483
+ globalMiddlewares: [
484
+ async (ctx) => {
485
+ ctx.session = (await getSession(ctx.req)) ?? null; // typed, works fine
486
+ },
487
+ ],
488
+ });
489
+ ```
490
+
491
+ 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.
492
+
493
+ ### RouterConfig Options
494
+
495
+ | Option | Type | Description |
496
+ | ------------------------- | ------------------ | -------------------------------------------------------------- |
497
+ | `context` | `fn` | Context creator — receives `Request`, returns your custom data |
498
+ | `routes` | `Routes` | Nested route object |
499
+ | `globalMiddlewares` | `Middleware[]` | Run before every request |
500
+ | `prefix` | `string` | Path prefix for all routes |
501
+ | `allowedOrigins` | `string[]` | Global CORS origin allowlist — empty allows all (dev default) |
502
+ | `debug` | `boolean` | Enable route debug logging |
503
+ | `websocket.onOpen` | `fn` | Called when a WebSocket connection opens |
504
+ | `websocket.onClose` | `fn` | Called when a WebSocket connection closes |
505
+ | `integrations.betterAuth` | `{ handler }` | Delegate `better-auth` routes to its handler |
506
+ | `integrations.rawRoutes` | `Record<path, fn>` | Escape hatch for raw Bun routes |
507
+ | `onError` | `fn` | Global error handler |
508
+
509
+ ### Dynamic Route Parameters
510
+
511
+ Prefix a segment with `:` to capture it as a param:
512
+
513
+ ```ts
514
+ const routes = {
515
+ users: {
516
+ ":id": procedure.query(async ({ ctx }) => {
517
+ ctx.params.id; // the captured value
518
+ return getUser(ctx.params.id);
519
+ }),
520
+ },
521
+ };
522
+ ```
523
+
524
+ ### Per-Procedure Timeout
525
+
526
+ Override the default 30s request timeout on any procedure:
527
+
528
+ ```ts
529
+ const slowQuery = procedure
530
+ .input(z.object({ q: z.string() }))
531
+ .timeout(120_000) // 2 minutes
532
+ .query(async ({ ctx, input }) => heavyComputation(input.q));
533
+ ```
534
+
535
+ ### Testing Without a Server
536
+
537
+ `router.testRequest()` executes a real HTTP request through the complete BRPC
538
+ pipeline without opening a port: route matching, context creation, global and
539
+ procedure middlewares, input validation, handlers, and BRPC error responses.
540
+ It returns the standard `Response`, so tests can assert both status codes and
541
+ response bodies with Bun's built-in test runner.
542
+
543
+ ```ts
544
+ import { describe, expect, test } from "bun:test";
545
+ import { createRouter } from "@mateosuarezdev/brpc";
546
+ import { context } from "./context";
547
+ import { routes } from "./routes";
548
+
549
+ const router = createRouter({ context, routes });
550
+
551
+ describe("posts.create", () => {
552
+ test("creates a post", async () => {
553
+ const response = await router.testRequest(
554
+ new Request("http://localhost/posts/create", {
555
+ method: "POST",
556
+ headers: { "Content-Type": "application/json" },
557
+ body: JSON.stringify({ title: "Hello", body: "World" }),
558
+ }),
559
+ );
560
+
561
+ expect(response.status).toBe(200);
562
+ await expect(response.json()).resolves.toEqual({
563
+ data: expect.objectContaining({ title: "Hello" }),
564
+ });
565
+ });
566
+ });
567
+ ```
568
+
569
+ Use ordinary `Request` objects. Queries read URL search parameters, mutations
570
+ require a JSON `Content-Type`, and form mutations require `multipart/form-data`.
571
+
572
+ ```ts
573
+ // Dynamic route parameters
574
+ const response = await router.testRequest(
575
+ new Request("http://localhost/users/usr_123"),
576
+ );
577
+
578
+ expect(response.status).toBe(200);
579
+ await expect(response.json()).resolves.toEqual({ data: expect.any(Object) });
580
+ ```
581
+
582
+ Test failures through the router too. This confirms the externally visible
583
+ status and BRPC error shape, rather than only testing an implementation detail.
584
+
585
+ ```ts
586
+ const response = await router.testRequest(
587
+ new Request("http://localhost/posts/create", {
588
+ method: "POST",
589
+ headers: { "Content-Type": "application/json" },
590
+ body: JSON.stringify({ title: "" }),
591
+ }),
592
+ );
593
+
594
+ expect(response.status).toBe(400);
595
+ await expect(response.json()).resolves.toMatchObject({
596
+ error: { code: "BAD_REQUEST" },
597
+ });
598
+ ```
599
+
600
+ For webhook tests, build a real signed `Request` in a test mock, then pass it
601
+ to `router.testRequest()`. This tests the raw-body/signature middleware and the
602
+ route together without starting a server.
603
+
604
+ ---
605
+
606
+ ## Client
607
+
608
+ Import from `@mateosuarezdev/brpc/client`:
609
+
610
+ ```ts
611
+ import { createBrpcClient } from "@mateosuarezdev/brpc/client";
612
+ import type { AppRoutes } from "./routes";
613
+
614
+ const client = createBrpcClient<AppRoutes>("https://api.example.com", {
615
+ headers: async () => ({
616
+ Authorization: `Bearer ${getToken()}`,
617
+ }),
618
+ prefix: "/api",
619
+ s3Endpoint: "https://cdn.example.com",
620
+ nodeEnv: "production",
621
+ debug: false,
622
+ });
623
+
624
+ // Query
625
+ const user = await client.routes.users.getById.query({ id: "123" });
626
+
627
+ // Mutation
628
+ const post = await client.routes.posts.create.mutation({ title: "Hello" });
629
+
630
+ // FormMutation
631
+ const result = await client.routes.media.upload.formMutation({ file: myFile });
632
+
633
+ // File/FileStream — returns a URL string
634
+ const url = await client.routes.avatar.file();
635
+
636
+ // HTML — returns raw HTML string
637
+ const html = await client.routes.page.html();
638
+
639
+ // Subscription
640
+ const { unsubscribe, publish } = client.routes.messages[":roomId"].subscription(
641
+ (message) => console.log("received:", message),
642
+ );
643
+ publish({ roomId: "general", text: "hello" });
644
+ unsubscribe();
645
+ ```
646
+
647
+ ### BrpcClientOptions
648
+
649
+ | Option | Type | Description |
650
+ | ------------ | ------------------ | --------------------------------------------------------------------- |
651
+ | `headers` | `fn \| Headers` | Default headers for all requests |
652
+ | `fetch` | `typeof fetch` | Custom fetch implementation |
653
+ | `WebSocket` | `typeof WebSocket` | Custom WebSocket implementation |
654
+ | `prefix` | `string` | API path prefix |
655
+ | `apiPrefix` | `string` | Additional API prefix |
656
+ | `s3Endpoint` | `string` | **Required.** S3/CDN endpoint for resolving storage object URLs |
657
+ | `nodeEnv` | `string` | **Required.** Current environment (`"development"` \| `"production"`) |
658
+ | `debug` | `boolean` | Enable client-side debug logging |
659
+
660
+ ### Cache Keys
661
+
662
+ Every procedure exposes helpers for use with query libraries like TanStack Query or `@mateosuarezdev/query`:
663
+
664
+ ```ts
665
+ // String key
666
+ client.routes.users.getById.getStringKey({ id: "123" });
667
+ // → "users/getById?{"id":"123"}"
668
+
669
+ // Array key — input from backend schema
670
+ client.routes.users.getById.getArrayKey({ id: "123" });
671
+ // → ["users/getById", "id", "123"]
672
+
673
+ // Array key — no backend inputs
674
+ client.routes.users.list.getArrayKey();
675
+ // → ["users/list"]
676
+
677
+ // No-inputs shorthand (always ignores any input)
678
+ client.routes.users.list.getNoInputsArrayKey();
679
+ // → ["users/list"]
680
+ ```
681
+
682
+ #### Client-side context keys
683
+
684
+ `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:
685
+
686
+ ```ts
687
+ // Procedure has no backend input, but cache is scoped per user
688
+ client.routes.workout.getSessions.getArrayKey(undefined, { userId });
689
+ // → ["workout/getSessions", "userId", "abc123"]
690
+
691
+ // Procedure has backend input AND needs user scoping
692
+ client.routes.workout.getSession.getArrayKey({ sessionId }, { userId });
693
+ // → ["workout/getSession", "sessionId", "42", "userId", "abc123"]
694
+ ```
695
+
696
+ `context` keys are sorted and merged with input keys deterministically, so the resulting array is always stable. Invalidation works the same way:
697
+
698
+ ```ts
699
+ // Invalidate all session queries for a specific user
700
+ queryService.invalidateQueries(["workout/getSessions", "userId", userId]);
701
+ ```
702
+
703
+ ### Utilities
704
+
705
+ ```ts
706
+ // Update WebSocket auth token (e.g. after token refresh)
707
+ await client.utils.updateWsAuth();
708
+
709
+ // Update headers at runtime
710
+ await client.utils.setHeader("Authorization", `Bearer ${newToken}`);
711
+ await client.utils.setHeaders({ "X-Custom": "value" });
712
+ ```
713
+
714
+ ### BrpcClientError
715
+
716
+ ```ts
717
+ import { BrpcClientError } from "@mateosuarezdev/brpc/client";
718
+
719
+ try {
720
+ await client.routes.auth.login.mutation({ email, password });
721
+ } catch (err) {
722
+ if (err instanceof BrpcClientError) {
723
+ err.status; // HTTP status code
724
+ err.code; // BRPCErrorCode string
725
+ err.clientCode; // custom client code if set
726
+ err.isUnauthorized(); // status === 401
727
+ err.isForbidden(); // status === 403
728
+ err.isNotFound(); // status === 404
729
+ err.isValidationError(); // status === 400
730
+ err.isClientError("INVALID_CREDS"); // clientCode === "INVALID_CREDS"
731
+ }
732
+ }
733
+ ```
734
+
735
+ ### React
736
+
737
+ Import from `@mateosuarezdev/brpc/client/react`:
738
+
739
+ ```ts
740
+ import { useSubscription } from "@mateosuarezdev/brpc/client/react";
741
+
742
+ function ChatRoom({ roomId }: { roomId: string }) {
743
+ const [messages, setMessages] = useState<Message[]>([]);
744
+
745
+ const { publish } = useSubscription(
746
+ (callback) => client.routes.messages[":roomId"].subscription(callback),
747
+ (message) => setMessages((prev) => [...prev, message]),
748
+ );
749
+
750
+ return (
751
+ <button onClick={() => publish({ roomId, text: "hello" })}>
752
+ Send
753
+ </button>
754
+ );
755
+ }
756
+ ```
757
+
758
+ ---
759
+
760
+ ## Schemas
761
+
762
+ ### createFileSchema
763
+
764
+ Validates `File` objects in `formMutation` inputs:
765
+
766
+ ```ts
767
+ import { createFileSchema } from "@mateosuarezdev/brpc";
768
+
769
+ z.object({
770
+ avatar: createFileSchema({
771
+ acceptedTypes: {
772
+ image: ["image/jpeg", "image/png"],
773
+ },
774
+ maxSize: 5, // MB
775
+ minSize: 0.1, // MB
776
+ messages: {
777
+ type: "Only JPEG and PNG are allowed",
778
+ maxSize: "File must be under 5MB",
779
+ },
780
+ }),
781
+
782
+ document: createFileSchema({
783
+ acceptedTypes: { document: "*" },
784
+ maxSize: 20,
785
+ }),
786
+
787
+ audio: createFileSchema({
788
+ acceptedTypes: { audio: "*" },
789
+ }),
790
+ });
791
+ ```
792
+
793
+ **Accepted type groups:** `image`, `video`, `audio`, `document`
794
+ Pass `"*"` to accept all types in a group, or an array of specific MIME type strings.
795
+
796
+ ---
797
+
798
+ ## Errors
799
+
800
+ ```ts
801
+ import { BRPCError } from "@mateosuarezdev/brpc";
802
+
803
+ // Constructor
804
+ throw new BRPCError({
805
+ code: "UNAUTHORIZED",
806
+ message: "Token expired",
807
+ clientCode: "TOKEN_EXPIRED", // readable code for the client
808
+ data: { expiredAt: new Date() },
809
+ });
810
+
811
+ // Static factory shortcuts
812
+ throw BRPCError.unauthorized("Token expired", "TOKEN_EXPIRED");
813
+ throw BRPCError.badRequest("Invalid input", "INVALID_EMAIL");
814
+ throw BRPCError.forbidden("Admins only");
815
+ throw BRPCError.notFound("User not found");
816
+ throw BRPCError.conflict("Email already in use", "EMAIL_TAKEN");
817
+ throw BRPCError.tooManyRequests();
818
+ throw BRPCError.internalServerError();
819
+ ```
820
+
821
+ ### Error Codes and HTTP Status Mapping
822
+
823
+ | Code | Status |
824
+ | ----------------------- | ------ |
825
+ | `BAD_REQUEST` | 400 |
826
+ | `UNAUTHORIZED` | 401 |
827
+ | `FORBIDDEN` | 403 |
828
+ | `NOT_FOUND` | 404 |
829
+ | `CONFLICT` | 409 |
830
+ | `UNPROCESSABLE_CONTENT` | 422 |
831
+ | `TOO_MANY_REQUESTS` | 429 |
832
+ | `INTERNAL_SERVER_ERROR` | 500 |
833
+ | `SERVICE_UNAVAILABLE` | 503 |
834
+
835
+ Zod validation errors are automatically caught and returned as `BAD_REQUEST` (400) with field-level detail.
836
+
837
+ ---
838
+
839
+ ## Streaming Media
840
+
841
+ Use `streamMedia` inside a `fileStream` procedure to handle HTTP Range requests automatically:
842
+
843
+ ```ts
844
+ import { streamMedia } from "@mateosuarezdev/brpc";
845
+
846
+ const video = procedure.fileStream(async ({ ctx }) => {
847
+ const file = Bun.file(`./media/${ctx.params.filename}`);
848
+
849
+ return streamMedia(file, ctx.req, {
850
+ maxChunkSize: 2 * 1024 * 1024, // 2MB per chunk (default)
851
+ cacheMaxAge: 3600, // Cache-Control max-age in seconds
852
+ acceptedExtensions: [".mp4", ".webm", ".ogg"],
853
+ });
854
+ });
855
+ ```
856
+
857
+ Responds with `206 Partial Content` when the client sends a `Range` header, enabling native browser seek/scrub for `<video>` and `<audio>` elements.
858
+
859
+ ---
860
+
861
+ ## Storage
862
+
863
+ All storage utilities are exported from the main package:
864
+
865
+ ```ts
866
+ import {
867
+ uploadOne,
868
+ uploadMany,
869
+ generateAndUploadImage,
870
+ uploadGeneratedVariants,
871
+ deleteOne,
872
+ deleteMany,
873
+ generateVariants,
874
+ getOptimizedImage,
875
+ getImageMetadata,
876
+ local,
877
+ } from "@mateosuarezdev/brpc";
878
+ ```
879
+
880
+ 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`).
881
+
882
+ ### S3
883
+
884
+ #### `uploadOne(path, file, opts?)`
885
+
886
+ 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.
887
+
888
+ ```ts
889
+ // Plain file upload
890
+ const result = await uploadOne("images/avatars", userFile);
891
+
892
+ // With options
893
+ const result = await uploadOne("images/avatars", userFile, {
894
+ acl: "private",
895
+ uploadedBy: ctx.session.sub,
896
+ });
897
+
898
+ // result.key — S3 key
899
+ // result.metadata — type, extension, size, dimensions (for images)
900
+ // result.isPrivate
901
+ ```
902
+
903
+ #### `uploadMany(path, files, opts?)`
904
+
905
+ Uploads multiple files in parallel. Returns `{ data, errors }`.
906
+
907
+ ```ts
908
+ const { data, errors } = await uploadMany("documents", files, {
909
+ acl: "public-read",
910
+ throwIf: "any", // "any" | "all" | "ignore"
911
+ });
912
+ ```
913
+
914
+ #### `generateAndUploadImage(path, file, opts?)`
915
+
916
+ The main image upload function. Automatically generates optimized variants (original, compressed, thumbnail, etc.) and uploads them all to S3 in one call.
917
+
918
+ ```ts
919
+ const uploadResult = await generateAndUploadImage("/exercises", imageFile, {
920
+ acl: "public-read",
921
+ uploadedBy: ctx.session.sub,
922
+ quality: 80,
923
+ });
924
+
925
+ // uploadResult.shared — name, resolvedType, provider, acl, isPrivate
926
+ // uploadResult.variants — keyed object with each variant's key, url, metadata
927
+ ```
928
+
929
+ #### `uploadGeneratedVariants(path, generated, opts?)`
930
+
931
+ Lower-level — uploads pre-generated variants returned by `generateVariants`. Use `generateAndUploadImage` unless you need to modify variants before uploading.
932
+
933
+ #### `deleteOne(key, opts?)`
934
+
935
+ Deletes a single S3 file by key. Returns `{ key, deleted, error }` — never throws, so caller decides how to handle failure.
936
+
937
+ ```ts
938
+ const { deleted, error } = await deleteOne("images/avatars/abc.webp");
939
+ if (!deleted) console.error("Failed to delete:", error);
940
+ ```
941
+
942
+ #### `deleteMany(keys, opts?)`
943
+
944
+ Deletes multiple keys in parallel. Returns a per-key result array.
945
+
946
+ ```ts
947
+ const results = await deleteMany(variantKeys);
948
+ ```
949
+
950
+ ### Images
951
+
952
+ #### `generateVariants(file, opts?)`
953
+
954
+ Generates optimized image variants from a `File`, `BunFile`, or local path using `sharp`. Returns a `GeneratedImageVariants` object with each variant as an `UploadableObjectBuffer`.
955
+
956
+ ```ts
957
+ const generated = await generateVariants(imageFile, {
958
+ quality: 80,
959
+ acl: "public-read",
960
+ });
961
+ // Pass to uploadGeneratedVariants or inspect variants directly
962
+ ```
963
+
964
+ #### `getImageMetadata(buffer)`
965
+
966
+ Extracts metadata from an image buffer using `sharp`.
967
+
968
+ ```ts
969
+ const meta = await getImageMetadata(buffer);
970
+ // meta.width, meta.height, meta.aspectRatio, meta.aspectRatioStr
971
+ // meta.type, meta.extension, meta.size
972
+ ```
973
+
974
+ #### `getOptimizedImage(options)`
975
+
976
+ 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.
977
+
978
+ ```ts
979
+ const file = await getOptimizedImage({
980
+ key: "images/photo.jpg",
981
+ width: 800,
982
+ quality: 75,
983
+ });
984
+
985
+ if (!file) throw BRPCError.notFound();
986
+ return file;
987
+ ```
988
+
989
+ ### Local
990
+
991
+ The `local` object provides a filesystem storage backend that mirrors the S3 API. Files are stored under `./buckets/`.
992
+
993
+ ```ts
994
+ // Upload
995
+ const { data, error } = await local.uploadOne("public/avatars", file);
996
+ if (error) throw BRPCError.internalServerError();
997
+ // data.key, data.bytesWritten
998
+
999
+ // Upload many
1000
+ const results = await local.uploadMany("public/photos", files);
1001
+
1002
+ // Get
1003
+ const file = await local.getOne("public/avatars/abc.jpg");
1004
+ if (!file) throw BRPCError.notFound();
1005
+
1006
+ // Delete
1007
+ const deleted = await local.deleteOne("public/avatars/abc.jpg");
1008
+ ```
1009
+
1010
+ ---
1011
+
1012
+ ## Auth
1013
+
1014
+ 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.
1015
+
1016
+ Import from the `auth` subpath:
1017
+
1018
+ ```ts
1019
+ import { createAuth, setAuthCookies, clearAuthCookies, parseCookies, COOKIE_NAMES } from "@mateosuarezdev/brpc/auth";
1020
+ ```
1021
+
1022
+ Requires the `0000_initial.surql` migration to be applied (sets up `users`, `user_identities`, `sessions`, `otp_codes` tables).
1023
+
1024
+ ### Server Setup
1025
+
1026
+ ```ts
1027
+ // services/auth/index.ts
1028
+ import { createAuth } from "@mateosuarezdev/brpc/auth";
1029
+ import { CacheService } from "@mateosuarezdev/brpc/cache";
1030
+ import { db } from "../db";
1031
+
1032
+ export const auth = createAuth({
1033
+ db,
1034
+ secrets: {
1035
+ jwt: process.env.JWT_SECRET!,
1036
+ jwtRefresh: process.env.JWT_REFRESH_SECRET!,
1037
+ },
1038
+ defaultSignupRoles: ["user"],
1039
+ isProd: process.env.NODE_ENV === "production",
1040
+ debug: process.env.NODE_ENV === "development",
1041
+ cache: CacheService, // in-memory session cache
1042
+ otpProvider: {
1043
+ sendEmailOTP: async ({ email, code }) => {
1044
+ await mailer.send({ to: email, subject: "Your code", text: code });
1045
+ },
1046
+ sendSMSOTP: async ({ phone, code }) => {
1047
+ await sms.send({ to: phone, body: code });
1048
+ },
1049
+ },
1050
+ });
1051
+ ```
1052
+
1053
+ #### `createAuth` Config
1054
+
1055
+ | Option | Type | Description |
1056
+ | --------------------- | --------------- | -------------------------------------------------------- |
1057
+ | `db` | `Surreal` | Connected SurrealDB instance |
1058
+ | `secrets.jwt` | `string` | JWT signing secret for access tokens |
1059
+ | `secrets.jwtRefresh` | `string` | JWT signing secret for refresh tokens |
1060
+ | `defaultSignupRoles` | `string[]` | Roles assigned to new users on sign-up |
1061
+ | `cache` | `CacheService` | Optional in-memory session cache to reduce DB reads |
1062
+ | `otpProvider` | `OTPProvider` | Callbacks for delivering OTP codes |
1063
+ | `accessTokenExpiry` | `string` | JWT expiry string (default: `"15m"`) |
1064
+ | `isProd` | `boolean` | Enables `Secure` flag on cookies |
1065
+ | `debug` | `boolean` | Logs auth events |
1066
+ | `disableCleanupCron` | `boolean` | Disables the automatic expired-session cleanup job |
1067
+
1068
+ ### Auth Routes
1069
+
1070
+ ```ts
1071
+ // routes/auth.ts
1072
+ import { auth } from "../services/auth";
1073
+
1074
+ export const authRoutes = {
1075
+ signUpEmail: apiProcedure
1076
+ .input(z.object({
1077
+ email: z.email(),
1078
+ password: z.string().optional(),
1079
+ passwordless: z.boolean().optional(),
1080
+ userMetadata: z.object({ name: z.string().nullable() }).optional(),
1081
+ }))
1082
+ .mutation(async ({ input, ctx }) => {
1083
+ // Returns AuthResponse on password sign-up, OTPResponse on passwordless
1084
+ return auth.signUp({ type: "email", ...input }, ctx.req);
1085
+ }),
1086
+
1087
+ signInEmail: apiProcedure
1088
+ .input(z.object({
1089
+ email: z.string(),
1090
+ password: z.string().optional(),
1091
+ passwordless: z.boolean().optional(),
1092
+ }))
1093
+ .mutation(async ({ input, ctx }) => {
1094
+ return auth.signIn({ type: "email", ...input }, ctx.req);
1095
+ }),
1096
+
1097
+ signUpPhone: apiProcedure
1098
+ .input(z.object({ phone: z.string(), passwordless: z.boolean().optional() }))
1099
+ .mutation(async ({ input, ctx }) => {
1100
+ return auth.signUp({ type: "phone", ...input }, ctx.req);
1101
+ }),
1102
+
1103
+ signInPhone: apiProcedure
1104
+ .input(z.object({ phone: z.string(), passwordless: z.boolean().optional() }))
1105
+ .mutation(async ({ input, ctx }) => {
1106
+ return auth.signIn({ type: "phone", ...input }, ctx.req);
1107
+ }),
1108
+
1109
+ verifyOTP: apiProcedure
1110
+ .input(z.object({ otpId: z.string(), code: z.string().length(6) }))
1111
+ .mutation(async ({ input, ctx }) => {
1112
+ // Always returns AuthResponse — OTP verification always completes sign-in
1113
+ return auth.verifyOTP(input.otpId, input.code, ctx.req);
1114
+ }),
1115
+
1116
+ signOut: apiUserProcedure.mutation(async ({ ctx }) => {
1117
+ await auth.signOut(ctx.session!.sessionId);
1118
+ return { success: true };
1119
+ }),
1120
+
1121
+ refreshToken: apiProcedure
1122
+ .input(z.object({ refreshToken: z.string() }))
1123
+ .mutation(async ({ input }) => {
1124
+ return auth.refreshTokens(input.refreshToken);
1125
+ }),
1126
+ };
1127
+ ```
1128
+
1129
+ ### Context Integration
1130
+
1131
+ Verify sessions in context so `ctx.session` is available to all procedures:
1132
+
1133
+ ```ts
1134
+ // router/context/index.ts
1135
+ import { createAuth } from "@mateosuarezdev/brpc/auth";
1136
+ import {
1137
+ parseCookies,
1138
+ COOKIE_NAMES,
1139
+ setAuthCookies,
1140
+ } from "@mateosuarezdev/brpc/auth";
1141
+ import { auth } from "../services/auth";
1142
+
1143
+ export const context = createContext(async (req) => {
1144
+ const authHeader = req.headers.get("authorization");
1145
+ const cookieHeader = req.headers.get("cookie");
1146
+ const headers = new Headers();
1147
+ let session = null;
1148
+
1149
+ // Header-based auth (API / mobile clients)
1150
+ if (authHeader) {
1151
+ session = auth.getCachedSession(authHeader)
1152
+ ?? await auth.fastVerifyToken(authHeader);
1153
+ if (session) auth.cacheSession(authHeader, session);
1154
+ }
1155
+ // Cookie-based auth (SSR / web clients)
1156
+ else if (cookieHeader) {
1157
+ const cookies = parseCookies(cookieHeader);
1158
+ const accessToken = cookies[COOKIE_NAMES.ACCESS_TOKEN];
1159
+
1160
+ if (accessToken) {
1161
+ const bearer = `Bearer ${accessToken}`;
1162
+ session = auth.getCachedSession(bearer)
1163
+ ?? await auth.fastVerifyToken(bearer);
1164
+ if (session) auth.cacheSession(bearer, session);
1165
+
1166
+ // Refresh if near expiry
1167
+ if (session && auth.isTokenNearExpiry(session.exp)) {
1168
+ const refresh = cookies[COOKIE_NAMES.REFRESH_TOKEN];
1169
+ if (refresh) {
1170
+ try {
1171
+ const result = await auth.refreshTokens(refresh);
1172
+ auth.invalidateCachedSession(bearer);
1173
+ setAuthCookies(headers, result, isProd);
1174
+ const newBearer = `Bearer ${result.accessToken}`;
1175
+ session = await auth.fastVerifyToken(newBearer);
1176
+ if (session) auth.cacheSession(newBearer, session);
1177
+ } catch {}
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ return { session, headers };
1184
+ });
1185
+ ```
1186
+
1187
+ #### Session Type (`DecodedJWT`)
1188
+
1189
+ | Field | Type | Description |
1190
+ | -------------- | -------------- | --------------------------------- |
1191
+ | `sub` | `string` | User ID |
1192
+ | `sessionId` | `string` | Session record ID |
1193
+ | `appMetadata` | `AppMetadata` | Roles and custom app metadata |
1194
+ | `userMetadata` | `UserMetadata` | Name, theme, preferred language |
1195
+ | `exp` | `number` | Token expiry (Unix timestamp) |
1196
+
1197
+ #### Cookie Helpers
1198
+
1199
+ ```ts
1200
+ import { setAuthCookies, clearAuthCookies, parseCookies, COOKIE_NAMES } from "@mateosuarezdev/brpc/auth";
1201
+
1202
+ // Set auth cookies on the response (after sign-in)
1203
+ setAuthCookies(headers, { accessToken, refreshToken, sessionId }, isProd);
1204
+
1205
+ // Clear auth cookies (on sign-out)
1206
+ clearAuthCookies(headers, isProd);
1207
+
1208
+ // Parse cookie header into a plain object
1209
+ const cookies = parseCookies(req.headers.get("cookie"));
1210
+ const token = cookies[COOKIE_NAMES.ACCESS_TOKEN];
1211
+
1212
+ // Cookie names
1213
+ COOKIE_NAMES.ACCESS_TOKEN // "access_token"
1214
+ COOKIE_NAMES.REFRESH_TOKEN // "refresh_token"
1215
+ COOKIE_NAMES.SESSION_ID // "session_id"
1216
+ ```
1217
+
1218
+ ### Client-Side Auth
1219
+
1220
+ For React Native / React apps, wrap your app in `AuthProvider` and use the `useAuth` hook.
1221
+
1222
+ ```ts
1223
+ // auth/provider.tsx
1224
+ import { AuthProvider } from "./provider";
1225
+
1226
+ export default function App() {
1227
+ return (
1228
+ <AuthProvider debug={__DEV__}>
1229
+ <RootNavigator />
1230
+ </AuthProvider>
1231
+ );
1232
+ }
1233
+ ```
1234
+
1235
+ ```ts
1236
+ // Anywhere inside the tree
1237
+ import { useAuth } from "./auth/context";
1238
+
1239
+ function ProfileScreen() {
1240
+ const { session, signOut, checkUserRoles } = useAuth();
1241
+
1242
+ const isAdmin = checkUserRoles("admin");
1243
+
1244
+ return (
1245
+ <View>
1246
+ <Text>{session?.user.userMetadata.name}</Text>
1247
+ {isAdmin && <AdminPanel />}
1248
+ <Button onPress={signOut} title="Sign out" />
1249
+ </View>
1250
+ );
1251
+ }
1252
+ ```
1253
+
1254
+ #### `AuthProvider` manages:
1255
+ - Restoring session from `AsyncStorage` on app start
1256
+ - Proactive token refresh (checked every 5 minutes and on app init)
1257
+ - Setting the `Authorization` header on the brpc client after sign-in/refresh
1258
+ - Clearing local session and headers on sign-out
1259
+
1260
+ #### `useAuth()` returns:
1261
+
1262
+ | Field | Type | Description |
1263
+ | ---------------- | ----------------------------------- | --------------------------------------------- |
1264
+ | `status` | `"pending" \| "settled" \| "error"` | Auth initialization state |
1265
+ | `session` | `AuthResponse \| null` | Current session including user and tokens |
1266
+ | `otpResponse` | `OTPResponse \| null` | Pending OTP state (set after passwordless req) |
1267
+ | `signUpEmailOTP` | `fn` | Start email passwordless sign-up |
1268
+ | `signInEmailOTP` | `fn` | Start email passwordless sign-in |
1269
+ | `verifyOTP` | `fn` | Complete OTP flow with 6-digit code |
1270
+ | `signOut` | `fn` | Sign out and clear local session |
1271
+ | `checkUserRoles` | `fn` | Check if current user has required roles |
1272
+
1273
+ #### `checkUserRoles(roles, options?)`
1274
+
1275
+ ```ts
1276
+ checkUserRoles("admin"); // single role
1277
+ checkUserRoles(["admin", "staff"]); // any of these (default)
1278
+ checkUserRoles(["admin", "staff"], { requireAll: true }); // must have all
1279
+ checkUserRoles("editor", { adminBypass: false }); // admins not auto-allowed
1280
+ ```
1281
+
1282
+ ---
1283
+
1284
+ ## Type Inference Utilities
1285
+
1286
+ ```ts
1287
+ import type {
1288
+ InferProcedureInput,
1289
+ InferProcedureOutput,
1290
+ InferRouterOutput,
1291
+ } from "@mateosuarezdev/brpc";
1292
+
1293
+ // Infer types from a procedure
1294
+ type CreatePostInput = InferProcedureInput<typeof routes.posts.create>;
1295
+ type CreatePostOutput = InferProcedureOutput<typeof routes.posts.create>;
1296
+
1297
+ // Infer the full output shape of a routes object
1298
+ type AppOutput = InferRouterOutput<typeof routes>;
1299
+ type UserOutput = AppOutput["users"]["getById"];
1300
+ ```
1301
+
1302
+ ---
1303
+
1304
+ ## Environment Variables
1305
+
1306
+ | Variable | Default | Description |
1307
+ | -------------------- | ---------- | -------------------------------------- |
1308
+ | `MAX_WS_CONNECTIONS` | `1000` | Max simultaneous WebSocket connections |
1309
+ | `WS_TIMEOUT` | `30000` | WebSocket idle timeout in ms |
1310
+ | `MAX_REQUEST_SIZE` | `10485760` | Max request body size in bytes (10MB) |
1311
+ | `REQUEST_TIMEOUT` | `30000` | Default procedure timeout in ms |
1312
+ | `ROUTE_CACHE_SIZE` | `1000` | Route matcher cache size |