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