@richie-rpc/server 1.2.2 → 1.2.4

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
@@ -20,19 +20,19 @@ const router = createRouter(contract, {
20
20
  getUser: async ({ params }) => {
21
21
  // params is fully typed based on the contract
22
22
  const user = await db.getUser(params.id);
23
-
23
+
24
24
  if (!user) {
25
25
  return { status: Status.NotFound, body: { error: 'User not found' } };
26
26
  }
27
-
27
+
28
28
  return { status: Status.OK, body: user };
29
29
  },
30
-
30
+
31
31
  createUser: async ({ body }) => {
32
32
  // body is fully typed and already validated
33
33
  const user = await db.createUser(body);
34
34
  return { status: Status.Created, body: user };
35
- }
35
+ },
36
36
  });
37
37
  ```
38
38
 
@@ -41,22 +41,22 @@ const router = createRouter(contract, {
41
41
  You can serve your API under a path prefix (e.g., `/api`) using the `basePath` option:
42
42
 
43
43
  ```typescript
44
- const router = createRouter(contract, handlers, {
45
- basePath: '/api'
44
+ const router = createRouter(contract, handlers, {
45
+ basePath: '/api',
46
46
  });
47
47
 
48
48
  Bun.serve({
49
49
  port: 3000,
50
50
  fetch(request) {
51
51
  const url = new URL(request.url);
52
-
52
+
53
53
  // Route all /api/* requests to the router
54
54
  if (url.pathname.startsWith('/api/')) {
55
55
  return router.fetch(request);
56
56
  }
57
-
57
+
58
58
  return new Response('Not Found', { status: 404 });
59
- }
59
+ },
60
60
  });
61
61
  ```
62
62
 
@@ -67,7 +67,7 @@ The router will automatically strip the basePath prefix before matching routes.
67
67
  ```typescript
68
68
  Bun.serve({
69
69
  port: 3000,
70
- fetch: router.fetch
70
+ fetch: router.fetch,
71
71
  });
72
72
  ```
73
73
 
@@ -78,13 +78,13 @@ Bun.serve({
78
78
  port: 3000,
79
79
  fetch(request) {
80
80
  const url = new URL(request.url);
81
-
81
+
82
82
  if (url.pathname.startsWith('/api')) {
83
83
  return router.handle(request);
84
84
  }
85
-
85
+
86
86
  return new Response('Not Found', { status: 404 });
87
- }
87
+ },
88
88
  });
89
89
  ```
90
90
 
@@ -94,10 +94,14 @@ Bun.serve({
94
94
  - ✅ Automatic response validation
95
95
  - ✅ Type-safe handler inputs
96
96
  - ✅ Type-safe status codes with `Status` const object
97
+ - ✅ HTTP Streaming with `StreamEmitter`
98
+ - ✅ Server-Sent Events with `SSEEmitter`
99
+ - ✅ WebSocket router with pub/sub support
97
100
  - ✅ Path parameter matching
98
101
  - ✅ Query parameter parsing
99
102
  - ✅ JSON body parsing
100
- - ✅ Form data support
103
+ - ✅ File uploads with `multipart/form-data`
104
+ - ✅ Nested file structures in request bodies
101
105
  - ✅ BasePath support for serving APIs under path prefixes
102
106
  - ✅ Detailed validation errors
103
107
  - ✅ 404 handling for unknown routes
@@ -136,14 +140,15 @@ Use the `Status` const object for type-safe status codes:
136
140
  ```typescript
137
141
  import { Status } from '@richie-rpc/server';
138
142
 
139
- return { status: Status.OK, body: user }; // 200
140
- return { status: Status.Created, body: newUser }; // 201
141
- return { status: Status.NoContent, body: {} }; // 204
142
- return { status: Status.BadRequest, body: error }; // 400
143
- return { status: Status.NotFound, body: error }; // 404
143
+ return { status: Status.OK, body: user }; // 200
144
+ return { status: Status.Created, body: newUser }; // 201
145
+ return { status: Status.NoContent, body: {} }; // 204
146
+ return { status: Status.BadRequest, body: error }; // 400
147
+ return { status: Status.NotFound, body: error }; // 404
144
148
  ```
145
149
 
146
150
  Available status codes in `Status` object:
151
+
147
152
  - **Success**: `OK` (200), `Created` (201), `Accepted` (202), `NoContent` (204)
148
153
  - **Redirection**: `MovedPermanently` (301), `Found` (302), `NotModified` (304)
149
154
  - **Client Errors**: `BadRequest` (400), `Unauthorized` (401), `Forbidden` (403), `NotFound` (404), `MethodNotAllowed` (405), `Conflict` (409), `UnprocessableEntity` (422), `TooManyRequests` (429)
@@ -171,18 +176,296 @@ const contract = defineContract({
171
176
  method: 'GET',
172
177
  path: '/teapot',
173
178
  responses: {
174
- 418: z.object({ message: z.string(), isTeapot: z.boolean() })
175
- }
176
- }
179
+ 418: z.object({ message: z.string(), isTeapot: z.boolean() }),
180
+ },
181
+ },
177
182
  });
