@replit/river 0.1.9 → 0.2.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.
@@ -6,17 +6,19 @@ import { createServer } from '../router/server';
6
6
  import { Transport } from '../transport/types';
7
7
  import { NaiveJsonCodec } from '../codec/json';
8
8
  import { createClient } from '../router/client';
9
+ const input = Type.Object({ a: Type.Number() });
10
+ const output = Type.Object({ b: Type.Number() });
9
11
  const fnBody = {
10
12
  type: 'rpc',
11
- input: Type.Object({ a: Type.Number() }),
12
- output: Type.Object({ b: Type.Number() }),
13
+ input,
14
+ output,
13
15
  async handler(_state, msg) {
14
16
  return reply(msg, { b: msg.payload.a });
15
17
  },
16
18
  };
17
19
  // typescript is limited to max 50 constraints
18
20
  // see: https://github.com/microsoft/TypeScript/issues/33541
19
- const svc = () => ServiceBuilder.create('test')
21
+ export const StupidlyLargeService = () => ServiceBuilder.create('test')
20
22
  .defineProcedure('f1', fnBody)
21
23
  .defineProcedure('f2', fnBody)
22
24
  .defineProcedure('f3', fnBody)
@@ -80,14 +82,14 @@ export class MockTransport extends Transport {
80
82
  }
