@richie-rpc/client 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
@@ -19,8 +19,8 @@ import { contract } from './contract';
19
19
  const client = createClient(contract, {
20
20
  baseUrl: 'https://api.example.com',
21
21
  headers: {
22
- 'Authorization': 'Bearer token123'
23
- }
22
+ Authorization: 'Bearer token123',
23
+ },
24
24
  });
25
25
  ```
26
26
 
@@ -36,20 +36,22 @@ const client = createClient(contract, {
36
36
 
37
37
  // Relative URL with path prefix (browser-friendly)
38
38
  const client = createClient(contract, {
39
- baseUrl: '/api', // Resolves to current origin + /api
39
+ baseUrl: '/api', // Resolves to current origin + /api
40
40
  });
41
41
 
42
42
  // Just the path (same origin)
43
43
  const client = createClient(contract, {
44
- baseUrl: '/', // Resolves to current origin
44
+ baseUrl: '/', // Resolves to current origin
45
45
  });
46
46
  ```
47
47
 
48
48
  **How it works:**
49
+
49
50
  - **Absolute URLs** (`http://...` or `https://...`): Used as-is
50
51
  - **Relative URLs** (starting with `/`): Automatically resolved using `window.location.origin` in browsers, or `http://localhost` in non-browser environments
51
52
 
52
53
  **Example:** In a browser at `https://example.com`, if your contract defines `/users`:
54
+
53
55
  - With `baseUrl: '/api'` → actual URL is `https://example.com/api/users`
54
56
  - With `baseUrl: '/'` → actual URL is `https://example.com/users`
55
57
 
@@ -59,8 +61,8 @@ The client provides fully typed methods for each endpoint in your contract:
59
61
 
60
62
  ```typescript
61
63
  // GET request with path parameters
62
- const user = await client.getUser({
63
- params: { id: '123' }
64
+ const user = await client.getUser({
65
+ params: { id: '123' },
64
66
  });
65
67
  // user is typed based on the response schema
66
68
 
@@ -68,23 +70,23 @@ const user = await client.getUser({
68
70
  const newUser = await client.createUser({
69
71
  body: {
70
72
  name: 'John Doe',
71
- email: 'john@example.com'
72
- }
73
+ email: 'john@example.com',
74
+ },
73
75
  });
74
76
 
75
77
  // Request with query parameters
76
78
  const users = await client.listUsers({
77
79
  query: {
78
80
  limit: '10',
79
- offset: '0'
80
- }
81
+ offset: '0',
82
+ },
81
83
  });
82
84
 
83
85
  // Request with custom headers
84
86
  const data = await client.getData({
85
87
  headers: {
86
- 'X-Custom-Header': 'value'
87
- }
88
+ 'X-Custom-Header': 'value',
89
+ },
88
90
  });
89
91
  ```
90
92
 