178
183
 
179
184
  const router = createRouter(contract, {
180
185
  teapot: async () => {
181
186
  return {
182
187
  status: 418 as const,
183
- body: { message: "I'm a teapot", isTeapot: true }
188
+ body: { message: "I'm a teapot", isTeapot: true },
184
189
  };
185
- }
190
+ },
191
+ });
192
+ ```
193
+
194
+ ## Handling File Uploads
195
+
196
+ The server automatically handles `multipart/form-data` requests when the contract specifies `contentType: 'multipart/form-data'`. File objects are fully reconstructed and passed to your handler:
197
+
198
+ ```typescript
199
+ const contract = defineContract({
200
+ uploadDocuments: {
201
+ method: 'POST',
202
+ path: '/upload',
203
+ contentType: 'multipart/form-data',
204
+ body: z.object({
205
+ documents: z.array(
206
+ z.object({
207
+ file: z.instanceof(File),
208
+ name: z.string(),
209
+ tags: z.array(z.string()).optional(),
210
+ }),
211
+ ),
212
+ category: z.string(),
213
+ }),
214
+ responses: {
215
+ [Status.Created]: z.object({
216
+ uploadedCount: z.number(),
217
+ totalSize: z.number(),
218
+ }),
219
+ },
220
+ },
221
+ });
222
+
223
+ const router = createRouter(contract, {
224
+ uploadDocuments: async ({ body }) => {
225
+ // body.documents is fully typed with File objects
226
+ let totalSize = 0;
227
+
228
+ for (const doc of body.documents) {
229
+ // doc.file is a File object
230
+ const buffer = await doc.file.arrayBuffer();
231
+ totalSize += buffer.byteLength;
232
+
233
+ console.log(`Processing: ${doc.name} (${doc.file.name})`);
234
+ console.log(`Tags: ${doc.tags?.join(', ') ?? 'none'}`);
235
+ }
236
+
237
+ return {
238
+ status: Status.Created,
239
+ body: {
240
+ uploadedCount: body.documents.length,
241
+ totalSize,
242
+ },
243
+ };
244
+ },
245
+ });
246
+ ```
247
+
248
+ The server automatically:
249
+
250
+ - Parses `multipart/form-data` requests
251
+ - Reconstructs nested structures with File objects
252
+ - Validates the body against your Zod schema
253
+
254
+ ## Streaming Responses
255
+
256
+ For AI-style streaming responses, handlers receive a `stream` object:
257
+
258
+ ```typescript
259
+ const contract = defineContract({
260
+ generateText: {
261
+ type: 'streaming',
262
+ method: 'POST',
263
+ path: '/generate',
264
+ body: z.object({ prompt: z.string() }),
265
+ chunk: z.object({ text: z.string() }),
266
+ finalResponse: z.object({ totalTokens: z.number() }),
267
+ },
268
+ });
269
+
270
+ const router = createRouter(contract, {
271
+ generateText: async ({ body, stream }) => {
272
+ const words = body.prompt.split(' ');
273
+
274
+ for (const word of words) {
275
+ if (!stream.isOpen) return; // Client disconnected
276
+ stream.send({ text: word + ' ' });
277
+ await new Promise((r) => setTimeout(r, 100));
278
+ }
279
+
280
+ stream.close({ totalTokens: words.length });
281
+ },
282
+ });
283
+ ```
284
+
285
+ ### StreamEmitter Interface
286
+
287
+ - `send(chunk)` - Send a chunk to the client (NDJSON format)
288
+ - `close(finalResponse?)` - Close the stream with optional final response
289
+ - `isOpen` - Check if the stream is still open
290
+
291
+ ## Server-Sent Events (SSE)
292
+
293
+ For server-to-client event streaming, handlers receive an `emitter` object:
294
+
295
+ ```typescript
296
+ const contract = defineContract({
297
+ notifications: {
298
+ type: 'sse',
299
+ method: 'GET',
300
+ path: '/notifications',
301
+ events: {
302
+ message: z.object({ text: z.string() }),
303
+ heartbeat: z.object({ timestamp: z.string() }),
304
+ },
305
+ },
306
+ });
307
+
308
+ const router = createRouter(contract, {
309
+ notifications: ({ emitter, signal }) => {
310
+ // Send heartbeats every 30 seconds
311
+ const heartbeatInterval = setInterval(() => {
312
+ if (!emitter.isOpen) return;
313
+ emitter.send('heartbeat', { timestamp: new Date().toISOString() });
314
+ }, 30000);
315
+
316
+ // Cleanup when client disconnects
317
+ signal.addEventListener('abort', () => {
318
+ clearInterval(heartbeatInterval);
319
+ });
320
+
321
+ // Optional: return cleanup function
322
+ return () => clearInterval(heartbeatInterval);
323
+ },
324
+ });
325
+ ```
326
+
327
+ ### SSEEmitter Interface
328
+
329
+ - `send(event, data, options?)` - Send an event with data and optional ID
330
+ - `close()` - Close the connection
331
+ - `isOpen` - Check if the connection is still open
332
+
333
+ ## WebSocket Router
334
+
335
+ For bidirectional real-time communication, use `createWebSocketRouter`:
336
+
337
+ ```typescript
338
+ import { createWebSocketRouter } from '@richie-rpc/server';
339
+ import { defineWebSocketContract } from '@richie-rpc/core';
340
+
341
+ const wsContract = defineWebSocketContract({
342
+ chat: {
343
+ path: '/ws/chat/:roomId',
344
+ params: z.object({ roomId: z.string() }),
345
+ clientMessages: {
346
+ sendMessage: { payload: z.object({ text: z.string() }) },
347
+ },
348
+ serverMessages: {
349
+ message: { payload: z.object({ userId: z.string(), text: z.string() }) },
350
+ error: { payload: z.object({ code: z.string(), message: z.string() }) },
351
+ },
352
+ },
353
+ });
354
+
355
+ const wsRouter = createWebSocketRouter(wsContract, {
356
+ chat: {
357
+ open(ws) {
358
+ // Called when connection opens
359
+ ws.subscribe(`room:${ws.data.params.roomId}`);
360
+ console.log('User joined room:', ws.data.params.roomId);
361
+ },
362
+
363
+ message(ws, msg) {
364
+ // Called for each validated client message
365
+ if (msg.type === 'sendMessage') {
366
+ ws.publish(`room:${ws.data.params.roomId}`, 'message', {
367
+ userId: 'user1',
368
+ text: msg.payload.text,
369
+ });
370
+ }
371
+ },
372
+
373
+ close(ws) {
374
+ // Called when connection closes
375
+ console.log('User left room:', ws.data.params.roomId);
376
+ },
377
+
378
+ validationError(ws, error) {
379
+ // Called when client message validation fails
380
+ ws.send('error', { code: 'VALIDATION_ERROR', message: error.message });
381
+ },
382
+ },
383
+ });
384
+ ```
385
+
386
+ ### Typed Per-Connection State
387
+
388
+ You can store typed per-connection state by defining a state interface and passing it as an option. This follows the same pattern as Bun's WebSocket data:
389
+
390
+ ```typescript
391
+ // Define your per-connection state type
392
+ interface ChatConnectionState {
393
+ connectionId: string;
394
+ userId?: string;
395
+ username?: string;
396
+ }
397
+
398
+ const wsRouter = createWebSocketRouter(
399
+ wsContract,
400
+ {
401
+ chat: {
402
+ open(ws) {
403
+ // Initialize typed state - fully typed, no casts needed
404
+ ws.data.state.connectionId = generateId();
405
+ ws.subscribe(`room:${ws.data.params.roomId}`);
406
+ },
407
+
408
+ message(ws, msg) {
409
+ // Access typed state
410
+ const { connectionId, username } = ws.data.state;
411
+
412
+ if (msg.type === 'join') {
413
+ // Update state
414
+ ws.data.state.username = msg.payload.username;
415
+ ws.data.state.userId = connectionId;
416
+ }
417
+
418
+ if (msg.type === 'sendMessage' && username) {
419
+ ws.publish(`room:${ws.data.params.roomId}`, 'message', {
420
+ userId: connectionId,
421
+ text: msg.payload.text,
422
+ });
423
+ }
424
+ },
425
+
426
+ close(ws) {
427
+ // State is available throughout connection lifecycle
428
+ console.log(`User ${ws.data.state.username} disconnected`);
429
+ },
430
+ },
431
+ },
432
+ // Pass state type hint as third argument
433
+ { state: {} as ChatConnectionState },
434
+ );
435
+ ```
436
+
437
+ The state object is initialized as an empty object for each connection and is available via `ws.data.state` in all handlers (`open`, `message`, `close`, `validationError`).
438
+
439
+ ### TypedServerWebSocket Interface
440
+
441
+ - `send(type, payload)` - Send a typed message to the client
442
+ - `subscribe(topic)` - Subscribe to a pub/sub topic
443
+ - `unsubscribe(topic)` - Unsubscribe from a topic
444
+ - `publish(topic, type, payload)` - Broadcast to all subscribers
445
+ - `close(code?, reason?)` - Close the connection
446
+ - `data.params` - Access path parameters from the connection
447
+ - `data.query` - Access query parameters from the connection
448
+ - `data.headers` - Access headers from the connection
449
+ - `data.state` - Access typed per-connection state (see above)
450
+
451
+ ### Integrating with Bun.serve
452
+
453
+ ```typescript
454
+ Bun.serve({
455
+ port: 3000,
456
+
457
+ websocket: wsRouter.websocketHandler,
458
+
459
+ async fetch(request, server) {
460
+ // Try WebSocket upgrade
461
+ const wsMatch = await wsRouter.matchAndPrepareUpgrade(request);
462
+ if (wsMatch && request.headers.get('upgrade') === 'websocket') {
463
+ if (server.upgrade(request, { data: wsMatch })) return;
464
+ }
465
+
466
+ // Handle regular HTTP requests
467
+ return router.fetch(request);
468
+ },
186
469
  });
