@replit/river 0.9.3 → 0.10.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.
Files changed (54) hide show
  1. package/dist/__tests__/bandwidth.bench.js +11 -11
  2. package/dist/__tests__/cleanup.test.d.ts +2 -0
  3. package/dist/__tests__/cleanup.test.d.ts.map +1 -0
  4. package/dist/__tests__/{invariants.test.js → cleanup.test.js} +47 -20
  5. package/dist/__tests__/disconnects.test.d.ts +2 -0
  6. package/dist/__tests__/disconnects.test.d.ts.map +1 -0
  7. package/dist/__tests__/disconnects.test.js +163 -0
  8. package/dist/__tests__/e2e.test.js +33 -32
  9. package/dist/__tests__/fixtures/cleanup.d.ts +2 -2
  10. package/dist/__tests__/fixtures/cleanup.d.ts.map +1 -1
  11. package/dist/__tests__/fixtures/cleanup.js +6 -9
  12. package/dist/__tests__/fixtures/services.d.ts +36 -36
  13. package/dist/__tests__/fixtures/services.d.ts.map +1 -1
  14. package/dist/__tests__/fixtures/services.js +36 -53
  15. package/dist/__tests__/handler.test.js +6 -7
  16. package/dist/__tests__/typescript-stress.test.d.ts +149 -149
  17. package/dist/__tests__/typescript-stress.test.d.ts.map +1 -1
  18. package/dist/__tests__/typescript-stress.test.js +14 -14
  19. package/dist/router/builder.d.ts +6 -7
  20. package/dist/router/builder.d.ts.map +1 -1
  21. package/dist/router/client.d.ts +7 -3
  22. package/dist/router/client.d.ts.map +1 -1
  23. package/dist/router/client.js +204 -106
  24. package/dist/router/defs.d.ts +16 -0
  25. package/dist/router/defs.d.ts.map +1 -0
  26. package/dist/router/defs.js +11 -0
  27. package/dist/router/index.d.ts +2 -0
  28. package/dist/router/index.d.ts.map +1 -1
  29. package/dist/router/index.js +1 -0
  30. package/dist/router/result.d.ts +2 -1
  31. package/dist/router/result.d.ts.map +1 -1
  32. package/dist/router/result.js +5 -1
  33. package/dist/router/server.d.ts +5 -5
  34. package/dist/router/server.d.ts.map +1 -1
  35. package/dist/router/server.js +125 -82
  36. package/dist/transport/impls/stdio/stdio.test.js +1 -2
  37. package/dist/transport/impls/ws/client.d.ts +1 -4
  38. package/dist/transport/impls/ws/client.d.ts.map +1 -1
  39. package/dist/transport/impls/ws/client.js +5 -6
  40. package/dist/transport/impls/ws/server.d.ts +3 -0
  41. package/dist/transport/impls/ws/server.d.ts.map +1 -1
  42. package/dist/transport/impls/ws/server.js +28 -23
  43. package/dist/transport/impls/ws/ws.test.js +84 -16
  44. package/dist/transport/index.d.ts +0 -9
  45. package/dist/transport/index.d.ts.map +1 -1
  46. package/dist/transport/index.js +0 -21
  47. package/dist/transport/message.d.ts +3 -4
  48. package/dist/transport/message.d.ts.map +1 -1
  49. package/dist/util/testHelpers.d.ts +20 -97
  50. package/dist/util/testHelpers.d.ts.map +1 -1
  51. package/dist/util/testHelpers.js +94 -249
  52. package/package.json +13 -12
  53. package/dist/__tests__/invariants.test.d.ts +0 -2
  54. package/dist/__tests__/invariants.test.d.ts.map +0 -1
@@ -1,10 +1,10 @@
1
1
  import { Static } from '@sinclair/typebox';
2
2
  import { Connection, Transport } from '../transport/transport';
3
- import { AnyProcedure, AnyService, PayloadType } from './builder';
3
+ import { AnyProcedure, PayloadType } from './builder';
4
4
  import type { Pushable } from 'it-pushable';
5
- import { TransportMessage } from '../transport/message';
6
5
  import { ServiceContext } from './context';
7
6
  import { Result, RiverError } from './result';
7
+ import { ServiceDefs } from './defs';
8
8
  /**
9
9
  * Represents a server with a set of services. Use {@link createServer} to create it.
10
10
  * @template Services - The type of services provided by the server.
@@ -19,8 +19,8 @@ interface ProcStream {
19
19
  serviceName: string;
20
20
  procedureName: string;
21
21
  procedure: AnyProcedure;
22
- incoming: Pushable<TransportMessage>;
23
- outgoing: Pushable<TransportMessage<Result<Static<PayloadType>, Static<RiverError>>>>;
22
+ incoming: Pushable<PayloadType>;
23
+ outgoing: Pushable<Result<Static<PayloadType>, Static<RiverError>>>;
24
24
  promises: {
25
25
  outputHandler: Promise<unknown>;
26
26
  inputHandler: Promise<unknown>;
@@ -34,6 +34,6 @@ interface ProcStream {
34
34
  * @param extendedContext - An optional object containing additional context to be passed to all services.
35
35
  * @returns A promise that resolves to a server instance with the registered services.
36
36
  */
