@dxos/edge-client 0.6.14-staging.e15392e → 0.7.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 (34) hide show
  1. package/dist/lib/browser/index.mjs +444 -226
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/testing/index.mjs +7 -4
  5. package/dist/lib/browser/testing/index.mjs.map +3 -3
  6. package/dist/lib/node/index.cjs +441 -225
  7. package/dist/lib/node/index.cjs.map +4 -4
  8. package/dist/lib/node/meta.json +1 -1
  9. package/dist/lib/node/testing/index.cjs +7 -4
  10. package/dist/lib/node/testing/index.cjs.map +3 -3
  11. package/dist/lib/node-esm/index.mjs +444 -226
  12. package/dist/lib/node-esm/index.mjs.map +4 -4
  13. package/dist/lib/node-esm/meta.json +1 -1
  14. package/dist/lib/node-esm/testing/index.mjs +7 -4
  15. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  16. package/dist/types/src/auth.d.ts +1 -1
  17. package/dist/types/src/auth.d.ts.map +1 -1
  18. package/dist/types/src/edge-client.d.ts +21 -18
  19. package/dist/types/src/edge-client.d.ts.map +1 -1
  20. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  21. package/dist/types/src/edge-ws-connection.d.ts +30 -0
  22. package/dist/types/src/edge-ws-connection.d.ts.map +1 -0
  23. package/dist/types/src/persistent-lifecycle.d.ts +7 -5
  24. package/dist/types/src/persistent-lifecycle.d.ts.map +1 -1
  25. package/dist/types/src/testing/test-utils.d.ts +1 -0
  26. package/dist/types/src/testing/test-utils.d.ts.map +1 -1
  27. package/package.json +14 -14
  28. package/src/auth.ts +4 -1
  29. package/src/edge-client.test.ts +101 -14
  30. package/src/edge-client.ts +128 -126
  31. package/src/edge-http-client.ts +4 -1
  32. package/src/edge-ws-connection.ts +148 -0
  33. package/src/persistent-lifecycle.ts +26 -11
  34. package/src/testing/test-utils.ts +3 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-ws-connection.d.ts","sourceRoot":"","sources":["../../../src/edge-ws-connection.ts"],"names":[],"mappings":"AAOA,OAAO,EAAW,QAAQ,EAAE,MAAM,eAAe,CAAC;AAIlD,OAAO,EAAiB,KAAK,OAAO,EAAE,MAAM,4CAA4C,CAAC;AAGzF,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAKpD,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACtC,iBAAiB,EAAE,MAAM,IAAI,CAAC;CAC/B,CAAC;AAEF,qBAAa,gBAAiB,SAAQ,QAAQ;IAK1C,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN7B,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,GAAG,CAAwB;gBAGhB,SAAS,EAAE,YAAY,EACvB,eAAe,EAAE;QAAE,GAAG,EAAE,GAAG,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,EACtD,UAAU,EAAE,yBAAyB;IAKxD,IACW,IAAI;;;;MAMd;IAEM,IAAI,CAAC,OAAO,EAAE,OAAO;cAMH,KAAK;cAkDL,MAAM;IAc/B,OAAO,CAAC,mBAAmB;IAe3B,OAAO,CAAC,2BAA2B;CAgBpC"}
@@ -1,14 +1,14 @@
1
1
  import { Resource } from '@dxos/context';