81
83
  describe("ensure typescript doesn't give up trying to infer the types for large services", () => {
82
84
  test('service with many procedures hits typescript limit', () => {
83
- expect(serializeService(svc())).toBeTruthy();
85
+ expect(serializeService(StupidlyLargeService())).toBeTruthy();
84
86
  });
85
87
  test('serverclient should support many services with many procedures', async () => {
86
88
  const listing = {
87
- a: svc(),
88
- b: svc(),
89
- c: svc(),
90
- d: svc(),
89
+ a: StupidlyLargeService(),
90
+ b: StupidlyLargeService(),
91
+ c: StupidlyLargeService(),
92
+ d: StupidlyLargeService(),
91
93
  };
92
94
  const server = await createServer(new MockTransport('SERVER'), listing);
93
95
  const client = createClient(new MockTransport('client'));
package/dist/index.d.ts CHANGED
@@ -11,4 +11,4 @@ export { TransportMessageSchema, OpaqueTransportMessageSchema, TransportAckSchem
11
11
  export type { TransportMessage, MessageId, OpaqueTransportMessage, TransportClientId, TransportMessageAck, } from './transport/message';
12
12
  export { StreamTransport } from './transport/stream';
13
13
  export { WebSocketTransport } from './transport/ws';
14
- export { createWebSocketServer, onServerReady, createWsTransports, waitForMessage, waitForSocketReady, createWebSocketClient, } from './transport/util';
14
+ export { createWebSocketServer, onServerReady, createWsTransports, waitForMessage, createLocalWebSocketClient, } from './transport/util';
package/dist/index.js CHANGED
@@ -6,4 +6,4 @@ export { Transport } from './transport/types';
6
6
  export { TransportMessageSchema, OpaqueTransportMessageSchema, TransportAckSchema, msg, payloadToTransportMessage, ack, reply, } from './transport/message';
7
7
  export { StreamTransport } from './transport/stream';
8
8
  export { WebSocketTransport } from './transport/ws';
9
- export { createWebSocketServer, onServerReady, createWsTransports, waitForMessage, waitForSocketReady, createWebSocketClient, } from './transport/util';
9
+ export { createWebSocketServer, onServerReady, createWsTransports, waitForMessage, createLocalWebSocketClient, } from './transport/util';
@@ -23,28 +23,28 @@ export const createClient = (transport) => _createRecursiveProxy(async (opts) =>
23
23
  const [input] = opts.args;
24
24
  if (input === undefined) {
25
25
  // stream case
26
- const i = pushable({ objectMode: true });
27
- const o = pushable({ objectMode: true });
28
- // i -> transport
26
+ const inputStream = pushable({ objectMode: true });
27
+ const outputStream = pushable({ objectMode: true });
28
+ // input -> transport
29
29
  // this gets cleaned up on i.end() which is called by closeHandler
30
30
  (async () => {
31
- for await (const rawIn of i) {
31
+ for await (const rawIn of inputStream) {
32
32
  transport.send(msg(transport.clientId, 'SERVER', serviceName, procName, rawIn));
33
33
  }
34
34
  })();
35
- // transport -> o
35
+ // transport -> output
36
36
  const listener = (msg) => {
37
37
  if (msg.serviceName === serviceName && msg.procedureName === procName) {
38
- o.push(msg.payload);
38
+ outputStream.push(msg.payload);
39
39
  }
40
40
  };
41
41
  transport.addMessageListener(listener);
42
42
  const closeHandler = () => {
43
- i.end();
44
- o.end();
43
+ inputStream.end();
44
+ outputStream.end();
45
45
  transport.removeMessageListener(listener);
46
46
  };
47
- return [i, o, closeHandler];
47
+ return [inputStream, outputStream, closeHandler];
48
48
  }
49
49
  else {
50
50
  // rpc case
@@ -30,6 +30,7 @@ export type MessageId = string;
30
30
  export type OpaqueTransportMessage = TransportMessage<unknown>;
31
31
  export type TransportClientId = 'SERVER' | string;
32
32
  export declare const TransportAckSchema: import("@sinclair/typebox").TObject<{
33
+ id: import("@sinclair/typebox").TString;
33
34
  from: import("@sinclair/typebox").TString;
34
35
  ack: import("@sinclair/typebox").TString;
35
36
  }>;
@@ -12,6 +12,7 @@ export const TransportMessageSchema = (t) => Type.Object({
12
12
  });
13
13
  export const OpaqueTransportMessageSchema = TransportMessageSchema(Type.Unknown());
14
14
  export const TransportAckSchema = Type.Object({
15
+ id: Type.String(),
15
16
  from: Type.String(),
16
17
  ack: Type.String(),
17
18
  });
@@ -30,6 +31,7 @@ export function payloadToTransportMessage(payload) {
30
31
  }
31
32
  export function ack(msg) {
32
33
  return {
34
+ id: nanoid(),
33
35
  from: msg.to,
34
36
  ack: msg.id,
35
37
  };
@@ -12,6 +12,7 @@ export class Transport {
12
12
  this.clientId = clientId;
13
13
  }
14
14
  onMessage(msg) {
15
+ // TODO: try catch from string buf
15
16
  const parsedMsg = this.codec.fromStringBuf(msg.toString());
16
17
  if (Value.Check(TransportAckSchema, parsedMsg)) {
17
18
  // process ack
@@ -30,6 +31,9 @@ export class Transport {
30
31
  }
31
32
  this.send(ack(parsedMsg));
32
33
  }
34
+ else {
35
+ // TODO: warn on malformed
36
+ }
33
37
  }
34
38
  addMessageListener(handler) {
35
39
  this.handlers.add(handler);
@@ -3,10 +3,10 @@ import http from 'http';
3
3
  import WebSocket from 'isomorphic-ws';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { Transport } from './types';
6
+ import { WebSocketTransport } from './ws';
6
7
  import { OpaqueTransportMessage } from './message';
7
8
  export declare function createWebSocketServer(server: http.Server): Promise<WebSocket.Server<typeof WebSocket, typeof http.IncomingMessage>>;
8
- export declare function onServerReady(server: http.Server, port: number): Promise<void>;
9
- export declare function createWsTransports(port: number, wss: WebSocketServer): Promise<[Transport, Transport]>;
10
- export declare function waitForSocketReady(socket: WebSocket): Promise<void>;
11
- export declare function createWebSocketClient(port: number): Promise<WebSocket>;
9
+ export declare function onServerReady(server: http.Server): Promise<number>;
10
+ export declare function createLocalWebSocketClient(port: number): Promise<WebSocket>;
11
+ export declare function createWsTransports(port: number, wss: WebSocketServer): [WebSocketTransport, WebSocketTransport];
12
12
  export declare function waitForMessage(t: Transport, filter?: (msg: OpaqueTransportMessage) => boolean): Promise<unknown>;
@@ -4,31 +4,36 @@ import { WebSocketTransport } from './ws';
4
4
  export async function createWebSocketServer(server) {
5
5
  return new WebSocketServer({ server });
6
6
  }
7
- export async function onServerReady(server, port) {
8
- return new Promise((resolve) => {
9
- server.listen(port, resolve);
10
- });
11
- }
12
- export async function createWsTransports(port, wss) {
13
- return new Promise((resolve) => {
14
- const clientSockPromise = createWebSocketClient(port);
15
- wss.on('connection', async (serverSock) => {
16
- resolve([
17
- new WebSocketTransport(await clientSockPromise, 'client'),
18
- new WebSocketTransport(serverSock, 'SERVER'),
19
- ]);
7
+ export async function onServerReady(server) {
8
+ return new Promise((resolve, reject) => {
9
+ server.listen(() => {
10
+ const addr = server.address();
11
+ if (typeof addr === 'object' && addr) {
12
+ resolve(addr.port);
13
+ }
14
+ else {
15
+ reject(new Error("couldn't find a port to allocate"));
16
+ }
20
17
  });
21
18
  });
22
19
  }
23
- export async function waitForSocketReady(socket) {
24
- return new Promise((resolve) => {
25
- socket.addEventListener('open', () => resolve());
26
- });
20
+ export async function createLocalWebSocketClient(port) {
21
+ return new WebSocket(`ws://localhost:${port}`);
27
22
  }
28
- export async function createWebSocketClient(port) {
29
- const client = new WebSocket(`ws://localhost:${port}`);
30
- await waitForSocketReady(client);
31
- return client;
23
+ export function createWsTransports(port, wss) {
24
+ return [
25
+ new WebSocketTransport(async () => {
26
+ return createLocalWebSocketClient(port);
27
+ }, 'client'),
28
+ new WebSocketTransport(async () => {
29
+ return new Promise((resolve) => {
30
+ wss.on('connection', async function onConnect(serverSock) {
31
+ wss.removeListener('connection', onConnect);
32
+ resolve(serverSock);
33
+ });
34
+ });
35
+ }, 'SERVER'),
36
+ ];
32
37
  }
33
38
  export async function waitForMessage(t, filter) {
34
39
  return new Promise((resolve, _reject) => {
@@ -1,10 +1,25 @@
1
1
  /// <reference types="ws" />
2
- import type WebSocket from 'isomorphic-ws';
2
+ import WebSocket from 'isomorphic-ws';
3
3
  import { Transport } from './types';
4
4
  import { MessageId, OpaqueTransportMessage, TransportClientId } from './message';
5
- export declare class WebSocketTransport extends Transport {
5
+ interface Options {
6
+ retryIntervalMs: number;
7
+ }
8
+ type WebSocketResult = {
6
9
  ws: WebSocket;
7
- constructor(ws: WebSocket, clientId: TransportClientId);
10
+ } | {
11
+ err: string;
12
+ };
13
+ export declare class WebSocketTransport extends Transport {
14
+ wsGetter: () => Promise<WebSocket>;
15
+ ws?: WebSocket;
16
+ destroyed: boolean;
17
+ reconnectPromise?: Promise<WebSocketResult>;
18
+ options: Options;
19
+ sendQueue: Array<MessageId>;
20
+ constructor(wsGetter: () => Promise<WebSocket>, clientId: TransportClientId, options?: Partial<Options>);
21
+ private tryConnect;
8
22
  send(msg: OpaqueTransportMessage): MessageId;
9
- close(): Promise<void>;
23
+ close(): Promise<void | undefined>;
10
24
  }
25
+ export {};
@@ -1,23 +1,88 @@
1
1
  import { Transport } from './types';
2
2
  import { NaiveJsonCodec } from '../codec/json';
3
- // TODO should answer:
4
- // - how do we handle graceful client disconnects? (i.e. close tab)
5
- // - how do we handle graceful service disconnects (i.e. a fuck off message)?
6
- // - how do we handle forceful client disconnects? (i.e. broken connection, offline)
7
- // - how do we handle forceful service disconnects (i.e. a crash)?
3
+ const defaultOptions = {
4
+ retryIntervalMs: 250,
5
+ };
8
6
  export class WebSocketTransport extends Transport {
7
+ wsGetter;
9
8
  ws;
10
- constructor(ws, clientId) {
9
+ destroyed;
10
+ reconnectPromise;
11
+ options;
12
+ sendQueue;
13
+ constructor(wsGetter, clientId, options) {
11
14
  super(NaiveJsonCodec, clientId);
12
- this.ws = ws;
13
- ws.on('message', (msg) => this.onMessage(msg.toString()));
15
+ this.destroyed = false;
16
+ this.wsGetter = wsGetter;
17
+ this.options = { ...defaultOptions, ...options };
18
+ this.sendQueue = [];
19
+ this.tryConnect();
20
+ }
21
+ // postcondition: ws is concretely a WebSocket
22
+ async tryConnect() {
23
+ // wait until it's ready or we get an error
24
+ this.reconnectPromise ??= new Promise(async (resolve) => {
25
+ const ws = await this.wsGetter();
26
+ if (ws.readyState === ws.OPEN) {
27
+ return resolve({ ws });
28
+ }
29
+ if (ws.readyState === ws.CLOSING || ws.readyState === ws.CLOSED) {
30
+ return resolve({ err: 'ws is closing or closed' });
31
+ }
32
+ ws.addEventListener('open', function onOpen() {
33
+ ws.removeEventListener('open', onOpen);
34
+ resolve({ ws });
35
+ });
36
+ ws.addEventListener('error', function onError(err) {
37
+ ws.removeEventListener('error', onError);
38
+ resolve({ err: err.message });
39
+ });
40
+ ws.addEventListener('close', function onClose(evt) {
41
+ ws.removeEventListener('close', onClose);
42
+ resolve({ err: evt.reason });
43
+ });
44
+ });
45
+ const res = await this.reconnectPromise;
46
+ // only send if we resolved a valid websocket
47
+ if ('ws' in res && res.ws.readyState === res.ws.OPEN) {
48
+ this.ws = res.ws;
49
+ this.ws.onmessage = (msg) => this.onMessage(msg.data.toString());
50
+ this.ws.onclose = () => {
51
+ this.reconnectPromise = undefined;
52
+ this.tryConnect().catch();
53
+ };
54
+ // send outstanding
55
+ for (const id of this.sendQueue) {
56
+ const msg = this.sendBuffer.get(id);
57
+ if (!msg) {
58
+ throw new Error('tried to resend a message we received an ack for');
59
+ }
60
+ this.ws.send(this.codec.toStringBuf(msg));
61
+ }
62
+ this.sendQueue = [];
63
+ return;
64
+ }
65
+ // otherwise try and reconnect again
66
+ this.reconnectPromise = undefined;
67
+ setTimeout(() => this.tryConnect(), this.options.retryIntervalMs);
14
68
  }
15
69
  send(msg) {
16
70
  const id = msg.id;
17
- this.ws.send(this.codec.toStringBuf(msg));
71
+ if (this.destroyed) {
72
+ throw new Error('ws is destroyed, cant send');
73
+ }
74
+ this.sendBuffer.set(id, msg);
75
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
76
+ this.ws.send(this.codec.toStringBuf(msg));
77
+ }
78
+ else {
79
+ this.sendQueue.push(id);
80
+ this.tryConnect().catch();
81
+ }
18
82
  return id;
19
83
  }
20
84
  async close() {
21
- return this.ws.close();
85
+ this.destroyed = true;
86
+ return this.ws?.close();
22
87
  }
23
88
  }
@@ -1,15 +1,22 @@
1
1
  import http from 'http';
2
- import { WebSocketTransport } from './ws';
3
- import { describe, test, expect, beforeAll, afterAll } from 'vitest';
4
- import { createWebSocketClient, createWebSocketServer, onServerReady, waitForMessage, } from './util';
5
- const port = 3000;
6
- describe('sending and receiving across websockets works', () => {
2
+ import { describe, test, expect, afterAll } from 'vitest';
3
+ import { createWebSocketServer, createWsTransports, onServerReady, waitForMessage, } from './util';
4
+ import { nanoid } from 'nanoid';
5
+ const getMsg = () => ({
6
+ id: nanoid(),
7
+ from: 'client',
8
+ to: 'SERVER',
9
+ serviceName: 'test',
10
+ procedureName: 'test',
11
+ payload: {
12
+ msg: 'cool',
13
+ test: Math.random(),
14
+ },
15
+ });
16
+ describe('sending and receiving across websockets works', async () => {
7
17
  const server = http.createServer();
8
- let wss;
9
- beforeAll(async () => {
10
- await onServerReady(server, port);
11
- wss = await createWebSocketServer(server);
12
- });
18
+ const port = await onServerReady(server);
19
+ const wss = await createWebSocketServer(server);
13
20
  afterAll(() => {
14
21
  wss.clients.forEach((socket) => {
15
22
  socket.close();
@@ -17,25 +24,52 @@ describe('sending and receiving across websockets works', () => {
17
24
  server.close();
18
25
  });
19
26
  test('basic send/receive', async () => {
20
- let serverTransport;
21
- wss.on('connection', (conn) => {
22
- serverTransport = new WebSocketTransport(conn, 'SERVER');
23
- });
24
- const clientSoc = await createWebSocketClient(port);
25
- const clientTransport = new WebSocketTransport(clientSoc, 'client');
26
- const msg = {
27
- msg: 'cool',
28
- test: 123,
29
- };
30
- clientTransport.send({
31
- id: '1',
32
- from: 'client',
33
- to: 'SERVER',
34
- serviceName: 'test',
35
- procedureName: 'test',
36
- payload: msg,
27
+ const [clientTransport, serverTransport] = createWsTransports(port, wss);
28
+ const msg = getMsg();
29
+ clientTransport.send(msg);
30
+ return expect(waitForMessage(serverTransport, (recv) => recv.id === msg.id)).resolves.toStrictEqual(msg.payload);
31
+ });
32
+ });
33
+ describe('retry logic', async () => {
34
+ const server = http.createServer();
35
+ const port = await onServerReady(server);
36
+ const wss = await createWebSocketServer(server);
37
+ afterAll(() => {
38
+ wss.clients.forEach((socket) => {
39
+ socket.close();
37
40
  });
38
- expect(serverTransport).toBeTruthy();
39
- return expect(waitForMessage(serverTransport)).resolves.toStrictEqual(msg);
41
+ server.close();
42
+ });
43
+ // TODO: right now, we only test client-side disconnects, we probably
44
+ // need to also write tests for server-side crashes (but this involves clearing/restoring state)
45
+ // not going to worry about this rn but for future
46
+ test('ws transport is recreated after clean disconnect', async () => {
47
+ const [clientTransport, serverTransport] = createWsTransports(port, wss);
48
+ const msg1 = getMsg();
49
+ const msg2 = getMsg();
50
+ clientTransport.send(msg1);
51
+ await expect(waitForMessage(serverTransport, (recv) => recv.id === msg1.id)).resolves.toStrictEqual(msg1.payload);
52
+ clientTransport.ws?.close();
53
+ clientTransport.send(msg2);
54
+ return expect(waitForMessage(serverTransport, (recv) => recv.id === msg2.id)).resolves.toStrictEqual(msg2.payload);
55
+ });
56
+ test('ws transport is recreated after unclean disconnect', async () => {
57
+ const [clientTransport, serverTransport] = createWsTransports(port, wss);
58
+ const msg1 = getMsg();
59
+ const msg2 = getMsg();
60
+ clientTransport.send(msg1);
61
+ await expect(waitForMessage(serverTransport, (recv) => recv.id === msg1.id)).resolves.toStrictEqual(msg1.payload);
62
+ clientTransport.ws?.terminate();
63
+ clientTransport.send(msg2);
64
+ return expect(waitForMessage(serverTransport, (recv) => recv.id === msg2.id)).resolves.toStrictEqual(msg2.payload);
65
+ });
66
+ test('ws transport is not recreated after manually closing', async () => {
67
+ const [clientTransport, serverTransport] = createWsTransports(port, wss);
68
+ const msg1 = getMsg();
69
+ const msg2 = getMsg();
70
+ clientTransport.send(msg1);
71
+ await expect(waitForMessage(serverTransport, (recv) => recv.id === msg1.id)).resolves.toStrictEqual(msg1.payload);
72
+ clientTransport.close();
73
+ return expect(() => clientTransport.send(msg2)).toThrow(new Error('ws is destroyed, cant send'));
40
74
  });
41
75
  });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@replit/river",
3
+ "sideEffects": false,
3
4
  "description": "It's like tRPC but... with JSON Schema Support, duplex streaming and support for service multiplexing. Transport agnostic!",
4
- "version": "0.1.9",
5
+ "version": "0.2.0",
5
6
  "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
7
+ "main": "index.js",
8
+ "types": "index.d.ts",
8
9
  "files": [
9
10
  "dist"
10
11
  ],
@@ -28,7 +29,8 @@
28
29
  "build": "tsc",
29
30
  "prepack": "npm run build",
30
31
  "release": "npm publish --access public",
31
- "test": "vitest"
32
+ "test": "vitest",
33
+ "bench": "vitest bench"
32
34
  },
33
35
  "engines": {
34
36
  "node": ">=16"