37
- export declare function createServer<Services extends Record<string, AnyService>>(transport: Transport<Connection>, services: Services, extendedContext?: Omit<ServiceContext, 'state'>): Promise<Server<Services>>;
37
+ export declare function createServer<Services extends ServiceDefs>(transport: Transport<Connection>, services: Services, extendedContext?: Omit<ServiceContext, 'state'>): Server<Services>;
38
38
  export {};
39
39
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../router/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAElE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAGL,gBAAgB,EAKjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,cAAc,EAA2B,MAAM,WAAW,CAAC;AAGpE,OAAO,EAEL,MAAM,EACN,UAAU,EAGX,MAAM,UAAU,CAAC;AAElB;;;GAGG;AACH,MAAM,WAAW,MAAM,CAAC,QAAQ;IAC9B,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACjC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,UAAU,UAAU;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,YAAY,CAAC;IACxB,QAAQ,EAAE,QAAQ,CAAC,gBAAgB,CAAC,CAAC;IACrC,QAAQ,EAAE,QAAQ,CAChB,gBAAgB,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAClE,CAAC;IACF,QAAQ,EAAE;QACR,aAAa,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;QAChC,YAAY,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;KAChC,CAAC;CACH;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,EAC5E,SAAS,EAAE,SAAS,CAAC,UAAU,CAAC,EAChC,QAAQ,EAAE,QAAQ,EAClB,eAAe,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,GAC9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAqQ3B"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../router/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAc,WAAW,EAAE,MAAM,WAAW,CAAC;AAElE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAU5C,OAAO,EAAE,cAAc,EAA2B,MAAM,WAAW,CAAC;AAGpE,OAAO,EAEL,MAAM,EACN,UAAU,EAGX,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAErC;;;GAGG;AACH,MAAM,WAAW,MAAM,CAAC,QAAQ;IAC9B,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACjC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,UAAU,UAAU;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,YAAY,CAAC;IACxB,QAAQ,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAChC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IACpE,QAAQ,EAAE;QACR,aAAa,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;QAChC,YAAY,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;KAChC,CAAC;CACH;AAsUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,SAAS,WAAW,EACvD,SAAS,EAAE,SAAS,CAAC,UAAU,CAAC,EAChC,QAAQ,EAAE,QAAQ,EAClB,eAAe,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,GAC9C,MAAM,CAAC,QAAQ,CAAC,CAElB"}
@@ -3,104 +3,109 @@ import { ControlMessagePayloadSchema, isStreamClose, isStreamOpen, reply, closeS
3
3
  import { log } from '../logging';
4
4
  import { Value } from '@sinclair/typebox/value';
5
5
  import { Err, UNCAUGHT_ERROR, } from './result';
6
- /**
7
- * Creates a server instance that listens for incoming messages from a transport and routes them to the appropriate service and procedure.
8
- * The server tracks the state of each service along with open streams and the extended context object.
9
- * @param transport - The transport to listen to.
10
- * @param services - An object containing all the services to be registered on the server.
11
- * @param extendedContext - An optional object containing additional context to be passed to all services.
12
- * @returns A promise that resolves to a server instance with the registered services.
13
- */
14
- export async function createServer(transport, services, extendedContext) {
15
- const contextMap = new Map();
6
+ class RiverServer {
7
+ transport;
8
+ services;
9
+ contextMap;
16
10
  // map of streamId to ProcStream
17
- const streamMap = new Map();
18
- async function cleanupStream(id) {
19
- const stream = streamMap.get(id);
20
- if (!stream) {
21
- return;
11
+ streamMap;
12
+ // map of client to their open streams by streamId
13
+ clientStreams;
14
+ constructor(transport, services, extendedContext) {
15
+ this.transport = transport;
16
+ this.services = services;
17
+ this.contextMap = new Map();
18
+ for (const service of Object.values(services)) {
19
+ this.contextMap.set(service, {
20
+ ...extendedContext,
21
+ state: service.state,
22
+ });
22
23
  }
23
- stream.incoming.end();
24
- await stream.promises.inputHandler;
25
- stream.outgoing.end();
26
- await stream.promises.outputHandler;
27
- streamMap.delete(id);
24
+ this.streamMap = new Map();
25
+ this.clientStreams = new Map();
26
+ this.transport.addEventListener('message', this.handler);
27
+ this.transport.addEventListener('connectionStatus', this.onDisconnect);
28
28
  }
29
- function getContext(service) {
30
- const context = contextMap.get(service);
31
- if (!context) {
32
- const err = `${transport.clientId} -- no context found for ${service.name}`;
33
- log?.error(err);
34
- throw new Error(err);
35
- }
36
- return context;
37
- }
38
- // populate the context map
39
- for (const service of Object.values(services)) {
40
- contextMap.set(service, { ...extendedContext, state: service.state });
29
+ get streams() {
30
+ return this.streamMap;
41
31
  }
42
- const handler = async (message) => {
43
- if (message.to !== transport.clientId) {
44
- log?.info(`${transport.clientId} -- got msg with destination that isn't the server, ignoring`);
32
+ handler = async (message) => {
33
+ if (message.to !== this.transport.clientId) {
34
+ log?.info(`${this.transport.clientId} -- got msg with destination that isn't the server, ignoring`);
45
35
  return;
46
36
  }
47
- const streamIdx = message.streamId;
48
- const procStream = streamMap.get(streamIdx);
49
- if (procStream) {
50
- // If the stream is a continuation, we do not admit the init messages.
51
- if (Value.Check(procStream.procedure.input, message.payload)) {
52
- procStream.incoming.push(message);
53
- }
54
- else if (!Value.Check(ControlMessagePayloadSchema, message.payload)) {
55
- log?.error(`${transport.clientId} -- procedure ${procStream.serviceName}.${procStream.procedureName} received invalid payload: ${JSON.stringify(message.payload)}`);
56
- }
57
- if (isStreamClose(message.controlFlags)) {
58
- await cleanupStream(streamIdx);
59
- }
37
+ let procStream = this.streamMap.get(message.streamId);
38
+ const isInitMessage = !procStream;
39
+ // create a proc stream if it doesnt exist
40
+ procStream ||= this.createNewProcStream(message);
41
+ if (!procStream) {
42
+ // if we fail to create a proc stream, just abort
60
43
  return;
61
44
  }
45
+ await this.pushToStream(procStream, message, isInitMessage);
46
+ };
47
+ // cleanup streams on unexpected disconnections
48
+ onDisconnect = async (evt) => {
49
+ if (evt.status !== 'disconnect') {
50
+ return;
51
+ }
52
+ const disconnectedClientId = evt.conn.connectedTo;
53
+ log?.info(`${this.transport.clientId} -- got unexpected disconnect from ${disconnectedClientId}, cleaning up streams`);
54
+ const streamsFromThisClient = this.clientStreams.get(disconnectedClientId);
55
+ if (!streamsFromThisClient) {
56
+ return;
57
+ }
58
+ await Promise.all(Array.from(streamsFromThisClient).map(this.cleanupStream));
59
+ this.clientStreams.delete(disconnectedClientId);
60
+ };
61
+ async close() {
62
+ this.transport.removeEventListener('message', this.handler);
63
+ this.transport.removeEventListener('connectionStatus', this.onDisconnect);
64
+ await Promise.all([...this.streamMap.keys()].map(this.cleanupStream));
65
+ }
66
+ createNewProcStream(message) {
62
67
  if (!isStreamOpen(message.controlFlags)) {
63
- log?.warn(`${transport.clientId} -- couldn't find a matching procedure stream for ${message.serviceName}.${message.procedureName}:${message.streamId}`);
68
+ log?.warn(`${this.transport.clientId} -- couldn't find a matching procedure stream for ${message.serviceName}.${message.procedureName}:${message.streamId}`);
64
69
  return;
65
70
  }
66
- if (!message.serviceName || !(message.serviceName in services)) {
67
- log?.warn(`${transport.clientId} -- couldn't find service ${message.serviceName}`);
71
+ if (!message.serviceName || !(message.serviceName in this.services)) {
72
+ log?.warn(`${this.transport.clientId} -- couldn't find service ${message.serviceName}`);
68
73
  return;
69
74
  }
70
- const service = services[message.serviceName];
71
- const serviceContext = getContext(service);
75
+ const service = this.services[message.serviceName];
76
+ const serviceContext = this.getContext(service);
72
77
  if (!message.procedureName ||
73
78
  !(message.procedureName in service.procedures)) {
74
- log?.warn(`${transport.clientId} -- couldn't find a matching procedure for ${message.serviceName}.${message.procedureName}`);
79
+ log?.warn(`${this.transport.clientId} -- couldn't find a matching procedure for ${message.serviceName}.${message.procedureName}`);
75
80
  return;
76
81
  }
77
82
  const procedure = service.procedures[message.procedureName];
78
- const procHasInitMessage = 'init' in procedure;
79
83
  const incoming = pushable({ objectMode: true });
80
84
  const outgoing = pushable({ objectMode: true });
81
85
  const outputHandler =
82
86
  // sending outgoing messages back to client
83
87
  (async () => {
84
88
  for await (const response of outgoing) {
85
- transport.send(response);
89
+ this.transport.send(reply(message, response));
86
90
  }
87
91
  // we ended, send a close bit back to the client
88
92
  // only subscriptions and streams have streams the
89
93
  // handler can close
90
94
  if (procedure.type === 'subscription' || procedure.type === 'stream') {
91
- transport.send(closeStream(transport.clientId, message.from, message.streamId));
95
+ this.transport.send(closeStream(this.transport.clientId, message.from, message.streamId));
92
96
  }
93
97
  })();
94
- function errorHandler(err) {
98
+ const errorHandler = (err) => {
95
99
  const errorMsg = err instanceof Error ? err.message : `[coerced to error] ${err}`;
96
- log?.error(`${transport.clientId} -- procedure ${message.serviceName}.${message.procedureName}:${message.streamId} threw an error: ${errorMsg}`);
97
- outgoing.push(reply(message, Err({
100
+ log?.error(`${this.transport.clientId} -- procedure ${message.serviceName}.${message.procedureName}:${message.streamId} threw an error: ${errorMsg}`);
101
+ outgoing.push(Err({
98
102
  code: UNCAUGHT_ERROR,
99
103
  message: errorMsg,
100
- })));
101
- }
104
+ }));
105
+ };
102
106
  // pump incoming message stream -> handler -> outgoing message stream
103
107
  let inputHandler;
108
+ const procHasInitMessage = 'init' in procedure;
104
109
  if (procedure.type === 'stream') {
105
110
  if (procHasInitMessage) {
106
111
  inputHandler = (async () => {
@@ -179,10 +184,10 @@ export async function createServer(transport, services, extendedContext) {
179
184
  else {
180
185
  // procedure is inferred to be never here as this is not a valid procedure type
181
186
  // we cast just to log
182
- log?.warn(`${transport.clientId} -- got request for invalid procedure type ${procedure.type} at ${message.serviceName}.${message.procedureName}`);
187
+ log?.warn(`${this.transport.clientId} -- got request for invalid procedure type ${procedure.type} at ${message.serviceName}.${message.procedureName}`);
183
188
  return;
184
189
  }
185
- streamMap.set(streamIdx, {
190
+ const procStream = {
186
191
  id: message.streamId,
187
192
  incoming,
188
193
  outgoing,
@@ -190,28 +195,66 @@ export async function createServer(transport, services, extendedContext) {
190
195
  procedureName: message.procedureName,
191
196
  procedure,
192
197
  promises: { inputHandler, outputHandler },
193
- });
194
- // This is the first message, so we parse is as the initialization message, if supplied.
195
- if ((!procHasInitMessage && Value.Check(procedure.input, message.payload)) ||
196
- (procHasInitMessage && Value.Check(procedure.init, message.payload))) {
197
- incoming.push(message);
198
+ };
199
+ this.streamMap.set(message.streamId, procStream);
200
+ // add this stream to ones from that client so we can clean it up in the case of a disconnect without close
201
+ const streamsFromThisClient = this.clientStreams.get(message.from) ?? new Set();
202
+ streamsFromThisClient.add(message.streamId);
203
+ this.clientStreams.set(message.from, streamsFromThisClient);
204
+ return procStream;
205
+ }
206
+ async pushToStream(procStream, message, isInit) {
207
+ const procedure = procStream.procedure;
208
+ const procHasInitMessage = 'init' in procedure;
209
+ if ((isInit &&
210
+ procHasInitMessage &&
211
+ Value.Check(procedure.init, message.payload)) ||
212
+ Value.Check(procedure.input, message.payload)) {
213
+ procStream.incoming.push(message.payload);
198
214
  }
199
215
  else if (!Value.Check(ControlMessagePayloadSchema, message.payload)) {
200
- log?.error(`${transport.clientId} -- procedure ${message.serviceName}.${message.procedureName} received invalid payload: ${JSON.stringify(message.payload)}`);
216
+ log?.error(`${this.transport.clientId} -- procedure ${procStream.serviceName}.${procStream.procedureName} received invalid payload: ${JSON.stringify(message.payload)}`);
201
217
  }
202
218
  if (isStreamClose(message.controlFlags)) {
203
- await cleanupStream(streamIdx);
204
- }
205
- };
206
- transport.addEventListener('message', handler);
207
- return {
208
- services,
209
- streams: streamMap,
210
- async close() {
211
- transport.removeEventListener('message', handler);
212
- for (const streamIdx of streamMap.keys()) {
213
- await cleanupStream(streamIdx);
219
+ await this.cleanupStream(message.streamId);
220
+ const streamsFromThisClient = this.clientStreams.get(message.from);
221
+ if (streamsFromThisClient) {
222
+ streamsFromThisClient.delete(message.streamId);
223
+ if (streamsFromThisClient.size === 0) {
224
+ this.clientStreams.delete(message.from);
225
+ }
214
226
  }
215
- },
227
+ }
228
+ }
229
+ getContext(service) {
230
+ const context = this.contextMap.get(service);
231
+ if (!context) {
232
+ const err = `${this.transport.clientId} -- no context found for ${service.name}`;
233
+ log?.error(err);
234
+ throw new Error(err);
235
+ }
236
+ return context;
237
+ }
238
+ cleanupStream = async (id) => {
239
+ const stream = this.streamMap.get(id);
240
+ if (!stream) {
241
+ return;
242
+ }
243
+ stream.incoming.end();
244
+ await stream.promises.inputHandler;
245
+ stream.outgoing.end();
246
+ await stream.promises.outputHandler;
247
+ this.streamMap.delete(id);
216
248
  };
217
249
  }
250
+ /**
251
+ * Creates a server instance that listens for incoming messages from a transport and routes them to the appropriate service and procedure.
252
+ * The server tracks the state of each service along with open streams and the extended context object.
253
+ * @param transport - The transport to listen to.
254
+ * @param services - An object containing all the services to be registered on the server.
255
+ * @param extendedContext - An optional object containing additional context to be passed to all services.
256
+ * @returns A promise that resolves to a server instance with the registered services.
257
+ */
258
+ export function createServer(transport, services, extendedContext) {
259
+ return new RiverServer(transport, services, extendedContext);
260
+ }
@@ -1,8 +1,7 @@
1
1
  import { describe, test, expect } from 'vitest';
2
2
  import stream from 'node:stream';
3
3
  import { StdioTransport } from './stdio';
4
- import { waitForMessage } from '../..';
5
- import { payloadToTransportMessage } from '../../../util/testHelpers';
4
+ import { payloadToTransportMessage, waitForMessage, } from '../../../util/testHelpers';
6
5
  import { ensureTransportIsClean } from '../../../__tests__/fixtures/cleanup';
7
6
  describe('sending and receiving across node streams works', () => {
8
7
  test('basic send/receive', async () => {
@@ -27,6 +27,7 @@ export declare class WebSocketClientTransport extends Transport<WebSocketConnect
27
27
  options: Options;
28
28
  serverId: TransportClientId;
29
29
  reconnectPromises: Map<TransportClientId, Promise<WebSocketResult>>;
30
+ tryReconnecting: boolean;
30
31
  /**
31
32
  * Creates a new WebSocketTransport instance.
32
33
  * @param wsGetter A function that returns a Promise that resolves to a WebSocket instance.
@@ -36,10 +37,6 @@ export declare class WebSocketClientTransport extends Transport<WebSocketConnect
36
37
  constructor(wsGetter: () => Promise<WebSocket>, clientId: TransportClientId, serverId: TransportClientId, providedOptions?: Partial<Options>);
37
38
  setupConnectionStatusListeners(): void;
38
39
  createNewConnection(to: string, attempt?: number): Promise<void>;
39
- /**
40
- * Begins a new attempt to establish a WebSocket connection.
41
- */
42
- open(): Promise<void>;
43
40
  }
44
41
  export {};
45
42
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../transport/impls/ws/client.ts"],"names":[],"mappings":";AAAA,OAAO,SAAS,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,UAAU,OAAO;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,KAAK,CAAC;CACd;AAQD,KAAK,eAAe,GAAG;IAAE,EAAE,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D;;;;GAIG;AACH,qBAAa,wBAAyB,SAAQ,SAAS,CAAC,mBAAmB,CAAC;IAC1E;;OAEG;IACH,QAAQ,EAAE,CAAC,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACxD,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,iBAAiB,CAAC;IAE5B,iBAAiB,EAAE,GAAG,CAAC,iBAAiB,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC;IAEpE;;;;;OAKG;gBAED,QAAQ,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,EAClC,QAAQ,EAAE,iBAAiB,EAC3B,QAAQ,EAAE,iBAAiB,EAC3B,eAAe,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC;IAWpC,8BAA8B,IAAI,IAAI;IAIhC,mBAAmB,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,SAAI;IAuEjD;;OAEG;IACG,IAAI;CAGX"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../transport/impls/ws/client.ts"],"names":[],"mappings":";AAAA,OAAO,SAAS,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,UAAU,OAAO;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,KAAK,CAAC;CACd;AAQD,KAAK,eAAe,GAAG;IAAE,EAAE,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D;;;;GAIG;AACH,qBAAa,wBAAyB,SAAQ,SAAS,CAAC,mBAAmB,CAAC;IAC1E;;OAEG;IACH,QAAQ,EAAE,CAAC,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACxD,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,iBAAiB,EAAE,GAAG,CAAC,iBAAiB,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC;IACpE,eAAe,EAAE,OAAO,CAAQ;IAEhC;;;;;OAKG;gBAED,QAAQ,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,EAClC,QAAQ,EAAE,iBAAiB,EAC3B,QAAQ,EAAE,iBAAiB,EAC3B,eAAe,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC;IAWpC,8BAA8B,IAAI,IAAI;IAIhC,mBAAmB,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,SAAI;CA6ElD"}
@@ -20,6 +20,7 @@ export class WebSocketClientTransport extends Transport {
20
20
  options;
21
21
  serverId;
22
22
  reconnectPromises;
23
+ tryReconnecting = true;
23
24
  /**
24
25
  * Creates a new WebSocketTransport instance.
25
26
  * @param wsGetter A function that returns a Promise that resolves to a WebSocket instance.
@@ -44,6 +45,10 @@ export class WebSocketClientTransport extends Transport {
44
45
  }
45
46
  let reconnectPromise = this.reconnectPromises.get(to);
46
47
  if (!reconnectPromise) {
48
+ if (!this.tryReconnecting) {
49
+ log?.info(`${this.clientId} -- tryReconnecting is false, not attempting reconnect`);
50
+ return;
51
+ }
47
52
  reconnectPromise = new Promise(async (resolve) => {
48
53
  log?.info(`${this.clientId} -- establishing a new websocket to ${to}`);
49
54
  const ws = await this.wsGetter(to);
@@ -93,10 +98,4 @@ export class WebSocketClientTransport extends Transport {
93
98
  setTimeout(() => this.createNewConnection(to, attempt + 1), this.options.retryIntervalMs * attempt);
94
99
  }
95
100
  }
96
- /**
97
- * Begins a new attempt to establish a WebSocket connection.
98
- */
99
- async open() {
100
- return this.createNewConnection(this.serverId);
101
- }
102
101
  }
@@ -2,6 +2,7 @@ import { Codec } from '../../../codec';
2
2
  import { TransportClientId } from '../../message';
3
3
  import { Transport } from '../../transport';
4
4
  import { Server } from 'ws';
5
+ import { WebSocket } from 'isomorphic-ws';
5
6
  import { WebSocketConnection } from './connection';
6
7
  interface Options {
7
8
  codec: Codec;
@@ -10,8 +11,10 @@ export declare class WebSocketServerTransport extends Transport<WebSocketConnect
10
11
  wss: Server;
11
12
  clientId: TransportClientId;
12
13
  constructor(wss: Server, clientId: TransportClientId, providedOptions?: Partial<Options>);
14
+ connectionHandler: (ws: WebSocket) => void;
13
15
  setupConnectionStatusListeners(): void;
14
16
  createNewConnection(to: string): Promise<void>;
17
+ close(): Promise<void>;
15
18
  }
16
19
  export {};
17
20
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../transport/impls/ws/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAkB,MAAM,gBAAgB,CAAC;AAEvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAC5B,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,UAAU,OAAO;IACf,KAAK,EAAE,KAAK,CAAC;CACd;AAMD,qBAAa,wBAAyB,SAAQ,SAAS,CAAC,mBAAmB,CAAC;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,iBAAiB,CAAC;gBAG1B,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,iBAAiB,EAC3B,eAAe,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC;IASpC,8BAA8B,IAAI,IAAI;IAiChC,mBAAmB,CAAC,EAAE,EAAE,MAAM;CAKrC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../transport/impls/ws/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAkB,MAAM,gBAAgB,CAAC;AAEvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAC5B,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,UAAU,OAAO;IACf,KAAK,EAAE,KAAK,CAAC;CACd;AAMD,qBAAa,wBAAyB,SAAQ,SAAS,CAAC,mBAAmB,CAAC;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,iBAAiB,CAAC;gBAG1B,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,iBAAiB,EAC3B,eAAe,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC;IASpC,iBAAiB,OAAQ,SAAS,UA6BhC;IAEF,8BAA8B,IAAI,IAAI;IAIhC,mBAAmB,CAAC,EAAE,EAAE,MAAM;IAM9B,KAAK;CAIZ"}
@@ -15,34 +15,39 @@ export class WebSocketServerTransport extends Transport {
15
15
  this.clientId = clientId;
16
16
  this.setupConnectionStatusListeners();
17
17
  }
18
+ connectionHandler = (ws) => {
19
+ let conn = undefined;
20
+ ws.onmessage = (msg) => {
21
+ // when we establish WebSocketConnection, ws.onmessage
22
+ // gets overriden so this only runs on the first valid message
23
+ // the websocket receives
24
+ const parsedMsg = this.parseMsg(msg.data);
25
+ if (parsedMsg && !conn) {
26
+ conn = new WebSocketConnection(this, parsedMsg.from, ws);
27
+ this.onConnect(conn);
28
+ this.handleMsg(parsedMsg);
29
+ }
30
+ };
31
+ // close is always emitted, even on error, ok to do cleanup here
32
+ ws.onclose = () => {
33
+ if (conn) {
34
+ this.onDisconnect(conn);
35
+ }
36
+ };
37
+ ws.onerror = (msg) => {
38
+ log?.warn(`${this.clientId} -- ws error from client ${conn?.connectedTo ?? 'unknown'}: ${msg}`);
39
+ };
40
+ };
18
41
  setupConnectionStatusListeners() {
19
- this.wss.on('connection', (ws) => {
20
- let conn = undefined;
21
- ws.onmessage = (msg) => {
22
- // when we establish WebSocketConnection, ws.onmessage
23
- // gets overriden so this only runs on the first valid message
24
- // the websocket receives
25
- const parsedMsg = this.parseMsg(msg.data);
26
- if (parsedMsg && !conn) {
27
- conn = new WebSocketConnection(this, parsedMsg.from, ws);
28
- this.onConnect(conn);
29
- this.handleMsg(parsedMsg);
30
- }
31
- };
32
- // close is always emitted, even on error, ok to do cleanup here
33
- ws.onclose = () => {
34
- if (conn) {
35
- this.onDisconnect(conn);
36
- }
37
- };
38
- ws.onerror = (msg) => {
39
- log?.warn(`${this.clientId} -- ws error from client ${conn?.connectedTo ?? 'unknown'}: ${msg}`);
40
- };
41
- });
42
+ this.wss.on('connection', this.connectionHandler);
42
43
  }
43
44
  async createNewConnection(to) {
44
45
  const err = `${this.clientId} -- failed to send msg to ${to}, client probably dropped`;
45
46
  log?.warn(err);
46
47
  return;
47
48
  }
49
+ async close() {
50
+ super.close();
51
+ this.wss.off('connection', this.connectionHandler);
52
+ }
48
53
  }
@@ -1,10 +1,10 @@
1
1
  import http from 'http';
2
- import { describe, test, expect, afterAll } from 'vitest';
3
- import { createWebSocketServer, createWsTransports, createDummyTransportMessage, onServerReady, createLocalWebSocketClient, } from '../../../util/testHelpers';
4
- import { msg, waitForMessage } from '../..';
2
+ import { describe, test, expect, afterAll, vi } from 'vitest';
3
+ import { createWebSocketServer, createWsTransports, createDummyTransportMessage, onServerReady, createLocalWebSocketClient, waitForMessage, } from '../../../util/testHelpers';
4
+ import { msg } from '../..';
5
5
  import { WebSocketServerTransport } from './server';
6
6
  import { WebSocketClientTransport } from './client';
7
- import { testFinishesCleanly } from '../../../__tests__/fixtures/cleanup';
7
+ import { testFinishesCleanly, waitFor, } from '../../../__tests__/fixtures/cleanup';
8
8
  describe('sending and receiving across websockets works', async () => {
9
9
  const server = http.createServer();
10
10
  const port = await onServerReady(server);
@@ -16,8 +16,9 @@ describe('sending and receiving across websockets works', async () => {
16
16
  test('basic send/receive', async () => {
17
17
  const [clientTransport, serverTransport] = createWsTransports(port, wss);
18
18
  const msg = createDummyTransportMessage();
19
+ const msgPromise = waitForMessage(serverTransport, (recv) => recv.id === msg.id);
19
20
  clientTransport.send(msg);
20
- await expect(waitForMessage(serverTransport, (recv) => recv.id === msg.id)).resolves.toStrictEqual(msg.payload);
21
+ await expect(msgPromise).resolves.toStrictEqual(msg.payload);
21
22
  await testFinishesCleanly({
22
23
  clientTransports: [clientTransport],
23
24
  serverTransport,
@@ -36,8 +37,9 @@ describe('sending and receiving across websockets works', async () => {
36
37
  const initClient = async (id) => {
37
38
  const client = new WebSocketClientTransport(() => createLocalWebSocketClient(port), id, 'SERVER');
38
39
  const initMsg = makeDummyMessage(id, serverId, 'hello server');
40
+ const initMsgPromise = waitForMessage(serverTransport, (recv) => recv.id === initMsg.id);
39
41
  client.send(initMsg);
40
- await expect(waitForMessage(serverTransport, (recv) => recv.id === initMsg.id)).resolves.toStrictEqual(initMsg.payload);
42
+ await expect(initMsgPromise).resolves.toStrictEqual(initMsg.payload);
41
43
  return client;
42
44
  };
43
45
  const client1 = await initClient(clientId1);
@@ -70,43 +72,109 @@ describe('retry logic', async () => {
70
72
  // TODO: right now, we only test client-side disconnects, we probably
71
73
  // need to also write tests for server-side crashes (but this involves clearing/restoring state)
72
74
  // not going to worry about this rn but for future
73
- test('ws transport is recreated after clean disconnect', async () => {
75
+ test('ws connection is recreated after clean disconnect', async () => {
74
76
  const [clientTransport, serverTransport] = createWsTransports(port, wss);
75
77
  const msg1 = createDummyTransportMessage();
76
78
  const msg2 = createDummyTransportMessage();
79
+ const msg1Promise = waitForMessage(serverTransport, (recv) => recv.id === msg1.id);
77
80
  clientTransport.send(msg1);
78
- await expect(waitForMessage(serverTransport, (recv) => recv.id === msg1.id)).resolves.toStrictEqual(msg1.payload);
81
+ await expect(msg1Promise).resolves.toStrictEqual(msg1.payload);
82
+ // clean disconnect
79
83
  clientTransport.connections.forEach((conn) => conn.ws.close());
80
- clientTransport.send(msg2);
84
+ const msg2Promise = waitForMessage(serverTransport, (recv) => recv.id === msg2.id);
81
85
  // by this point the client should have reconnected
82
- await expect(waitForMessage(serverTransport, (recv) => recv.id === msg2.id)).resolves.toStrictEqual(msg2.payload);
86
+ clientTransport.send(msg2);
87
+ await expect(msg2Promise).resolves.toStrictEqual(msg2.payload);
83
88
  await testFinishesCleanly({
84
89
  clientTransports: [clientTransport],
85
90
  serverTransport,
86
91
  });
87
92
  });
88
- test('ws transport is recreated after unclean disconnect', async () => {
93
+ test('ws connection is recreated after unclean disconnect', async () => {
89
94
  const [clientTransport, serverTransport] = createWsTransports(port, wss);
90
95
  const msg1 = createDummyTransportMessage();
91
96
  const msg2 = createDummyTransportMessage();
97
+ const msg1Promise = waitForMessage(serverTransport, (recv) => recv.id === msg1.id);
92
98
  clientTransport.send(msg1);
93
- await expect(waitForMessage(serverTransport, (recv) => recv.id === msg1.id)).resolves.toStrictEqual(msg1.payload);
99
+ await expect(msg1Promise).resolves.toStrictEqual(msg1.payload);
100
+ // unclean disconnect
94
101
  clientTransport.connections.forEach((conn) => conn.ws.terminate());
102
+ const msg2Promise = waitForMessage(serverTransport, (recv) => recv.id === msg2.id);
103
+ // by this point the client should have reconnected
95
104
  clientTransport.send(msg2);
105
+ await expect(msg2Promise).resolves.toStrictEqual(msg2.payload);
106
+ await testFinishesCleanly({
107
+ clientTransports: [clientTransport],
108
+ serverTransport,
109
+ });
110
+ });
111
+ test('both client and server transport gets connection and disconnection notifs', async () => {
112
+ const [clientTransport, serverTransport] = createWsTransports(port, wss);
113
+ const msg1 = createDummyTransportMessage();
114
+ const msg2 = createDummyTransportMessage();
115
+ const onClientConnect = vi.fn();
116
+ const onClientDisconnect = vi.fn();
117
+ const clientHandler = (evt) => {
118
+ if (evt.conn.connectedTo !== serverTransport.clientId)
119
+ return;
120
+ if (evt.status === 'connect')
121
+ return onClientConnect();
122
+ if (evt.status === 'disconnect')
123
+ return onClientDisconnect();
124
+ };
125
+ const onServerConnect = vi.fn();
126
+ const onServerDisconnect = vi.fn();
127
+ const serverHandler = (evt) => {
128
+ if (evt.status === 'connect' &&
129
+ evt.conn.connectedTo === clientTransport.clientId)
130
+ return onServerConnect();
131
+ if (evt.status === 'disconnect' &&
132
+ evt.conn.connectedTo === clientTransport.clientId)
133
+ return onServerDisconnect();
134
+ };
135
+ clientTransport.addEventListener('connectionStatus', clientHandler);
136
+ serverTransport.addEventListener('connectionStatus', serverHandler);
137
+ expect(onClientConnect).toHaveBeenCalledTimes(0);
138
+ expect(onClientDisconnect).toHaveBeenCalledTimes(0);
139
+ expect(onServerConnect).toHaveBeenCalledTimes(0);
140
+ expect(onServerDisconnect).toHaveBeenCalledTimes(0);
141
+ const msg1Promise = waitForMessage(serverTransport, (recv) => recv.id === msg1.id);
142
+ clientTransport.send(msg1);
143
+ await expect(msg1Promise).resolves.toStrictEqual(msg1.payload);
144
+ expect(onClientConnect).toHaveBeenCalledTimes(1);
145
+ expect(onClientDisconnect).toHaveBeenCalledTimes(0);
146
+ expect(onServerConnect).toHaveBeenCalledTimes(1);
147
+ expect(onServerDisconnect).toHaveBeenCalledTimes(0);
148
+ // clean disconnect
149
+ clientTransport.connections.forEach((conn) => conn.ws.close());
150
+ // wait for connection status to propagate to server
151
+ await waitFor(() => expect(onClientConnect).toHaveBeenCalledTimes(1));
152
+ await waitFor(() => expect(onClientDisconnect).toHaveBeenCalledTimes(1));
153
+ await waitFor(() => expect(onServerConnect).toHaveBeenCalledTimes(1));
154
+ await waitFor(() => expect(onServerDisconnect).toHaveBeenCalledTimes(1));
155
+ const msg2Promise = waitForMessage(serverTransport, (recv) => recv.id === msg2.id);
96
156
  // by this point the client should have reconnected
97
- await expect(waitForMessage(serverTransport, (recv) => recv.id === msg2.id)).resolves.toStrictEqual(msg2.payload);
98
- // this is not expected to be clean because we destroyed the transport
157
+ clientTransport.send(msg2);
158
+ await expect(msg2Promise).resolves.toStrictEqual(msg2.payload);
159
+ expect(onClientConnect).toHaveBeenCalledTimes(2);
160
+ expect(onClientDisconnect).toHaveBeenCalledTimes(1);
161
+ expect(onServerConnect).toHaveBeenCalledTimes(2);
162
+ expect(onServerDisconnect).toHaveBeenCalledTimes(1);
163
+ // teardown
164
+ clientTransport.removeEventListener('connectionStatus', clientHandler);
165
+ serverTransport.removeEventListener('connectionStatus', serverHandler);
99
166
  await testFinishesCleanly({
100
167
  clientTransports: [clientTransport],
101
168
  serverTransport,
102
169
  });
103
170
  });
104
- test('ws transport is not recreated after destroy', async () => {
171
+ test('ws connection is not recreated after destroy', async () => {
105
172
  const [clientTransport, serverTransport] = createWsTransports(port, wss);
106
173
  const msg1 = createDummyTransportMessage();
107
174
  const msg2 = createDummyTransportMessage();
175
+ const promise1 = waitForMessage(serverTransport, (recv) => recv.id === msg1.id);
108
176
  clientTransport.send(msg1);
109
- await expect(waitForMessage(serverTransport, (recv) => recv.id === msg1.id)).resolves.toStrictEqual(msg1.payload);
177
+ await expect(promise1).resolves.toStrictEqual(msg1.payload);
110
178
  clientTransport.destroy();
111
179
  expect(() => clientTransport.send(msg2)).toThrow(new Error('transport is destroyed, cant send'));
112
180
  // this is not expected to be clean because we destroyed the transport
@@ -1,13 +1,4 @@
1
- import { OpaqueTransportMessage } from './message';
2
- import { Transport, Connection } from './transport';
3
1
  export { Transport, Connection } from './transport';
4
2
  export { TransportMessageSchema, OpaqueTransportMessageSchema, msg, reply, } from './message';
5
3
  export type { TransportMessage, MessageId, OpaqueTransportMessage, TransportClientId, isStreamOpen, isStreamClose, } from './message';
6
- /**
7
- * Waits for a message from the transport.
8
- * @param {Transport} t - The transport to listen to.
9
- * @param filter - An optional filter function to apply to the received messages.
10
- * @returns A promise that resolves with the payload of the first message that passes the filter.
11
- */
12
- export declare function waitForMessage(t: Transport<Connection>, filter?: (msg: OpaqueTransportMessage) => boolean, rejectMismatch?: boolean): Promise<unknown>;
13
4
  //# sourceMappingURL=index.d.ts.map