2
- export type PersistentLifecycleParams = {
2
+ export type PersistentLifecycleParams<T> = {
3
3
  /**
4
4
  * Create connection.
5
5
  * If promise resolves successfully, connection is considered established.
6
6
  */
7
- start: () => Promise<void>;
7
+ start: () => Promise<T | undefined>;
8
8
  /**
9
9
  * Reset connection to initial state.
10
10
  */
11
- stop: () => Promise<void>;
11
+ stop: (state: T) => Promise<void>;
12
12
  /**
13
13
  * Called after successful start.
14
14
  */
@@ -23,17 +23,19 @@ export type PersistentLifecycleParams = {
23
23
  * Handles restarts (e.g. persists connection).
24
24
  * Restarts are scheduled with exponential backoff.
25
25
  */
26
- export declare class PersistentLifecycle extends Resource {
26
+ export declare class PersistentLifecycle<T> extends Resource {
27
27
  private readonly _start;
28
28
  private readonly _stop;
29
29
  private readonly _onRestart?;
30
30
  private readonly _maxRestartDelay;
31
+ private _currentContext;
31
32
  private _restartTask?;
32
33
  private _restartAfter;
33
- constructor({ start, stop, onRestart, maxRestartDelay }: PersistentLifecycleParams);
34
+ constructor({ start, stop, onRestart, maxRestartDelay }: PersistentLifecycleParams<T>);
34
35
  protected _open(): Promise<void>;
35
36
  protected _close(): Promise<void>;
36
37
  private _restart;
38
+ private _stopCurrentContext;
37
39
  /**
38
40
  * Scheduling restart should be done from outside.
39
41
  */
@@ -1 +1 @@
1
- {"version":3,"file":"persistent-lifecycle.d.ts","sourceRoot":"","sources":["../../../src/persistent-lifecycle.ts"],"names":[],"mappings":"AAKA,OAAO,EAAqC,QAAQ,EAAE,MAAM,eAAe,CAAC;AAO5E,MAAM,MAAM,yBAAyB,GAAG;IACtC;;;OAGG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAE3B;;OAEG;IACH,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1B;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF;;;GAGG;AACH,qBAAa,mBAAoB,SAAQ,QAAQ;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;IAC7C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAE1C,OAAO,CAAC,YAAY,CAAC,CAA2B;IAChD,OAAO,CAAC,aAAa,CAAK;gBAEd,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,eAA2C,EAAE,EAAE,yBAAyB;cASrF,KAAK;cAeL,MAAM;YAMjB,QAAQ;IAgBtB;;OAEG;IAEH,eAAe;CAMhB"}
1
+ {"version":3,"file":"persistent-lifecycle.d.ts","sourceRoot":"","sources":["../../../src/persistent-lifecycle.ts"],"names":[],"mappings":"AAKA,OAAO,EAAqC,QAAQ,EAAE,MAAM,eAAe,CAAC;AAO5E,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IACzC;;;OAGG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IAEpC;;OAEG;IACH,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAElC;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF;;;GAGG;AACH,qBAAa,mBAAmB,CAAC,CAAC,CAAE,SAAQ,QAAQ;IAClD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA+B;IACtD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA8B;IACpD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAE1C,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,YAAY,CAAC,CAA2B;IAChD,OAAO,CAAC,aAAa,CAAK;gBAEd,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,eAA2C,EAAE,EAAE,yBAAyB,CAAC,CAAC,CAAC;cASxF,KAAK;cAgBL,MAAM;YAMjB,QAAQ;YAkBR,mBAAmB;IAWjC;;OAEG;IAEH,eAAe;CAMhB"}
@@ -10,6 +10,7 @@ type TestEdgeWsServerParams = {
10
10
  export declare const createTestEdgeWsServer: (port?: number, params?: TestEdgeWsServerParams) => Promise<{
11
11
  server: WebSocket.Server;
12
12
  messageSink: any[];
13
+ messageSourceLog: any[];
13
14
  endpoint: string;
14
15
  cleanup: () => void;
15
16
  currentConnection: () => WebSocket | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../../../../src/testing/test-utils.ts"],"names":[],"mappings":"AAIA,OAAO,SAAS,MAAM,eAAe,CAAC;AAEtC,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGtC,OAAO,EAAoC,KAAK,OAAO,EAAE,MAAM,4CAA4C,CAAC;AAK5G,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC,KAAK,sBAAsB,GAAG;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,GAAG,CAAC;IAC9C,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;CACpE,CAAC;AAEF,eAAO,MAAM,sBAAsB,2BAAwC,sBAAsB;;;;;;mCAmE9E,OAAO,mBAAmB,UAAU;uBA1BhC,OAAO;;EAS7B,CAAC"}
1
+ {"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../../../../src/testing/test-utils.ts"],"names":[],"mappings":"AAIA,OAAO,SAAS,MAAM,eAAe,CAAC;AAEtC,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGtC,OAAO,EAAoC,KAAK,OAAO,EAAE,MAAM,4CAA4C,CAAC;AAK5G,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC,KAAK,sBAAsB,GAAG;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,GAAG,CAAC;IAC9C,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;CACpE,CAAC;AAEF,eAAO,MAAM,sBAAsB,2BAAwC,sBAAsB;;;;;;;mCAsE9E,OAAO,mBAAmB,UAAU;uBA1BhC,OAAO;;EAS7B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/edge-client",
3
- "version": "0.6.14-staging.e15392e",
3
+ "version": "0.7.0",
4
4
  "description": "EDGE Client",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -35,21 +35,21 @@
35
35
  "dependencies": {
36
36
  "isomorphic-ws": "^5.0.0",
37
37
  "ws": "^8.14.2",
38
- "@dxos/async": "0.6.14-staging.e15392e",
39
- "@dxos/credentials": "0.6.14-staging.e15392e",
40
- "@dxos/context": "0.6.14-staging.e15392e",
41
- "@dxos/crypto": "0.6.14-staging.e15392e",
42
- "@dxos/debug": "0.6.14-staging.e15392e",
43
- "@dxos/invariant": "0.6.14-staging.e15392e",
44
- "@dxos/keyring": "0.6.14-staging.e15392e",
45
- "@dxos/keys": "0.6.14-staging.e15392e",
46
- "@dxos/log": "0.6.14-staging.e15392e",
47
- "@dxos/node-std": "0.6.14-staging.e15392e",
48
- "@dxos/protocols": "0.6.14-staging.e15392e",
49
- "@dxos/util": "0.6.14-staging.e15392e"
38
+ "@dxos/async": "0.7.0",
39
+ "@dxos/context": "0.7.0",
40
+ "@dxos/credentials": "0.7.0",
41
+ "@dxos/debug": "0.7.0",
42
+ "@dxos/crypto": "0.7.0",
43
+ "@dxos/keyring": "0.7.0",
44
+ "@dxos/invariant": "0.7.0",
45
+ "@dxos/keys": "0.7.0",
46
+ "@dxos/node-std": "0.7.0",
47
+ "@dxos/protocols": "0.7.0",
48
+ "@dxos/util": "0.7.0",
49
+ "@dxos/log": "0.7.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@dxos/test-utils": "0.6.14-staging.e15392e"
52
+ "@dxos/test-utils": "0.7.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"
package/src/auth.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { createCredential, signPresentation } from '@dxos/credentials';
6
6
  import { type Signer } from '@dxos/crypto';
7
+ import { invariant } from '@dxos/invariant';
7
8
  import { Keyring } from '@dxos/keyring';
8
9
  import { PublicKey } from '@dxos/keys';
9
10
  import { type Chain, type Credential } from '@dxos/protocols/proto/dxos/halo/credentials';
@@ -47,7 +48,7 @@ export const createChainEdgeIdentity = async (
47
48
  signer: Signer,
48
49
  identityKey: PublicKey,
49
50
  peerKey: PublicKey,
50
- chain: Chain,
51
+ chain: Chain | undefined,
51
52
  credentials: Credential[],
52
53
  ): Promise<EdgeIdentity> => {
53
54
  const credentialsToSign =
@@ -70,6 +71,8 @@ export const createChainEdgeIdentity = async (
70
71
  identityKey: identityKey.toHex(),
71
72
  peerKey: peerKey.toHex(),
72
73
  presentCredentials: async ({ challenge }) => {
74
+ // TODO: make chain required after device invitation flow update release
75
+ invariant(chain);
73
76
  return signPresentation({
74
77
  presentation: {
75
78
  credentials: credentialsToSign,
@@ -12,33 +12,33 @@ import { openAndClose } from '@dxos/test-utils';
12
12
  import { createEphemeralEdgeIdentity, createTestHaloEdgeIdentity } from './auth';
13
13
  import { protocol } from './defs';
14
14
  import { EdgeClient } from './edge-client';
15
+ import { type EdgeIdentity } from './edge-identity';
16
+ import { EdgeConnectionClosedError, EdgeIdentityChangedError } from './errors';
15
17
  import { createTestEdgeWsServer } from './testing';
16
18
 
17
19
  describe('EdgeClient', () => {
18
- const textMessage = (message: string) => protocol.createMessage(TextMessageSchema, { payload: { message } });
20
+ let wsServerPort = 8001;
19
21
 
20
22
  test('reconnects on error', async () => {
21
- const { closeConnection, endpoint, cleanup } = await createTestEdgeWsServer(8001);
23
+ const { closeConnection, endpoint, cleanup } = await createTestEdgeWsServer(wsServerPort++);
22
24
  onTestFinished(cleanup);
23
25
 
24
- const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
25
- await openAndClose(client);
26
+ const { client, reconnectTrigger } = await openNewClient(endpoint);
26
27
  await client.send(textMessage('Hello world 1'));
27
28
  expect(client.isOpen).is.true;
28
29
 
29
- const reconnected = client.reconnect.waitForCount(1);
30
+ reconnectTrigger.reset();
30
31
  await closeConnection();
31
- await reconnected;
32
+ await reconnectTrigger.wait();
32
33
  await expect(client.send(textMessage('Hello world 2'))).resolves.not.toThrow();
33
34
  });
34
35
 
35
36
  test('isConnected', async () => {
36
37
  const admitConnection = new Trigger();
37
- const { closeConnection, endpoint, cleanup } = await createTestEdgeWsServer(8001, { admitConnection });
38
+ const { closeConnection, endpoint, cleanup } = await createTestEdgeWsServer(wsServerPort++, { admitConnection });
38
39
  onTestFinished(cleanup);
39
40
 
40
- const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
41
- await openAndClose(client);
41
+ const { client } = await openNewClient(endpoint);
42
42
 
43
43
  expect(client.isConnected).toBeFalsy();
44
44
  admitConnection.wake();
@@ -54,20 +54,90 @@ describe('EdgeClient', () => {
54
54
  });
55
55
 
56
56
  test('set identity reconnects', async () => {
57
- const { endpoint, cleanup } = await createTestEdgeWsServer(8002);
57
+ const { endpoint, cleanup } = await createTestEdgeWsServer(wsServerPort++);
58
58
  onTestFinished(cleanup);
59
59
 
60
- const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
61
- await openAndClose(client);
60
+ const { client, reconnectTrigger } = await openNewClient(endpoint);
62
61
  await client.send(textMessage('Hello world 1'));
63
62
  expect(client.isOpen).is.true;
64
63
 
65
- const reconnected = client.reconnect.waitForCount(1);
64
+ reconnectTrigger.reset();
66
65
  client.setIdentity(await createEphemeralEdgeIdentity());
67
- await reconnected;
66
+ await reconnectTrigger.wait();
68
67
  await expect(client.send(textMessage('Hello world 2'))).resolves.not.toThrow();
69
68
  });
70
69
 
70
+ test('send blocks until connection becomes ready', async () => {
71
+ const admitConnection = new Trigger();
72
+ const { endpoint, messageSink, cleanup } = await createTestEdgeWsServer(wsServerPort++, { admitConnection });
73
+ onTestFinished(cleanup);
74
+
75
+ const { client } = await openNewClient(endpoint);
76
+ setTimeout(() => admitConnection.wake(), 20);
77
+ await client.send(textMessage('Hello world 1'));
78
+ await expect.poll(() => messageSink.length).toBe(1);
79
+ });
80
+
81
+ test('send fails if identity changes before connection becomes ready', async () => {
82
+ const admitConnection = new Trigger();
83
+ const { endpoint, cleanup, messageSink } = await createTestEdgeWsServer(wsServerPort++, { admitConnection });
84
+ onTestFinished(cleanup);
85
+
86
+ const { client } = await openNewClient(endpoint);
87
+ setTimeout(async () => client.setIdentity(await createEphemeralEdgeIdentity()));
88
+ await expect(client.send(textMessage('Hello world 1'))).rejects.toThrow(EdgeIdentityChangedError);
89
+
90
+ // Test recovers.
91
+ setTimeout(() => admitConnection.wake(), 20);
92
+ await client.send(textMessage('Hello world 1'));
93
+ await expect.poll(() => messageSink.length).toBe(1);
94
+ });
95
+
96
+ test('send fails if client is closed before connection becomes ready', async () => {
97
+ const admitConnection = new Trigger();
98
+ const { endpoint, cleanup } = await createTestEdgeWsServer(wsServerPort++, { admitConnection });
99
+ onTestFinished(cleanup);
100
+
101
+ const { client } = await openNewClient(endpoint);
102
+ setTimeout(() => client.close());
103
+ await expect(client.send(textMessage('Hello world 1'))).rejects.toThrow(EdgeConnectionClosedError);
104
+ });
105
+
106
+ test('onReconnect trigger', async () => {
107
+ const admitConnection = new Trigger();
108
+ const { endpoint, cleanup, closeConnection } = await createTestEdgeWsServer(wsServerPort++, { admitConnection });
109
+ onTestFinished(cleanup);
110
+
111
+ const { client } = await openNewClient(endpoint);
112
+ let callCount = 0;
113
+ client.onReconnected(() => callCount++);
114
+ admitConnection.wake();
115
+
116
+ await expect.poll(() => callCount).toEqual(1);
117
+ await closeConnection();
118
+ await expect.poll(() => callCount).toEqual(2);
119
+
120
+ const trigger = new Trigger();
121
+ client.onReconnected(() => trigger.wake());
122
+ await trigger.wait();
123
+ expect(callCount).toEqual(2);
124
+ });
125
+
126
+ test('send message right after identity change is delivered successfully with the new identity', async () => {
127
+ const { endpoint, cleanup, messageSourceLog } = await createTestEdgeWsServer(wsServerPort++);
128
+ onTestFinished(cleanup);
129
+
130
+ const { client, identity: oldIdentity } = await openNewClient(endpoint);
131
+ await client.send(textMessage('Hello world 1', oldIdentity));
132
+ expect(client.isOpen).is.true;
133
+
134
+ const newIdentity = await createEphemeralEdgeIdentity();
135
+ client.setIdentity(newIdentity);
136
+ await client.send(textMessage('Hello world 2', newIdentity));
137
+ await expect.poll(() => messageSourceLog.length).toBe(2);
138
+ expect(messageSourceLog.map((m) => m.peerKey)).toStrictEqual([oldIdentity.peerKey, newIdentity.peerKey]);
139
+ });
140
+
71
141
  test.skipIf(!process.env.EDGE_ENDPOINT)('connect to local edge server', async () => {
72
142
  // const identity = await createEphemeralEdgeIdentity();
73
143
 
@@ -79,4 +149,21 @@ describe('EdgeClient', () => {
79
149
  await client.send(textMessage('Hello world 1'));
80
150
  expect(client.isOpen).is.true;
81
151
  });
152
+
153
+ const textMessage = (message: string, source?: EdgeIdentity) =>
154
+ protocol.createMessage(TextMessageSchema, {
155
+ source: source && { peerKey: source.peerKey, identityKey: source.identityKey },
156
+ payload: { message },
157
+ });
158
+
159
+ const openNewClient = async (endpoint: string) => {
160
+ const identity = await createEphemeralEdgeIdentity();
161
+ const client = new EdgeClient(identity, { socketEndpoint: endpoint });
162
+ await openAndClose(client);
163
+ const reconnectTrigger = new Trigger();
164
+ client.onReconnected(() => {
165
+ reconnectTrigger.wake();
166
+ });
167
+ return { client, reconnectTrigger, identity };
168
+ };
82
169
  });
@@ -2,37 +2,33 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import WebSocket from 'isomorphic-ws';
6
-
7
- import { Trigger, Event, scheduleTaskInterval, scheduleTask, TriggerState } from '@dxos/async';
8
- import { Context, LifecycleState, Resource, type Lifecycle } from '@dxos/context';
9
- import { log } from '@dxos/log';
10
- import { buf } from '@dxos/protocols/buf';
11
- import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
5
+ import { Trigger, scheduleMicroTask, TriggerState } from '@dxos/async';
6
+ import { Resource, type Lifecycle } from '@dxos/context';
7
+ import { log, logInfo } from '@dxos/log';
8
+ import { type Message } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
12
9
 
13
10
  import { protocol } from './defs';
14
11
  import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
12
+ import { EdgeWsConnection } from './edge-ws-connection';
15
13
  import { EdgeConnectionClosedError, EdgeIdentityChangedError } from './errors';
16
14
  import { PersistentLifecycle } from './persistent-lifecycle';
17
- import { type Protocol, toUint8Array } from './protocol';
15
+ import { type Protocol } from './protocol';
18
16
  import { getEdgeUrlWithProtocol } from './utils';
19
17
 
20
18
  const DEFAULT_TIMEOUT = 10_000;
21
- const SIGNAL_KEEPALIVE_INTERVAL = 5_000;
22
19
 
23
- export type MessageListener = (message: Message) => void | Promise<void>;
20
+ export type MessageListener = (message: Message) => void;
21
+ export type ReconnectListener = () => void;
24
22
 
25
23
  export interface EdgeConnection extends Required<Lifecycle> {
26
- connected: Event;
27
- reconnect: Event;
28
-
29
24
  get info(): any;
30
25
  get identityKey(): string;
31
26
  get peerKey(): string;
32
27
  get isOpen(): boolean;
33
28
  get isConnected(): boolean;
34
29
  setIdentity(identity: EdgeIdentity): void;
35
- addListener(listener: MessageListener): () => void;
30
+ onMessage(listener: MessageListener): () => void;
31
+ onReconnected(listener: ReconnectListener): () => void;
36
32
  send(message: Message): Promise<void>;
37
33
  }
38
34
 
@@ -44,25 +40,25 @@ export type MessengerConfig = {
44
40
  };
45
41
 
46
42
  /**
47
- * Messenger client.
43
+ * Messenger client for EDGE:
44
+ * - While open, uses PersistentLifecycle to keep an open EdgeWsConnection, reconnecting on failures.
45
+ * - Manages identity and re-create EdgeWsConnection when identity changes.
46
+ * - Dispatches connection state and message notifications.
48
47
  */
49
48
  export class EdgeClient extends Resource implements EdgeConnection {
50
- public readonly reconnect = new Event();
51
- public readonly connected = new Event();
52
- private readonly _persistentLifecycle = new PersistentLifecycle({
53
- start: async () => this._openWebSocket(),
54
- stop: async () => this._closeWebSocket(),
55
- onRestart: async () => this.reconnect.emit(),
49
+ private readonly _persistentLifecycle = new PersistentLifecycle<EdgeWsConnection>({
50
+ start: async () => this._connect(),
51
+ stop: async (state: EdgeWsConnection) => this._disconnect(state),
56
52
  });
57
53
 
58
- private readonly _listeners = new Set<MessageListener>();
59
- private _ready = new Trigger();
60
- private _ws?: WebSocket = undefined;
61
- private _keepaliveCtx?: Context = undefined;
62
- private _heartBeatContext?: Context = undefined;
54
+ private readonly _messageListeners = new Set<MessageListener>();
55
+ private readonly _reconnectListeners = new Set<ReconnectListener>();
63
56
 
64
- private _baseWsUrl: string;
65
- private _baseHttpUrl: string;
57
+ private readonly _baseWsUrl: string;
58
+ private readonly _baseHttpUrl: string;
59
+
60
+ private _currentConnection?: EdgeWsConnection = undefined;
61
+ private _ready = new Trigger();
66
62
 
67
63
  constructor(
68
64
  private _identity: EdgeIdentity,
@@ -73,7 +69,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
73
69
  this._baseHttpUrl = getEdgeUrlWithProtocol(_config.socketEndpoint, 'http');
74
70
  }
75
71
 
76
- // TODO(burdon): Attach logging.
72
+ @logInfo
77
73
  public get info() {
78
74
  return {
79
75
  open: this.isOpen,
@@ -83,7 +79,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
83
79
  }
84
80
 
85
81
  get isConnected() {
86
- return Boolean(this._ws) && this._ready.state === TriggerState.RESOLVED;
82
+ return Boolean(this._currentConnection) && this._ready.state === TriggerState.RESOLVED;
87
83
  }
88
84
 
89
85
  get identityKey() {
@@ -98,13 +94,32 @@ export class EdgeClient extends Resource implements EdgeConnection {
98
94
  if (identity.identityKey !== this._identity.identityKey || identity.peerKey !== this._identity.peerKey) {
99
95
  log('Edge identity changed', { identity, oldIdentity: this._identity });
100
96
  this._identity = identity;
97
+ this._closeCurrentConnection(new EdgeIdentityChangedError());
101
98
  this._persistentLifecycle.scheduleRestart();
102
99
  }
103
100
  }
104
101
 
105
- public addListener(listener: MessageListener): () => void {
106
- this._listeners.add(listener);
107
- return () => this._listeners.delete(listener);
102
+ public onMessage(listener: MessageListener): () => void {
103
+ this._messageListeners.add(listener);
104
+ return () => this._messageListeners.delete(listener);
105
+ }
106
+
107
+ public onReconnected(listener: () => void): () => void {
108
+ this._reconnectListeners.add(listener);
109
+ if (this._ready.state === TriggerState.RESOLVED) {
110
+ // Microtask so that listener is always called asynchronously, no matter the state of the ready trigger
111
+ // at the moment of registration.
112
+ scheduleMicroTask(this._ctx, () => {
113
+ if (this._reconnectListeners.has(listener)) {
114
+ try {
115
+ listener();
116
+ } catch (error) {
117
+ log.catch(error);
118
+ }
119
+ }
120
+ });
121
+ }
122
+ return () => this._reconnectListeners.delete(listener);
108
123
  }
109
124
 
110
125
  /**
@@ -122,97 +137,96 @@ export class EdgeClient extends Resource implements EdgeConnection {
122
137
  */
123
138
  protected override async _close() {
124
139
  log('closing...', { peerKey: this._identity.peerKey });
140
+ this._closeCurrentConnection();
125
141
  await this._persistentLifecycle.close();
126
142
  }
127
143
 
128
- private async _openWebSocket() {
144
+ private async _connect(): Promise<EdgeWsConnection | undefined> {
129
145
  if (this._ctx.disposed) {
130
- return;
146
+ return undefined;
131
147
  }
132
- const path = `/ws/${this._identity.identityKey}/${this._identity.peerKey}`;
148
+
149
+ const identity = this._identity;
150
+ const path = `/ws/${identity.identityKey}/${identity.peerKey}`;
133
151
  const protocolHeader = this._config.disableAuth ? undefined : await this._createAuthHeader(path);
152
+ if (this._identity !== identity) {
153
+ log('identity changed during auth header request');
154
+ return undefined;
155
+ }
134
156
 
157
+ const restartRequired = new Trigger();
135
158
  const url = new URL(path, this._baseWsUrl);
136
159
  log('Opening websocket', { url: url.toString(), protocolHeader });
137
- this._ws = new WebSocket(url, protocolHeader ? [protocolHeader] : []);
138
-
139
- this._ws.onopen = () => {
140
- log('opened', this.info);
141
- this._ready.wake();
142
- this.connected.emit();
143
- };
144
- this._ws.onclose = () => {
145
- log('closed', this.info);
146
- this._persistentLifecycle.scheduleRestart();
147
- };
148
- this._ws.onerror = (event) => {
149
- log.warn('EdgeClient socket error', { error: event.error, info: event.message });
150
- this._persistentLifecycle.scheduleRestart();
151
- };
152
- /**
153
- * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
154
- */
155
- this._ws.onmessage = async (event) => {
156
- if (event.data === '__pong__') {
157
- this._onHeartbeat();
158
- return;
159
- }
160
- const data = await toUint8Array(event.data);
161
- const message = buf.fromBinary(MessageSchema, data);
162
- log('received', { peerKey: this._identity.peerKey, payload: protocol.getPayloadType(message) });
163
- if (message) {
164
- for (const listener of this._listeners) {
165
- try {
166
- await listener(message);
167
- } catch (err) {
168
- log.error('processing', { err, payload: protocol.getPayloadType(message) });
160
+ const connection = new EdgeWsConnection(
161
+ identity,
162
+ { url, protocolHeader },
163
+ {
164
+ onConnected: () => {
165
+ if (this._isActive(connection)) {
166
+ this._ready.wake();
167
+ this._notifyReconnected();
168
+ } else {
169
+ log.verbose('connected callback ignored, because connection is not active');
169
170
  }
170
- }
171
- }
172
- };
173
-
174
- // TODO(dmaretskyi): Potential race condition here since web socket errors don't resolve this trigger.
175
- await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
176
- log('Websocket is ready', { identity: this._identity.identityKey, peer: this._identity.peerKey });
177
-
178
- // TODO(dmaretskyi): Potential leak: context re-assigned without disposing the previous one.
179
- this._keepaliveCtx = new Context();
180
- scheduleTaskInterval(
181
- this._keepaliveCtx,
182
- async () => {
183
- // TODO(mykola): use RFC6455 ping/pong once implemented in the browser?
184
- // Cloudflare's worker responds to this `without interrupting hibernation`. https://developers.cloudflare.com/durable-objects/api/websockets/#setwebsocketautoresponse
185
- this._ws?.send('__ping__');
171
+ },
172
+ onRestartRequired: () => {
173
+ if (this._isActive(connection)) {
174
+ this._closeCurrentConnection();
175
+ this._persistentLifecycle.scheduleRestart();
176
+ } else {
177
+ log.verbose('restart requested by inactive connection');
178
+ }
179
+ restartRequired.wake();
180
+ },
181
+ onMessage: (message) => {
182
+ if (this._isActive(connection)) {
183
+ this._notifyMessageReceived(message);
184
+ } else {
185
+ log.verbose('ignored a message on inactive connection', {
186
+ from: message.source,
187
+ type: message.payload?.typeUrl,
188
+ });
189
+ }
190
+ },
186
191
  },
187
- SIGNAL_KEEPALIVE_INTERVAL,
188
192
  );
189
- this._ws.send('__ping__');
190
- this._onHeartbeat();
193
+ this._currentConnection = connection;
194
+
195
+ await connection.open();
196
+ // Race with restartRequired so that restart is not blocked by _connect execution.
197
+ // Wait on ready to attempt a reconnect if it times out.
198
+ await Promise.race([this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT }), restartRequired]);
199
+
200
+ return connection;
201
+ }
202
+
203
+ private async _disconnect(state: EdgeWsConnection) {
204
+ await state.close();
205
+ }
206
+
207
+ private _closeCurrentConnection(error: Error = new EdgeConnectionClosedError()) {
208
+ this._currentConnection = undefined;
209
+ this._ready.throw(error);
210
+ this._ready.reset();
191
211
  }
192
212
 
193
- private async _closeWebSocket() {
194
- if (!this._ws) {
195
- return;
213
+ private _notifyReconnected() {
214
+ for (const listener of this._reconnectListeners) {
215
+ try {
216
+ listener();
217
+ } catch (err) {
218
+ log.error('ws reconnect listener failed', { err });
219
+ }
196
220
  }
197
- try {
198
- this._ready.throw(this.isOpen ? new EdgeIdentityChangedError() : new EdgeConnectionClosedError());
199
- this._ready.reset();
200
- void this._keepaliveCtx?.dispose();
201
- this._keepaliveCtx = undefined;
202
- void this._heartBeatContext?.dispose();
203
- this._heartBeatContext = undefined;
204
-
205
- // NOTE: Remove event handlers to avoid scheduling restart.
206
- this._ws.onopen = () => {};
207
- this._ws.onclose = () => {};
208
- this._ws.onerror = () => {};
209
- this._ws.close();
210
- this._ws = undefined;
211
- } catch (err) {
212
- if (err instanceof Error && err.message.includes('WebSocket is closed before the connection is established.')) {
213
- return;
221
+ }
222
+
223
+ private _notifyMessageReceived(message: Message) {
224
+ for (const listener of this._messageListeners) {
225
+ try {
226
+ listener(message);
227
+ } catch (err) {
228
+ log.error('ws incoming message processing failed', { err, payload: protocol.getPayloadType(message) });
214
229
  }
215
- log.warn('Error closing websocket', { err });
216
230
  }
217
231
  }
218
232
 
@@ -225,9 +239,11 @@ export class EdgeClient extends Resource implements EdgeConnection {
225
239
  log('waiting for websocket to become ready');
226
240
  await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
227
241
  }
228
- if (!this._ws) {
242
+
243
+ if (!this._currentConnection) {
229
244
  throw new EdgeConnectionClosedError();
230
245
  }
246
+
231
247
  if (
232
248
  message.source &&
233
249
  (message.source.peerKey !== this._identity.peerKey || message.source.identityKey !== this.identityKey)
@@ -235,23 +251,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
235
251
  throw new EdgeIdentityChangedError();
236
252
  }
237
253
 
238
- log('sending...', { peerKey: this._identity.peerKey, payload: protocol.getPayloadType(message) });
239
- this._ws.send(buf.toBinary(MessageSchema, message));
240
- }
241
-
242
- private _onHeartbeat() {
243
- if (this._lifecycleState !== LifecycleState.OPEN) {
244
- return;
245
- }
246
- void this._heartBeatContext?.dispose();
247
- this._heartBeatContext = new Context();
248
- scheduleTask(
249
- this._heartBeatContext,
250
- () => {
251
- this._persistentLifecycle.scheduleRestart();
252
- },
253
- 2 * SIGNAL_KEEPALIVE_INTERVAL,
254
- );
254
+ this._currentConnection.send(message);
255
255
  }
256
256
 
257
257
  private async _createAuthHeader(path: string): Promise<string | undefined> {
@@ -265,6 +265,8 @@ export class EdgeClient extends Resource implements EdgeConnection {
265
265
  return undefined;
266
266
  }
267
267
  }
268
+
269
+ private _isActive = (connection: EdgeWsConnection) => connection === this._currentConnection;
268
270
  }
269
271
 
270
272
  const encodePresentationWsAuthHeader = (encodedPresentation: Uint8Array): string => {