@@ -116,6 +118,7 @@ if (response.status === 201) {
116
118
  ```
117
119
 
118
120
  The client automatically:
121
+
119
122
  - Detects `multipart/form-data` content type from the contract
120
123
  - Serializes nested structures with File objects to FormData
121
124
  - Sets the correct `Content-Type` header with boundary
@@ -150,20 +153,23 @@ try {
150
153
  ```typescript
151
154
  useEffect(() => {
152
155
  const controller = new AbortController();
153
-
154
- client.getConversation({
155
- params: { projectId, sessionId },
156
- abortSignal: controller.signal,
157
- }).then((response) => {
158
- if (response.status === 200) {
159
- setData(response.data);
160
- }
161
- }).catch((error) => {
162
- if (error.name !== 'AbortError') {
163
- console.error('Request failed:', error);
164
- }
165
- });
166
-
156
+
157
+ client
158
+ .getConversation({
159
+ params: { projectId, sessionId },
160
+ abortSignal: controller.signal,
161
+ })
162
+ .then((response) => {
163
+ if (response.status === 200) {
164
+ setData(response.data);
165
+ }
166
+ })
167
+ .catch((error) => {
168
+ if (error.name !== 'AbortError') {
169
+ console.error('Request failed:', error);
170
+ }
171
+ });
172
+
167
173
  // Cleanup: abort request if component unmounts
168
174
  return () => controller.abort();
169
175
  }, [projectId, sessionId]);
@@ -190,12 +196,233 @@ try {
190
196
  }
191
197
  ```
192
198
 
199
+ ## Streaming Responses
200
+
201
+ For streaming endpoints, the client returns an event-based result:
202
+
203
+ ```typescript
204
+ const contract = defineContract({
205
+ generateText: {
206
+ type: 'streaming',
207
+ method: 'POST',
208
+ path: '/generate',
209
+ body: z.object({ prompt: z.string() }),
210
+ chunk: z.object({ text: z.string() }),
211
+ finalResponse: z.object({ totalTokens: z.number() }),
212
+ },
213
+ });
214
+
215
+ const client = createClient(contract, { baseUrl: 'http://localhost:3000' });
216
+
217
+ const result = client.generateText({ body: { prompt: 'Hello world' } });
218
+
219
+ // Listen for chunks
220
+ result.on('chunk', (chunk) => {
221
+ process.stdout.write(chunk.text);
222
+ });
223
+
224
+ // Listen for stream completion
225
+ result.on('close', (final) => {
226
+ if (final) {
227
+ console.log(`\nTotal tokens: ${final.totalTokens}`);
228
+ }
229
+ });
230
+
231
+ // Handle errors
232
+ result.on('error', (error) => {
233
+ console.error('Stream error:', error.message);
234
+ });
235
+
236
+ // Abort if needed
237
+ result.abort();
238
+ ```
239
+
240
+ ### StreamingResult Interface
241
+
242
+ - `on('chunk', handler)` - Subscribe to chunks
243
+ - `on('close', handler)` - Subscribe to stream close (with optional final response)
244
+ - `on('error', handler)` - Subscribe to errors
245
+ - `abort()` - Abort the stream
246
+ - `aborted` - Check if the stream was aborted
247
+
248
+ ## Server-Sent Events (SSE)
249
+
250
+ For SSE endpoints, the client returns an event-based connection:
251
+
252
+ ```typescript
253
+ const contract = defineContract({
254
+ notifications: {
255
+ type: 'sse',
256
+ method: 'GET',
257
+ path: '/notifications',
258
+ events: {
259
+ message: z.object({ text: z.string(), timestamp: z.string() }),
260
+ heartbeat: z.object({ timestamp: z.string() }),
261
+ },
262
+ },
263
+ });
264
+
265
+ const client = createClient(contract, { baseUrl: 'http://localhost:3000' });
266
+
267
+ const conn = client.notifications();
268
+
269
+ // Listen for specific event types
270
+ conn.on('message', (data) => {
271
+ console.log(`Message: ${data.text} at ${data.timestamp}`);
272
+ });
273
+
274
+ conn.on('heartbeat', (data) => {
275
+ console.log('Heartbeat:', data.timestamp);
276
+ });
277
+
278
+ // Handle connection errors
279
+ conn.on('error', (error) => {
280
+ console.error('SSE error:', error.message);
281
+ });
282
+
283
+ // Close when done
284
+ conn.close();
285
+
286
+ // Check connection state
287
+ console.log('State:', conn.state); // 'connecting' | 'open' | 'closed'
288
+ ```
289
+
290
+ ### SSEConnection Interface
291
+
292
+ - `on(event, handler)` - Subscribe to a specific event type
293
+ - `on('error', handler)` - Subscribe to connection errors
294
+ - `close()` - Close the connection
295
+ - `state` - Current connection state
296
+
297
+ ## WebSocket Client
298
+
299
+ For bidirectional real-time communication, use `createWebSocketClient`:
300
+
301
+ ```typescript
302
+ import { createWebSocketClient } from '@richie-rpc/client';
303
+ import { defineWebSocketContract } from '@richie-rpc/core';
304
+
305
+ const wsContract = defineWebSocketContract({
306
+ chat: {
307
+ path: '/ws/chat/:roomId',
308
+ params: z.object({ roomId: z.string() }),
309
+ clientMessages: {
310
+ sendMessage: { payload: z.object({ text: z.string() }) },
311
+ },
312
+ serverMessages: {
313
+ message: { payload: z.object({ userId: z.string(), text: z.string() }) },
314
+ error: { payload: z.object({ code: z.string(), message: z.string() }) },
315
+ },
316
+ },
317
+ });
318
+
319
+ const wsClient = createWebSocketClient(wsContract, {
320
+ baseUrl: 'ws://localhost:3000',
321
+ });
322
+
323
+ // Get a typed WebSocket instance
324
+ const chat = wsClient.chat({ params: { roomId: 'general' } });
325
+
326
+ // Connect (returns disconnect function)
327
+ const disconnect = chat.connect();
328
+
329
+ // Track connection state
330
+ chat.onStateChange((connected) => {
331
+ console.log('Connected:', connected);
332
+ });
333
+
334
+ // Listen for specific message types
335
+ chat.on('message', (payload) => {
336
+ console.log(`${payload.userId}: ${payload.text}`);
337
+ });
338
+
339
+ chat.on('error', (payload) => {
340
+ console.error(`Error ${payload.code}: ${payload.message}`);
341
+ });
342
+
343
+ // Listen for all messages
344
+ chat.onMessage((message) => {
345
+ console.log('Received:', message.type, message.payload);
346
+ });
347
+
348
+ // Handle connection errors
349
+ chat.onError((error) => {
350
+ console.error('Connection error:', error.message);
351
+ });
352
+
353
+ // Send messages (validates before sending)
354
+ chat.send('sendMessage', { text: 'Hello!' });
355
+
356
+ // Disconnect when done
357
+ disconnect();
358
+ ```
359
+
360
+ ### TypedWebSocket Interface
361
+
362
+ - `connect()` - Connect to the WebSocket server, returns disconnect function
363
+ - `send(type, payload)` - Send a typed message (validates before sending)
364
+ - `on(type, handler)` - Subscribe to a specific message type
365
+ - `onMessage(handler)` - Subscribe to all messages
366
+ - `onStateChange(handler)` - Track connection state changes
367
+ - `onError(handler)` - Handle connection errors
368
+ - `connected` - Check current connection state
369
+
370
+ ### React Integration Example
371
+
372
+ ```typescript
373
+ function ChatRoom({ roomId }: { roomId: string }) {
374
+ const [connected, setConnected] = useState(false);
375
+ const [messages, setMessages] = useState<Message[]>([]);
376
+
377
+ const chat = useMemo(
378
+ () => wsClient.chat({ params: { roomId } }),
379
+ [roomId]
380
+ );
381
+
382
+ // Connection lifecycle
383
+ useEffect(() => {
384
+ const disconnect = chat.connect();
385
+ return () => disconnect();
386
+ }, [chat]);
387
+
388
+ // Track connection state
389
+ useEffect(() => {
390
+ return chat.onStateChange(setConnected);
391
+ }, [chat]);
392
+
393
+ // Subscribe to messages (only when connected)
394
+ useEffect(() => {
395
+ if (!connected) return;
396
+ return chat.on('message', (payload) => {
397
+ setMessages((prev) => [...prev, payload]);
398
+ });
399
+ }, [connected, chat]);
400
+
401
+ const handleSend = (text: string) => {
402
+ chat.send('sendMessage', { text });
403
+ };
404
+
405
+ return (
406
+ <div>
407
+ <div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
408
+ {messages.map((msg, i) => (
409
+ <div key={i}>{msg.userId}: {msg.text}</div>
410
+ ))}
411
+ <button onClick={() => handleSend('Hello!')}>Send</button>
412
+ </div>
413
+ );
414
+ }
415
+ ```
416
+
193
417
  ## Features
194
418
 
195
419
  - ✅ Full type safety based on contract
196
420
  - ✅ Automatic path parameter interpolation
197
421
  - ✅ Query parameter encoding
198
422
  - ✅ BasePath support in baseUrl
423
+ - ✅ HTTP Streaming with event-based API
424
+ - ✅ Server-Sent Events (SSE) client
425
+ - ✅ WebSocket client with typed messages
199
426
  - ✅ Request validation before sending
200
427
  - ✅ Response validation after receiving
201
428
  - ✅ Detailed error information
@@ -211,10 +438,10 @@ try {
211
438
 
212
439
  ```typescript
213
440
  interface ClientConfig {
214
- baseUrl: string; // Base URL for all requests
215
- headers?: Record<string, string>; // Default headers
216
- validateRequest?: boolean; // Validate before sending (default: true)
217
- validateResponse?: boolean; // Validate after receiving (default: true)
441
+ baseUrl: string; // Base URL for all requests
442
+ headers?: Record<string, string>; // Default headers
443
+ validateRequest?: boolean; // Validate before sending (default: true)
444
+ validateResponse?: boolean; // Validate after receiving (default: true)
218
445
  }
219
446
  ```
220
447
 
@@ -226,7 +453,7 @@ Responses include both the status code and data:
226
453
  const response = await client.getUser({ params: { id: '123' } });
227
454
 
228
455
  console.log(response.status); // 200, 404, etc.
229
- console.log(response.data); // Typed response body
456
+ console.log(response.data); // Typed response body
230
457
  ```
231
458
 
232
459
  ## Error Handling
@@ -240,11 +467,11 @@ Thrown when request data fails validation:
240
467
  ```typescript
241
468
  try {
242
469
  await client.createUser({
243
- body: { email: 'invalid-email' }
470
+ body: { email: 'invalid-email' },
244
471
  });
245
472
  } catch (error) {
246
473
  if (error instanceof ClientValidationError) {
247
- console.log(error.field); // 'body'
474
+ console.log(error.field); // 'body'
248
475
  console.log(error.issues); // Zod validation issues
249
476
  }
250
477
  }
@@ -259,9 +486,9 @@ try {
259
486
  await client.getUser({ params: { id: '999' } });
260
487
  } catch (error) {
261
488
  if (error instanceof HTTPError) {
262
- console.log(error.status); // 404
489
+ console.log(error.status); // 404
263
490
  console.log(error.statusText); // 'Not Found'
264
- console.log(error.body); // Response body
491
+ console.log(error.body); // Response body
265
492
  }
266
493
  }
267
494
  ```
@@ -273,12 +500,12 @@ All client methods are fully typed based on your contract:
273
500
  ```typescript
274
501
  // ✅ Type-safe: required fields
275
502
  await client.createUser({
276
- body: { name: 'John', email: 'john@example.com' }
503
+ body: { name: 'John', email: 'john@example.com' },
277
504
  });
278
505
 
279
506
  // ❌ Type error: missing required field
280
507
  await client.createUser({
281
- body: { name: 'John' }
508
+ body: { name: 'John' },
282
509
  });
283
510
 
284
511
  // ✅ Type-safe: response data
@@ -313,8 +540,8 @@ You can disable validation:
313
540
  ```typescript
314
541
  const client = createClient(contract, {
315
542
  baseUrl: 'https://api.example.com',
316
- validateRequest: false, // Skip request validation
317
- validateResponse: false // Skip response validation
543
+ validateRequest: false, // Skip request validation
544
+ validateResponse: false, // Skip response validation
318
545
  });
319
546
  ```
320
547
 
@@ -326,4 +553,3 @@ const client = createClient(contract, {
326
553
  ## License
327
554
 
328
555
  MIT
329
-