@richie-rpc/server 1.2.3 → 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,6 +94,9 @@ 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
@@ -137,14 +140,15 @@ Use the `Status` const object for type-safe status codes:
137
140
  ```typescript
138
141
  import { Status } from '@richie-rpc/server';
139
142
 
140
- return { status: Status.OK, body: user }; // 200
141
- return { status: Status.Created, body: newUser }; // 201
142
- return { status: Status.NoContent, body: {} }; // 204
143
- return { status: Status.BadRequest, body: error }; // 400
144
- 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
145
148
  ```
146
149
 
147
150
  Available status codes in `Status` object:
151
+
148
152
  - **Success**: `OK` (200), `Created` (201), `Accepted` (202), `NoContent` (204)
149
153
  - **Redirection**: `MovedPermanently` (301), `Found` (302), `NotModified` (304)
150
154
  - **Client Errors**: `BadRequest` (400), `Unauthorized` (401), `Forbidden` (403), `NotFound` (404), `MethodNotAllowed` (405), `Conflict` (409), `UnprocessableEntity` (422), `TooManyRequests` (429)
@@ -172,18 +176,18 @@ const contract = defineContract({
172
176
  method: 'GET',
173
177
  path: '/teapot',
174
178
  responses: {
175
- 418: z.object({ message: z.string(), isTeapot: z.boolean() })
176
- }
177
- }
179
+ 418: z.object({ message: z.string(), isTeapot: z.boolean() }),
180
+ },
181
+ },
178
182
  });
179
183
 
180
184
  const router = createRouter(contract, {
181
185
  teapot: async () => {
182
186
  return {
183
187
  status: 418 as const,
184
- body: { message: "I'm a teapot", isTeapot: true }
188
+ body: { message: "I'm a teapot", isTeapot: true },
185
189
  };
186
- }
190
+ },
187
191
  });
188
192
  ```
189
193
 
@@ -198,11 +202,13 @@ const contract = defineContract({
198
202
  path: '/upload',
199
203
  contentType: 'multipart/form-data',
200
204
  body: z.object({
201
- documents: z.array(z.object({
202
- file: z.instanceof(File),
203
- name: z.string(),
204
- tags: z.array(z.string()).optional(),
205
- })),
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
+ ),
206
212
  category: z.string(),
207
213
  }),
208
214
  responses: {
@@ -240,10 +246,229 @@ const router = createRouter(contract, {
240
246
  ```
241
247
 
242
248
  The server automatically:
249
+
243
250
  - Parses `multipart/form-data` requests
244
251
  - Reconstructs nested structures with File objects
245
252
  - Validates the body against your Zod schema
246
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
+ },
469
+ });
470
+ ```
471
+
247
472
  ## Error Handling
248
473
 
249
474
  The router throws specific error classes that you can catch and handle. These errors are thrown before handlers are called, so you should wrap your router calls in try-catch blocks.
@@ -255,11 +480,13 @@ The router throws specific error classes that you can catch and handle. These er
255
480
  Thrown when request or response validation fails. Contains detailed Zod validation issues.
256
481
 
257
482
  **Properties:**
483
+
258
484
  - `field: string` - The field that failed validation (`"params"`, `"query"`, `"headers"`, `"body"`, or `"response[status]"`)
259
485
  - `issues: z.ZodIssue[]` - Array of Zod validation issues with detailed error information
260
486
  - `message: string` - Error message
261
487
 
262
488
  **When thrown:**
489
+
263
490
  - Invalid path parameters (params)
264
491
  - Invalid query parameters (query)
265
492
  - Invalid request headers (headers)
@@ -287,10 +514,10 @@ Bun.serve({
287
514
  field: error.field,
288
515
  issues: error.issues,
289
516
  },
290
- { status: 400 }
517
+ { status: 400 },
291
518
  );
292
519
  }
293
-
520
+
294
521
  if (error instanceof RouteNotFoundError) {
295
522
  // Handle route not found
296
523
  return Response.json(
@@ -298,16 +525,13 @@ Bun.serve({
298
525
  error: 'Not Found',
299
526
  message: `Route ${error.method} ${error.path} not found`,
300
527
  },
301
- { status: 404 }
528
+ { status: 404 },
302
529
  );
303
530
  }
304
-
531
+
305
532
  // Handle unexpected errors
306
533
  console.error('Unexpected error:', error);
307
- return Response.json(
308
- { error: 'Internal Server Error' },
309
- { status: 500 }
310
- );
534
+ return Response.json({ error: 'Internal Server Error' }, { status: 500 });
311
535
  }
312
536
  },
313
537
  });
@@ -318,10 +542,12 @@ Bun.serve({
318
542
  Thrown when no matching route is found for the request.
319
543
 
320
544
  **Properties:**
545
+
321
546
  - `path: string` - The requested path
322
547
  - `method: string` - The HTTP method (GET, POST, etc.)
323
548
 
324
549
  **When thrown:**
550
+
325
551
  - No endpoint in the contract matches the request method and path
326
552
 
327
553
  **Example:**
@@ -336,7 +562,7 @@ try {
336
562
  error: 'Not Found',
337
563
  message: `Cannot ${error.method} ${error.path}`,
338
564
  },
339
- { status: 404 }
565
+ { status: 404 },
340
566
  );
341
567
  }
342
568
  throw error; // Re-throw other errors
@@ -346,12 +572,7 @@ try {
346
572
  ### Complete Error Handling Example
347
573
 
348
574
  ```typescript
349
- import {
350
- createRouter,
351
- ValidationError,
352
- RouteNotFoundError,
353
- Status,
354
- } from '@richie-rpc/server';
575
+ import { createRouter, ValidationError, RouteNotFoundError, Status } from '@richie-rpc/server';
355
576
 
356
577
  const router = createRouter(contract, handlers);
357
578
 
@@ -359,7 +580,7 @@ Bun.serve({
359
580
  port: 3000,
360
581
  async fetch(request) {
361
582
  const url = new URL(request.url);
362
-
583
+
363
584
  // Handle API routes
364
585
  if (url.pathname.startsWith('/api/')) {
365
586
  try {
@@ -377,29 +598,26 @@ Bun.serve({
377
598
  code: issue.code,
378
599
  })),
379
600
  },
380
- { status: 400 }
601
+ { status: 400 },
381
602
  );
382
603
  }
383
-
604
+
384
605
  if (error instanceof RouteNotFoundError) {
385
606
  return Response.json(
386
607
  {
387
608
  error: 'Not Found',
388
609
  message: `Route ${error.method} ${error.path} not found`,
389
610
  },
390
- { status: 404 }
611
+ { status: 404 },
391
612
  );
392
613
  }
393
-
614
+
394
615
  // Log unexpected errors
395
616
  console.error('Unexpected error:', error);
396
- return Response.json(
397
- { error: 'Internal Server Error' },
398
- { status: 500 }
399
- );
617
+ return Response.json({ error: 'Internal Server Error' }, { status: 500 });
400
618
  }
401
619
  }
402
-
620
+
403
621
  // Handle other routes
404
622
  return new Response('Not Found', { status: 404 });
405
623
  },
@@ -447,4 +665,3 @@ Both request and response data are validated against the contract schemas:
447
665
  ## License
448
666
 
449
667
  MIT
450
-