187
470
  ```
188
471
 
@@ -197,11 +480,13 @@ The router throws specific error classes that you can catch and handle. These er
197
480
  Thrown when request or response validation fails. Contains detailed Zod validation issues.
198
481
 
199
482
  **Properties:**
483
+
200
484
  - `field: string` - The field that failed validation (`"params"`, `"query"`, `"headers"`, `"body"`, or `"response[status]"`)
201
485
  - `issues: z.ZodIssue[]` - Array of Zod validation issues with detailed error information
202
486
  - `message: string` - Error message
203
487
 
204
488
  **When thrown:**
489
+
205
490
  - Invalid path parameters (params)
206
491
  - Invalid query parameters (query)
207
492
  - Invalid request headers (headers)
@@ -229,10 +514,10 @@ Bun.serve({
229
514
  field: error.field,
230
515
  issues: error.issues,
231
516
  },
232
- { status: 400 }
517
+ { status: 400 },
233
518
  );
234
519
  }
235
-
520
+
236
521
  if (error instanceof RouteNotFoundError) {
237
522
  // Handle route not found
238
523
  return Response.json(
@@ -240,16 +525,13 @@ Bun.serve({
240
525
  error: 'Not Found',
241
526
  message: `Route ${error.method} ${error.path} not found`,
242
527
  },
243
- { status: 404 }
528
+ { status: 404 },
244
529
  );
245
530
  }
246
-
531
+
247
532
  // Handle unexpected errors
248
533
  console.error('Unexpected error:', error);
249
- return Response.json(
250
- { error: 'Internal Server Error' },
251
- { status: 500 }
252
- );
534
+ return Response.json({ error: 'Internal Server Error' }, { status: 500 });
253
535
  }
254
536
  },
255
537
  });
@@ -260,10 +542,12 @@ Bun.serve({
260
542
  Thrown when no matching route is found for the request.
261
543
 
262
544
  **Properties:**
545
+
263
546
  - `path: string` - The requested path
264
547
  - `method: string` - The HTTP method (GET, POST, etc.)
265
548
 
266
549
  **When thrown:**
550
+
267
551
  - No endpoint in the contract matches the request method and path
268
552
 
269
553
  **Example:**
@@ -278,7 +562,7 @@ try {
278
562
  error: 'Not Found',
279
563
  message: `Cannot ${error.method} ${error.path}`,
280
564
  },
281
- { status: 404 }
565
+ { status: 404 },
282
566
  );
283
567
  }
284
568
  throw error; // Re-throw other errors
@@ -288,12 +572,7 @@ try {
288
572
  ### Complete Error Handling Example
289
573
 
290
574
  ```typescript
