@replit/river 0.209.7 → 0.210.0

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
@@ -1,7 +1,5 @@
1
1
  # River
2
2
 
3
- River allows multiple clients to connect to and make remote procedure calls to a remote server as if they were local procedures.
4
-
5
3
  ## Long-lived streaming remote procedure calls
6
4
 
7
5
  River provides a framework for long-lived streaming Remote Procedure Calls (RPCs) in modern web applications, featuring advanced error handling and customizable retry policies to ensure seamless communication between clients and servers.
@@ -14,7 +12,8 @@ River provides a framework similar to [tRPC](https://trpc.io/) and [gRPC](https:
14
12
  - result types and error handling
15
13
  - snappy DX (no code generation)
16
14
  - transparent reconnect support for long-lived sessions
17
- - over any transport (WebSockets and Unix Domain Socket out of the box)
15
+ - over any transport (WebSockets out of the box)
16
+ - full OpenTelemetry integration (distributed tracing for connections, sessions, procedure calls)
18
17
 
19
18
  See [PROTOCOL.md](./PROTOCOL.md) for more information on the protocol.
20
19
 
@@ -27,13 +26,7 @@ Before proceeding, ensure you have TypeScript 5 installed and configured appropr
27
26
  You must verify that:
28
27
 
29
28
  - `compilerOptions.moduleResolution` is set to `"bundler"`
30
- - `compilerOptions.strictFunctionTypes` is set to `true`
31
- - `compilerOptions.strictNullChecks` is set to `true`
32
-
33
- or, preferably, that:
34
-
35
- - `compilerOptions.moduleResolution` is set to `"bundler"`
36
- - `compilerOptions.strict` is set to `true`
29
+ - `compilerOptions.strict` is set to true (or at least `compilerOptions.strictFunctionTypes` and `compilerOptions.strictNullChecks`)
37
30
 
38
31
  Like so:
39
32
 
@@ -47,7 +40,7 @@ Before proceeding, ensure you have TypeScript 5 installed and configured appropr
47
40
  }
48
41
  ```
49
42
 
50
- If these options already exist in your `tsconfig.json` and don't match what is shown above, modify them. River is designed for `"strict": true`, but technically only `strictFunctionTypes` and `strictNullChecks` being set to `true` is required. Failing to set these will cause unresolvable type errors when defining services.
43
+ If these options already exist in your `tsconfig.json` and don't match what is shown above, modify them. Failing to set these will cause unresolvable type errors when defining services.
51
44
 
52
45
  2. Install River and Dependencies:
53
46
 
@@ -75,14 +68,15 @@ Before proceeding, ensure you have TypeScript 5 installed and configured appropr
75
68
 
76
69
  ### A basic router
77
70
 
78
- First, we create a service using `ServiceSchema`:
71
+ First, we create a service:
79
72
 
80
73
  ```ts
81
- import { ServiceSchema, Procedure, Ok } from '@replit/river';
74
+ import { createServiceSchema, Procedure, Ok } from '@replit/river';
82
75
  import { Type } from '@sinclair/typebox';
83
76
 
