@richie-rpc/client 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
@@ -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,26 +70,59 @@ 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
 
93
+ ### File Uploads (multipart/form-data)
94
+
95
+ Upload files with full type safety. Files can be nested anywhere in the request body:
96
+
97
+ ```typescript
98
+ // Contract defines the file upload endpoint
99
+ // (see @richie-rpc/core for defining contentType: 'multipart/form-data')
100
+
101
+ // Client usage - just pass File objects in the body
102
+ const file1 = new File(['content'], 'report.pdf', { type: 'application/pdf' });
103
+ const file2 = new File(['data'], 'data.csv', { type: 'text/csv' });
104
+
105
+ const response = await client.uploadDocuments({
106
+ body: {
107
+ documents: [
108
+ { file: file1, name: 'Q4 Report', tags: ['quarterly', 'finance'] },
109
+ { file: file2, name: 'Sales Data' },
110
+ ],
111
+ category: 'reports',
112
+ },
113
+ });
114
+
115
+ if (response.status === 201) {
116
+ console.log(`Uploaded ${response.data.uploadedCount} files`);
117
+ }
118
+ ```
119
+
120
+ The client automatically:
121
+
122
+ - Detects `multipart/form-data` content type from the contract
123
+ - Serializes nested structures with File objects to FormData
124
+ - Sets the correct `Content-Type` header with boundary
125
+
91
126
  ### Canceling Requests
92
127
 
93
128
  You can cancel in-flight requests using `AbortController`:
@@ -118,20 +153,23 @@ try {
118
153
  ```typescript
119
154
  useEffect(() => {
120
155
  const controller = new AbortController();
121
-
122
- client.getConversation({
123
- params: { projectId, sessionId },
124
- abortSignal: controller.signal,
125
- }).then((response) => {
126
- if (response.status === 200) {
127
- setData(response.data);
128
- }
129
- }).catch((error) => {
130
- if (error.name !== 'AbortError') {
131
- console.error('Request failed:', error);
132
- }
133
- });
134
-
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
+
135
173
  // Cleanup: abort request if component unmounts
136
174
  return () => controller.abort();
137
175
  }, [projectId, sessionId]);
@@ -158,18 +196,241 @@ try {
158
196
  }
159
197
  ```
160
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
+
161
417
  ## Features
162
418
 
163
419
  - ✅ Full type safety based on contract
164
420
  - ✅ Automatic path parameter interpolation
165
421
  - ✅ Query parameter encoding
166
422
  - ✅ BasePath support in baseUrl
423
+ - ✅ HTTP Streaming with event-based API
424
+ - ✅ Server-Sent Events (SSE) client
425
+ - ✅ WebSocket client with typed messages
167
426
  - ✅ Request validation before sending
168
427
  - ✅ Response validation after receiving
169
428
  - ✅ Detailed error information
170
429
  - ✅ Support for all HTTP methods
171
430
  - ✅ Custom headers per request
172
431
  - ✅ Request cancellation with AbortController
432
+ - ✅ File uploads with `multipart/form-data`
433
+ - ✅ Nested file structures in request bodies
173
434
 
174
435
  ## Configuration
175
436
 
@@ -177,10 +438,10 @@ try {
177
438
 
178
439
  ```typescript
179
440
  interface ClientConfig {
180
- baseUrl: string; // Base URL for all requests
181
- headers?: Record<string, string>; // Default headers
182
- validateRequest?: boolean; // Validate before sending (default: true)
183
- 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)
184
445
  }
185
446
  ```
186
447
 
@@ -192,7 +453,7 @@ Responses include both the status code and data:
192
453
  const response = await client.getUser({ params: { id: '123' } });
193
454
 
194
455
  console.log(response.status); // 200, 404, etc.
195
- console.log(response.data); // Typed response body
456
+ console.log(response.data); // Typed response body
196
457
  ```
197
458
 
198
459
  ## Error Handling
@@ -206,11 +467,11 @@ Thrown when request data fails validation:
206
467
  ```typescript
207
468
  try {
208
469
  await client.createUser({
209
- body: { email: 'invalid-email' }
470
+ body: { email: 'invalid-email' },
210
471
  });
211
472
  } catch (error) {
212
473
  if (error instanceof ClientValidationError) {
213
- console.log(error.field); // 'body'
474
+ console.log(error.field); // 'body'
214
475
  console.log(error.issues); // Zod validation issues
215
476
  }
216
477
  }
@@ -225,9 +486,9 @@ try {
225
486
  await client.getUser({ params: { id: '999' } });
226
487
  } catch (error) {
227
488
  if (error instanceof HTTPError) {
228
- console.log(error.status); // 404
489
+ console.log(error.status); // 404
229
490
  console.log(error.statusText); // 'Not Found'
230
- console.log(error.body); // Response body
491
+ console.log(error.body); // Response body
231
492
  }
232
493
  }
233
494
  ```
@@ -239,12 +500,12 @@ All client methods are fully typed based on your contract:
239
500
  ```typescript
240
501
  // ✅ Type-safe: required fields
241
502
  await client.createUser({
242
- body: { name: 'John', email: 'john@example.com' }
503
+ body: { name: 'John', email: 'john@example.com' },
243
504
  });
244
505
 
245
506
  // ❌ Type error: missing required field
246
507
  await client.createUser({
247
- body: { name: 'John' }
508
+ body: { name: 'John' },
248
509
  });
249
510
 
250
511
  // ✅ Type-safe: response data
@@ -279,8 +540,8 @@ You can disable validation:
279
540
  ```typescript
280
541
  const client = createClient(contract, {
281
542
  baseUrl: 'https://api.example.com',
282
- validateRequest: false, // Skip request validation
283
- validateResponse: false // Skip response validation
543
+ validateRequest: false, // Skip request validation
544
+ validateResponse: false, // Skip response validation
284
545
  });
285
546
  ```
286
547
 
@@ -292,4 +553,3 @@ const client = createClient(contract, {
292
553
  ## License
293
554
 
294
555
  MIT
295
-