291
- import {
292
- createRouter,
293
- ValidationError,
294
- RouteNotFoundError,
295
- Status,
296
- } from '@richie-rpc/server';
575
+ import { createRouter, ValidationError, RouteNotFoundError, Status } from '@richie-rpc/server';
297
576
 
298
577
  const router = createRouter(contract, handlers);
299
578
 
@@ -301,7 +580,7 @@ Bun.serve({
301
580
  port: 3000,
302
581
  async fetch(request) {
303
582
  const url = new URL(request.url);
304
-
583
+
305
584
  // Handle API routes
306
585
  if (url.pathname.startsWith('/api/')) {
307
586
  try {
@@ -319,29 +598,26 @@ Bun.serve({
319
598
  code: issue.code,
320
599
  })),
321
600
  },
322
- { status: 400 }
601
+ { status: 400 },
323
602
  );
324
603
  }
325
-
604
+
326
605
  if (error instanceof RouteNotFoundError) {
327
606
  return Response.json(
328
607
  {
329
608
  error: 'Not Found',
330
609
  message: `Route ${error.method} ${error.path} not found`,
331
610
  },
332
- { status: 404 }
611
+ { status: 404 },
333
612
  );
334
613
  }
335
-
614
+
336
615
  // Log unexpected errors
337
616
  console.error('Unexpected error:', error);
338
- return Response.json(
339
- { error: 'Internal Server Error' },
340
- { status: 500 }
341
- );
617
+ return Response.json({ error: 'Internal Server Error' }, { status: 500 });
342
618
  }
343
619
  }
344
-
620
+
345
621
  // Handle other routes
346
622
  return new Response('Not Found', { status: 404 });
347
623
  },
@@ -389,4 +665,3 @@ Both request and response data are validated against the contract schemas:
389
665
  ## License
390
666
 
391
667
  MIT
392
-