77
+ const ServiceSchema = createServiceSchema();
84
78
  export const ExampleService = ServiceSchema.define(
85
- // configuration
79
+ // optional configuration parameter
86
80
  {
87
81
  // initializer for shared state
88
82
  initializeState: () => ({ count: 0 }),
@@ -90,10 +84,13 @@ export const ExampleService = ServiceSchema.define(
90
84
  // procedures
91
85
  {
92
86
  add: Procedure.rpc({
87
+ // input type
93
88
  requestInit: Type.Object({ n: Type.Number() }),
89
+ // response data type
94
90
  responseData: Type.Object({ result: Type.Number() }),
95
- requestErrors: Type.Never(),
96
- // note that a handler is unique per user RPC
91
+ // any error results (other than the uncaught) that this procedure can return
92
+ responseError: Type.Never(),
93
+ // note that a handler is unique per user
97
94
  async handler({ ctx, reqInit: { n } }) {
98
95
  // access and mutate shared state
99
96
  ctx.state.count += n;
@@ -118,11 +115,13 @@ const port = 3000;
118
115
  const wss = new WebSocketServer({ server: httpServer });
119
116
  const transport = new WebSocketServerTransport(wss, 'SERVER');
120
117
 
121
- export const server = createServer(transport, {
118
+ const services = {
122
119
  example: ExampleService,
123
- });
120
+ };
124
121
 
125
- export type ServiceSurface = typeof server;
122
+ export type ServiceSurface = typeof services;
123
+
124
+ const server = createServer(transport, services);
126
125
 
127
126
  httpServer.listen(port);
128
127
  ```
@@ -133,13 +132,15 @@ In another file for the client (to create a separate entrypoint),
133
132
  import { WebSocketClientTransport } from '@replit/river/transport/ws/client';
134
133
  import { createClient } from '@replit/river';
135
134
  import { WebSocket } from 'ws';
135
+ import type { ServiceSurface } from './server';
136
+ // ^ type only import to avoid bundling the server!
136
137
 
137
138
  const transport = new WebSocketClientTransport(
138
139
  async () => new WebSocket('ws://localhost:3000'),
139
140
  'my-client-id',
140
141
  );
141
142
 
142
- const client = createClient(
143
+ const client = createClient<ServiceSurface>(
143
144
  transport,
144
145
  'SERVER', // transport id of the server in the previous step
145
146
  { eagerlyConnect: true }, // whether to eagerly connect to the server on creation (optional argument)
@@ -155,6 +156,88 @@ if (result.ok) {
155
156
  }
156
157
  ```
157
158
 
159
+ ### Error Handling
160
+
161
+ River uses a Result pattern for error handling. All procedure responses are wrapped in `Ok()` for success or `Err()` for errors:
162
+
163
+ ```ts
164
+ import { Ok, Err } from '@replit/river';
165
+
166
+ // success
167
+ return Ok({ result: 42 });
168
+
169
+ // error
170
+ return Err({ code: 'INVALID_INPUT', message: 'Value must be positive' });
171
+ ```
172
+
173
+ #### Custom Error Types
174
+
175
+ You can define custom error schemas for your procedures:
176
+
177
+ ```ts
178
+ const MathService = ServiceSchema.define({
179
+ divide: Procedure.rpc({
180
+ requestInit: Type.Object({ a: Type.Number(), b: Type.Number() }),
181
+ responseData: Type.Object({ result: Type.Number() }),
182
+ responseError: Type.Union([
183
+ Type.Object({
184
+ code: Type.Literal('DIVISION_BY_ZERO'),
185
+ message: Type.String(),
186
+ extras: Type.Object({ dividend: Type.Number() }),
187
+ }),
188
+ Type.Object({
189
+ code: Type.Literal('INVALID_INPUT'),
190
+ message: Type.String(),
191
+ }),
192
+ ]),
193
+ async handler({ reqInit: { a, b } }) {
194
+ if (b === 0) {
195
+ return Err({
196
+ code: 'DIVISION_BY_ZERO',
197
+ message: 'Cannot divide by zero',
198
+ extras: { dividend: a },
199
+ });
200
+ }
201
+
202
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
203
+ return Err({
204
+ code: 'INVALID_INPUT',
205
+ message: 'Inputs must be finite numbers',
206
+ });
207
+ }
208
+
209
+ return Ok({ result: a / b });
210
+ },
211
+ }),
212
+ });
213
+ ```
214
+
215
+ #### Uncaught Errors
216
+
217
+ When a procedure handler throws an uncaught error, River automatically handles it:
218
+
219
+ ```ts
220
+ const ExampleService = ServiceSchema.define({
221
+ maybeThrow: Procedure.rpc({
222
+ requestInit: Type.Object({ shouldThrow: Type.Boolean() }),
223
+ responseData: Type.Object({ result: Type.String() }),
224
+ async handler({ reqInit: { shouldThrow } }) {
225
+ if (shouldThrow) {
226
+ throw new Error('Something went wrong!');
227
+ }
228
+
229
+ return Ok({ result: 'success' });
230
+ },
231
+ }),
232
+ });
233
+
234
+ // client will receive an error with code 'UNCAUGHT_ERROR'
235
+ const result = await client.example.maybeThrow.rpc({ shouldThrow: true });
236
+ if (!result.ok && result.payload.code === 'UNCAUGHT_ERROR') {
237
+ console.log('Handler threw an error:', result.payload.message);
238
+ }
239
+ ```
240
+
158
241
  ### Logging
159
242
 
160
243
  To add logging, you can bind a logging function to a transport.
@@ -208,7 +291,380 @@ transport.addEventListener('sessionTransition', (evt) => {
208
291
  });
209
292
  ```
210
293
 
211
- ### Custom Handshake
294
+ ### Advanced Patterns
295
+
296
+ #### All Procedure Types
297
+
298
+ River supports four types of procedures, each with different message patterns:
299
+
300
+ ##### Unary RPC Procedures (1:1)
301
+
302
+ Single request, single response:
303
+
304
+ ```ts
305
+ const ExampleService = ServiceSchema.define({
306
+ add: Procedure.rpc({
307
+ requestInit: Type.Object({ a: Type.Number(), b: Type.Number() }),
308
+ responseData: Type.Object({ result: Type.Number() }),
309
+ async handler({ reqInit: { a, b } }) {
310
+ return Ok({ result: a + b });
311
+ },
312
+ }),
313
+ });
314
+
315
+ // client usage
316
+ const result = await client.example.add.rpc({ a: 1, b: 2 });
317
+ if (result.ok) {
318
+ console.log(result.payload.result); // 3
319
+ }
320
+ ```
321
+
322
+ ##### Upload Procedures (n:1)
323
+
324
+ Multiple requests, single response:
325
+
326
+ ```ts
327
+ const ExampleService = ServiceSchema.define({
328
+ sum: Procedure.upload({
329
+ requestInit: Type.Object({ multiplier: Type.Number() }),
330
+ requestData: Type.Object({ value: Type.Number() }),
331
+ responseData: Type.Object({ total: Type.Number() }),
332
+ responseError: Type.Object({
333
+ code: Type.Literal('INVALID_INPUT'),
334
+ message: Type.String(),
335
+ }),
336
+ async handler({ ctx, reqInit, reqReadable }) {
337
+ let sum = 0;
338
+ for await (const msg of reqReadable) {
339
+ if (!msg.ok) {
340
+ return ctx.cancel('client disconnected');
341
+ }
342
+
343
+ sum += msg.payload.value;
344
+ }
345
+ return Ok({ total: sum * reqInit.multiplier });
346
+ },
347
+ }),
348
+ });
349
+
350
+ // client usage
351
+ const { reqWritable, finalize } = client.example.sum.upload({ multiplier: 2 });
352
+ reqWritable.write({ value: 1 });
353
+ reqWritable.write({ value: 2 });
354
+ reqWritable.write({ value: 3 });
355
+
356
+ const result = await finalize();
357
+ if (result.ok) {
358
+ console.log(result.payload.total); // 12 (6 * 2)
359
+ } else {
360
+ console.error('Upload failed:', result.payload.message);
361
+ }
362
+ ```
363
+
364
+ ##### Subscription Procedures (1:n)
365
+
366
+ Single request, multiple responses:
367
+
368
+ ```ts
369
+ const ExampleService = ServiceSchema.define(
370
+ { initializeState: () => ({ count: 0 }) },
371
+ {
372
+ counter: Procedure.subscription({
373
+ requestInit: Type.Object({ interval: Type.Number() }),
374
+ responseData: Type.Object({ count: Type.Number() }),
375
+ async handler({ ctx, reqInit, resWritable }) {
376
+ const intervalId = setInterval(() => {
377
+ ctx.state.count++;
378
+ resWritable.write(Ok({ count: ctx.state.count }));
379
+ }, reqInit.interval);
380
+
381
+ ctx.signal.addEventListener('abort', () => {
382
+ clearInterval(intervalId);
383
+ });
384
+ },
385
+ }),
386
+ },
387
+ );
388
+
389
+ // client usage
390
+ const { resReadable } = client.example.counter.subscribe({ interval: 1000 });
391
+ for await (const msg of resReadable) {
392
+ if (msg.ok) {
393
+ console.log('Count:', msg.payload.count);
394
+ } else {
395
+ console.error('Subscription error:', msg.payload.message);
396
+ break; // exit on error for subscriptions
397
+ }
398
+ }
399
+ ```
400
+
401
+ ##### Stream Procedures (n:n)
402
+
403
+ Multiple requests, multiple responses:
404
+
405
+ ```ts
406
+ const ExampleService = ServiceSchema.define({
407
+ echo: Procedure.stream({
408
+ requestInit: Type.Object({ prefix: Type.String() }),
409
+ requestData: Type.Object({ message: Type.String() }),
410
+ responseData: Type.Object({ echo: Type.String() }),
411
+ async handler({ reqInit, reqReadable, resWritable, ctx }) {
412
+ for await (const msg of reqReadable) {
413
+ if (!msg.ok) {
414
+ return;
415
+ }
416
+
417
+ const { message } = msg.payload;
418
+ resWritable.write(
419
+ Ok({
420
+ echo: `${reqInit.prefix}: ${message}`,
421
+ }),
422
+ );
423
+ }
424
+
425
+ // client ended their side, we can close ours
426
+ resWritable.close();
427
+ },
428
+ }),
429
+ });
430
+
431
+ // client usage
432
+ const { reqWritable, resReadable } = client.example.echo.stream({
433
+ prefix: 'Server',
434
+ });
435
+
436
+ // send messages
437
+ reqWritable.write({ message: 'Hello' });
438
+ reqWritable.write({ message: 'World' });
439
+ reqWritable.close();
440
+
441
+ // read responses
442
+ for await (const msg of resReadable) {
443
+ if (msg.ok) {
444
+ console.log(msg.payload.echo); // "Server: Hello", "Server: World"
445
+ } else {
446
+ console.error('Stream error:', msg.payload.message);
447
+ }
448
+ }
449
+ ```
450
+
451
+ #### Client Cancellation
452
+
453
+ River supports client-side cancellation using AbortController. All procedure calls accept an optional `signal` parameter:
454
+
455
+ ```ts
456
+ const controller = new AbortController();
457
+ const rpcResult = client.example.longRunning.rpc(
458
+ { data: 'hello world' },
459
+ { signal: controller.signal },
460
+ );
461
+
462
+ // cancel the operation
463
+ controller.abort();
464
+
465
+ // all cancelled operations will receive an error with CANCEL_CODE
466
+ const result = await rpcResult;
467
+ if (!result.ok && result.payload.code === 'CANCEL_CODE') {
468
+ console.log('Operation was cancelled');
469
+ }
470
+ ```
471
+
472
+ When a client cancels an operation, the server handler receives the cancellation via the `ctx.signal`:
473
+
474
+ ```ts
475
+ const ExampleService = ServiceSchema.define({
476
+ longRunning: Procedure.rpc({
477
+ requestInit: Type.Object({}),
478
+ responseData: Type.Object({ result: Type.String() }),
479
+ async handler({ ctx }) {
480
+ ctx.signal.addEventListener('abort', () => {
481
+ // do something
482
+ });
483
+
484
+ // long running operation
485
+ await new Promise((resolve) => setTimeout(resolve, 10000));
486
+ return Ok({ result: 'completed' });
487
+ },
488
+ }),
489
+
490
+ streamingExample: Procedure.stream({
491
+ requestInit: Type.Object({}),
492
+ requestData: Type.Object({ message: Type.String() }),
493
+ responseData: Type.Object({ echo: Type.String() }),
494
+ async handler({ ctx, reqReadable, resWritable }) {
495
+ // for streams, cancellation closes both readable and writable
496
+ // in addition to triggering the abort signal.
497
+ for await (const msg of reqReadable) {
498
+ if (!msg.ok) {
499
+ // msg.payload.code === CANCEL_CODE error if client cancelled
500
+ break;
501
+ }
502
+
503
+ resWritable.write(Ok({ echo: msg.payload.message }));
504
+ }
505
+
506
+ resWritable.close();
507
+ },
508
+ }),
509
+ });
510
+ ```
511
+
512
+ Worth noting that the `ctx.signal` is triggered regardless of the reason the procedure has ended.
513
+
514
+ #### Codecs
515
+
516
+ River provides two built-in codecs:
517
+
518
+ - `NaiveJsonCodec`: Simple JSON serialization
519
+ - `BinaryCodec`: Efficient msgpack serialization (recommended for production)
520
+
521
+ ```ts
522
+ import { BinaryCodec, NaiveJsonCodec } from '@replit/river/codec';
523
+
524
+ // use binary codec for better performance
525
+ const transport = new WebSocketClientTransport(
526
+ async () => new WebSocket('ws://localhost:3000'),
527
+ 'my-client-id',
528
+ { codec: BinaryCodec },
529
+ );
530
+ ```
531
+
532
+ You can also create custom codecs for message serialization:
533
+
534
+ ```ts
535
+ import { Codec } from '@replit/river/codec';
536
+
537
+ class CustomCodec implements Codec {
538
+ toBuffer(obj: object): Uint8Array {
539
+ // custom serialization logic
540
+ }
541
+
542
+ fromBuffer(buf: Uint8Array): object {
543
+ // custom deserialization logic
544
+ }
545
+ }
546
+
547
+ // use with transports
548
+ const transport = new WebSocketClientTransport(
549
+ async () => new WebSocket('ws://localhost:3000'),
550
+ 'my-client-id',
551
+ { codec: new CustomCodec() },
552
+ );
553
+ ```
554
+
555
+ #### Custom Transports
556
+
557
+ You can implement custom transports by extending the base Transport classes:
558
+
559
+ ```ts
560
+ import { ClientTransport, ServerTransport } from '@replit/river/transport';
561
+ import { Connection } from '@replit/river/transport';
562
+
563
+ // custom connection implementation
564
+ class MyCustomConnection extends Connection {
565
+ private socket: MyCustomSocket;
566
+
567
+ constructor(socket: MyCustomSocket) {
568
+ super();
569
+ this.socket = socket;
570
+
571
+ this.socket.onMessage = (data: Uint8Array) => {
572
+ this.dataListener?.(data);
573
+ };
574
+
575
+ this.socket.onClose = () => {
576
+ this.closeListener?.();
577
+ };
578
+
579
+ this.socket.onError = (err: Error) => {
580
+ this.errorListener?.(err);
581
+ };
582
+ }
583
+
584
+ send(msg: Uint8Array): boolean {
585
+ return this.socket.send(msg);
586
+ }
587
+
588
+ close(): void {
589
+ this.socket.close();
590
+ }
591
+ }
592
+
593
+ // custom client transport
594
+ class MyCustomClientTransport extends ClientTransport<MyCustomConnection> {
595
+ constructor(
596
+ private connectFn: () => Promise<MyCustomSocket>,
597
+ clientId: string,
598
+ ) {
599
+ super(clientId);
600
+ }
601
+
602
+ async createNewOutgoingConnection(): Promise<MyCustomConnection> {
603
+ const socket = await this.connectFn();
604
+ return new MyCustomConnection(socket);
605
+ }
606
+ }
607
+
608
+ // custom server transport
609
+ class MyCustomServerTransport extends ServerTransport<MyCustomConnection> {
610
+ constructor(
611
+ private server: MyCustomServer,
612
+ clientId: string,
613
+ ) {
614
+ super(clientId);
615
+
616
+ server.onConnection = (socket: MyCustomSocket) => {
617
+ const connection = new MyCustomConnection(socket);
618
+ this.handleConnection(connection);
619
+ };
620
+ }
621
+ }
622
+
623
+ // usage
624
+ const clientTransport = new MyCustomClientTransport(
625
+ () => connectToMyCustomServer(),
626
+ 'client-id',
627
+ );
628
+
629
+ const client = createClient<ServiceSurface>(clientTransport, 'SERVER');
630
+ ```
631
+
632
+ #### Testing
633
+
634
+ River provides utilities for testing your services:
635
+
636
+ ```ts
637
+ import { createMockTransportNetwork } from '@replit/river/testUtil';
638
+
639
+ describe('My Service', () => {
640
+ // create mock transport network
641
+ const { getClientTransport, getServerTransport, cleanup } =
642
+ createMockTransportNetwork();
643
+ afterEach(cleanup);
644
+
645
+ test('should add numbers correctly', async () => {
646
+ // setup server
647
+ const serverTransport = getServerTransport('SERVER');
648
+ const services = {
649
+ math: MathService,
650
+ };
651
+ const server = createServer(serverTransport, services);
652
+
653
+ // setup client
654
+ const clientTransport = getClientTransport('client');
655
+ const client = createClient<typeof services>(clientTransport, 'SERVER');
656
+
657
+ // test the service
658
+ const result = await client.math.add.rpc({ a: 1, b: 2 });
659
+ expect(result.ok).toBe(true);
660
+ if (result.ok) {
661
+ expect(result.payload.result).toBe(3);
662
+ }
663
+ });
664
+ });
665
+ ```
666
+
667
+ #### Custom Handshake
212
668
 
213
669
  River allows you to extend the protocol-level handshake so you can add additional logic to
214
670
  validate incoming connections.
@@ -216,32 +672,33 @@ validate incoming connections.
216
672
  You can do this by passing extra options to `createClient` and `createServer` and extending the `ParsedMetadata` interface:
217
673
 
218
674
  ```ts
219
- declare module '@replit/river' {
220
- interface ParsedMetadata {
221
- userId: number;
222
- }
223
- }
675
+ type ContextType = { ... }; // has to extend object
676
+ type ParsedMetadata = { parsedToken: string };
677
+ const ServiceSchema = createServiceSchema<ContextType, ParsedMetadata>();
678
+
679
+ const services = { ... }; // use custom ServiceSchema builder here
224
680
 
225
- const schema = Type.Object({ token: Type.String() });
681
+ const handshakeSchema = Type.Object({ token: Type.String() });
226
682
  createClient<typeof services>(new MockClientTransport('client'), 'SERVER', {
227
683
  eagerlyConnect: false,
228
- handshakeOptions: createClientHandshakeOptions(schema, async () => ({
684
+ handshakeOptions: createClientHandshakeOptions(handshakeSchema, async () => ({
229
685
  // the type of this function is
230
- // () => Static<typeof schema> | Promise<Static<typeof schema>>
686
+ // () => Static<typeof handshakeSchema> | Promise<Static<typeof handshakeSchema>>
231
687
  token: '123',
232
688
  })),
233
689
  });
234
690
 
235
691
  createServer(new MockServerTransport('SERVER'), services, {
236
692
  handshakeOptions: createServerHandshakeOptions(
237
- schema,
693
+ handshakeSchema,
238
694
  (metadata, previousMetadata) => {
239
695
  // the type of this function is
240
- // (metadata: Static<typeof<schema>, previousMetadata?: ParsedMetadata) =>
696
+ // (metadata: Static<typeof<handshakeSchema>, previousMetadata?: ParsedMetadata) =>
241
697
  // | false | Promise<false> (if you reject it)
242
698
  // | ParsedMetadata | Promise<ParsedMetadata> (if you allow it)
243
699
  // next time a connection happens on the same session, previousMetadata will
244
700
  // be populated with the last returned value
701
+ return { parsedToken: metadata.token };
245
702
  },
246
703
  ),
247
704
  });
@@ -1997,7 +1997,7 @@ function createServerHandshakeOptions(schema, validate) {
1997
1997
  }
1998
1998
 
1999
1999
  // package.json
2000
- var version = "0.209.7";
2000
+ var version = "0.210.0";
2001
2001
 
2002
2002
  export {
2003
2003
  generateId,
@@ -2038,4 +2038,4 @@ export {
2038
2038
  createConnectionTelemetryInfo,
2039
2039
  getTracer
2040
2040
  };
2041
- //# sourceMappingURL=chunk-MZP5A6YH.js.map
2041
+ //# sourceMappingURL=chunk-23NPMSFH.js.map