@dxos/edge-client 0.6.9 → 0.6.10-main.bbdfaa4

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 +276 -138
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +263 -129
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/types/src/{client.d.ts → edge-client.d.ts} +21 -16
  8. package/dist/types/src/edge-client.d.ts.map +1 -0
  9. package/dist/types/src/edge-client.test.d.ts +2 -0
  10. package/dist/types/src/edge-client.test.d.ts.map +1 -0
  11. package/dist/types/src/errors.d.ts +4 -0
  12. package/dist/types/src/errors.d.ts.map +1 -0
  13. package/dist/types/src/index.d.ts +1 -1
  14. package/dist/types/src/index.d.ts.map +1 -1
  15. package/dist/types/src/persistent-lifecycle.d.ts +42 -0
  16. package/dist/types/src/persistent-lifecycle.d.ts.map +1 -0
  17. package/dist/types/src/persistent-lifecycle.test.d.ts +2 -0
  18. package/dist/types/src/persistent-lifecycle.test.d.ts.map +1 -0
  19. package/dist/types/src/test-utils.d.ts +11 -0
  20. package/dist/types/src/test-utils.d.ts.map +1 -0
  21. package/dist/types/src/websocket.test.d.ts +2 -0
  22. package/dist/types/src/websocket.test.d.ts.map +1 -0
  23. package/package.json +13 -9
  24. package/src/edge-client.test.ts +50 -0
  25. package/src/edge-client.ts +226 -0
  26. package/src/errors.ts +9 -0
  27. package/src/index.ts +1 -1
  28. package/src/persistent-lifecycle.test.ts +71 -0
  29. package/src/persistent-lifecycle.ts +106 -0
  30. package/src/protocol.test.ts +1 -1
  31. package/src/test-utils.ts +49 -0
  32. package/src/websocket.test.ts +35 -0
  33. package/dist/types/src/client.d.ts.map +0 -1
  34. package/src/client.ts +0 -179
@@ -0,0 +1,106 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { DeferredTask, sleep, synchronized } from '@dxos/async';
6
+ import { cancelWithContext, LifecycleState, Resource } from '@dxos/context';
7
+ import { warnAfterTimeout } from '@dxos/debug';
8
+ import { log } from '@dxos/log';
9
+
10
+ const INIT_RESTART_DELAY = 100;
11
+ const DEFAULT_MAX_RESTART_DELAY = 5000;
12
+
13
+ export type PersistentLifecycleParams = {
14
+ /**
15
+ * Create connection.
16
+ * If promise resolves successfully, connection is considered established.
17
+ */
18
+ start: () => Promise<void>;
19
+
20
+ /**
21
+ * Reset connection to initial state.
22
+ */
23
+ stop: () => Promise<void>;
24
+
25
+ /**
26
+ * Called after successful start.
27
+ */
28
+ onRestart?: () => Promise<void>;
29
+
30
+ /**
31
+ * Maximum delay between restartion attempts.
32
+ * Default: 5000ms
33
+ */
34
+ maxRestartDelay?: number;
35
+ };
36
+
37
+ /**
38
+ * Handles restarts (e.g. persists connection).
39
+ * Restarts are scheduled with exponential backoff.
40
+ */
41
+ export class PersistentLifecycle extends Resource {
42
+ private readonly _start: () => Promise<void>;
43
+ private readonly _stop: () => Promise<void>;
44
+ private readonly _onRestart?: () => Promise<void>;
45
+ private readonly _maxRestartDelay: number;
46
+
47
+ private _restartTask?: DeferredTask = undefined;
48
+ private _restartAfter = 0;
49
+
50
+ constructor({ start, stop, onRestart, maxRestartDelay = DEFAULT_MAX_RESTART_DELAY }: PersistentLifecycleParams) {
51
+ super();
52
+ this._start = start;
53
+ this._stop = stop;
54
+ this._onRestart = onRestart;
55
+ this._maxRestartDelay = maxRestartDelay;
56
+ }
57
+
58
+ @synchronized
59
+ protected override async _open() {
60
+ this._restartTask = new DeferredTask(this._ctx, async () => {
61
+ try {
62
+ await this._restart();
63
+ } catch (err) {
64
+ log.warn('Restart failed', { err });
65
+ this._restartTask?.schedule();
66
+ }
67
+ });
68
+ await this._start().catch((err) => {
69
+ log.warn('Start failed', { err });
70
+ this._restartTask?.schedule();
71
+ });
72
+ }
73
+
74
+ protected override async _close() {
75
+ await this._restartTask?.join();
76
+ await this._stop();
77
+ this._restartTask = undefined;
78
+ }
79
+
80
+ private async _restart() {
81
+ log(`restarting in ${this._restartAfter}ms`, { state: this._lifecycleState });
82
+ await this._stop();
83
+ if (this._lifecycleState !== LifecycleState.OPEN) {
84
+ return;
85
+ }
86
+ await cancelWithContext(this._ctx!, sleep(this._restartAfter));
87
+ this._restartAfter = Math.min(Math.max(this._restartAfter * 2, INIT_RESTART_DELAY), this._maxRestartDelay);
88
+
89
+ // May fail if the connection is not established.
90
+ await warnAfterTimeout(5_000, 'Connection establishment takes too long', () => this._start());
91
+
92
+ this._restartAfter = 0;
93
+ await this._onRestart?.();
94
+ }
95
+
96
+ /**
97
+ * Scheduling restart should be done from outside.
98
+ */
99
+ @synchronized
100
+ scheduleRestart() {
101
+ if (this._lifecycleState !== LifecycleState.OPEN) {
102
+ return;
103
+ }
104
+ this._restartTask!.schedule();
105
+ }
106
+ }
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  import { expect } from 'chai';
6
- import { describe, test } from 'vitest';
7
6
 
8
7
  import { buf } from '@dxos/protocols/buf';
9
8
  import {
@@ -12,6 +11,7 @@ import {
12
11
  SwarmRequestSchema,
13
12
  SwarmResponseSchema,
14
13
  } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
14
+ import { describe, test } from '@dxos/test';
15
15
 
16
16
  import { Protocol } from './protocol';
17
17
 
@@ -0,0 +1,49 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import WebSocket from 'isomorphic-ws';
6
+
7
+ import { Trigger } from '@dxos/async';
8
+ import { log } from '@dxos/log';
9
+ import { buf } from '@dxos/protocols/buf';
10
+ import { MessageSchema, TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
11
+ import { afterTest } from '@dxos/test';
12
+
13
+ import { protocol } from './defs';
14
+ import { toUint8Array } from './protocol';
15
+
16
+ export const DEFAULT_PORT = 8080;
17
+
18
+ export const createTestWsServer = async (port = DEFAULT_PORT) => {
19
+ const server = new WebSocket.Server({ port });
20
+ let connection: WebSocket;
21
+ const closeTrigger = new Trigger();
22
+ server.on('connection', (ws) => {
23
+ connection = ws;
24
+ ws.on('error', (err) => log.catch(err));
25
+ ws.on('message', async (data) => {
26
+ if (String(data) === '__ping__') {
27
+ ws.send('__pong__');
28
+ return;
29
+ }
30
+ log('message', {
31
+ payload: protocol.getPayload(buf.fromBinary(MessageSchema, await toUint8Array(data)), TextMessageSchema),
32
+ });
33
+ });
34
+ ws.on('close', () => closeTrigger.wake());
35
+ });
36
+
37
+ afterTest(() => server.close());
38
+ return {
39
+ server,
40
+ /**
41
+ * Close the server connection.
42
+ */
43
+ error: async () => {
44
+ connection.close(1011);
45
+ await closeTrigger.wait();
46
+ },
47
+ endpoint: `ws://localhost:${port}`,
48
+ };
49
+ };
@@ -0,0 +1,35 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { expect } from 'chai';
6
+ import WebSocket from 'isomorphic-ws';
7
+
8
+ import { Trigger, TriggerState } from '@dxos/async';
9
+ import { describe, test } from '@dxos/test';
10
+
11
+ import { createTestWsServer } from './test-utils';
12
+
13
+ describe('WebSocket', () => {
14
+ test('swap `onclose` handler ', async () => {
15
+ const { endpoint } = await createTestWsServer();
16
+ const ws = new WebSocket(endpoint);
17
+ const opened = new Trigger();
18
+ ws.onopen = () => {
19
+ opened.wake();
20
+ };
21
+ const closeCalled1 = new Trigger();
22
+ ws.onclose = () => {
23
+ closeCalled1.wake();
24
+ };
25
+ const closeCalled2 = new Trigger();
26
+ ws.onclose = () => {
27
+ closeCalled2.wake();
28
+ };
29
+
30
+ await opened.wait();
31
+ ws.close();
32
+ await closeCalled2.wait();
33
+ expect(closeCalled1.state === TriggerState.WAITING).is.true;
34
+ });
35
+ });
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/client.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkB,QAAQ,EAAE,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAEzE,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,4CAA4C,CAAC;AAGzF,OAAO,EAAE,KAAK,QAAQ,EAAgB,MAAM,YAAY,CAAC;AAIzD,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAEzE,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAAC,SAAS,CAAC;IACzD,IAAI,IAAI,IAAI,GAAG,CAAC;IAChB,IAAI,WAAW,IAAI,SAAS,CAAC;IAC7B,IAAI,SAAS,IAAI,SAAS,CAAC;IAC3B,IAAI,MAAM,IAAI,OAAO,CAAC;IACtB,WAAW,CAAC,MAAM,EAAE;QAAE,SAAS,EAAE,SAAS,CAAC;QAAC,WAAW,EAAE,SAAS,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5E,WAAW,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,IAAI,CAAC;IACnD,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB,CAAC;AAEF;;GAEG;AAGH,qBAAa,UAAW,SAAQ,QAAS,YAAW,cAAc;IAQ9D,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,UAAU;IAClB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAT1B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA8B;IACzD,OAAO,CAAC,UAAU,CAAC,CAA4B;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAW;IACrC,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,GAAG,CAAC,CAAwB;gBAG1B,YAAY,EAAE,SAAS,EACvB,UAAU,EAAE,SAAS,EACZ,OAAO,EAAE,eAAe;IAO3C,IAAW,IAAI;;;;MAMd;IAED,IAAI,WAAW,cAEd;IAED,IAAI,SAAS,cAEZ;IAED,IAAW,MAAM,YAEhB;IAED,WAAW,CAAC,EAAE,SAAS,EAAE,WAAW,EAAE,EAAE;QAAE,SAAS,EAAE,SAAS,CAAC;QAAC,WAAW,EAAE,SAAS,CAAA;KAAE;IAUjF,WAAW,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,IAAI;IAKzD;;OAEG;cACsB,KAAK;IAa9B;;OAEG;cACsB,MAAM;YAOjB,cAAc;YAwCd,eAAe;IAM7B;;;OAGG;IAEU,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;CAOnD"}
package/src/client.ts DELETED
@@ -1,179 +0,0 @@
1
- //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- import WebSocket from 'isomorphic-ws';
6
-
7
- import { Trigger } from '@dxos/async';
8
- import { LifecycleState, Resource, type Lifecycle } from '@dxos/context';
9
- import { invariant } from '@dxos/invariant';
10
- import { type PublicKey } from '@dxos/keys';
11
- import { log } from '@dxos/log';
12
- import { buf } from '@dxos/protocols/buf';
13
- import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
14
-
15
- import { protocol } from './defs';
16
- import { type Protocol, toUint8Array } from './protocol';
17
-
18
- const DEFAULT_TIMEOUT = 5_000;
19
-
20
- export type MessageListener = (message: Message) => void | Promise<void>;
21
-
22
- export interface EdgeConnection extends Required<Lifecycle> {
23
- get info(): any;
24
- get identityKey(): PublicKey;
25
- get deviceKey(): PublicKey;
26
- get isOpen(): boolean;
27
- setIdentity(params: { deviceKey: PublicKey; identityKey: PublicKey }): void;
28
- addListener(listener: MessageListener): () => void;
29
- send(message: Message): Promise<void>;
30
- }
31
-
32
- export type MessengerConfig = {
33
- socketEndpoint: string;
34
- timeout?: number;
35
- protocol?: Protocol;
36
- };
37
-
38
- /**
39
- * Messenger client.
40
- */
41
- // TODO(dmaretskyi): Rename EdgeClient.
42
- // TODO(mykola): Handle reconnections.
43
- export class EdgeClient extends Resource implements EdgeConnection {
44
- private readonly _listeners = new Set<MessageListener>();
45
- private _reconnect?: Promise<void> = undefined;
46
- private readonly _protocol: Protocol;
47
- private _ready = new Trigger();
48
- private _ws?: WebSocket = undefined;
49
-
50
- constructor(
51
- private _identityKey: PublicKey,
52
- private _deviceKey: PublicKey,
53
- private readonly _config: MessengerConfig,
54
- ) {
55
- super();
56
- this._protocol = this._config.protocol ?? protocol;
57
- }
58
-
59
- // TODO(burdon): Attach logging.
60
- public get info() {
61
- return {
62
- open: this.isOpen,
63
- identity: this._identityKey,
64
- device: this._deviceKey,
65
- };
66
- }
67
-
68
- get identityKey() {
69
- return this._identityKey;
70
- }
71
-
72
- get deviceKey() {
73
- return this._deviceKey;
74
- }
75
-
76
- public get isOpen() {
77
- return this._lifecycleState === LifecycleState.OPEN;
78
- }
79
-
80
- setIdentity({ deviceKey, identityKey }: { deviceKey: PublicKey; identityKey: PublicKey }) {
81
- this._deviceKey = deviceKey;
82
- this._identityKey = identityKey;
83
- this._reconnect = this._closeWebSocket()
84
- .then(async () => {
85
- await this._openWebSocket();
86
- })
87
- .catch((err) => log.catch(err));
88
- }
89
-
90
- public addListener(listener: MessageListener): () => void {
91
- this._listeners.add(listener);
92
- return () => this._listeners.delete(listener);
93
- }
94
-
95
- /**
96
- * Open connection to messaging service.
97
- */
98
- protected override async _open() {
99
- await this._reconnect;
100
- if (this._ws) {
101
- return;
102
- }
103
- invariant(this._deviceKey && this._identityKey);
104
- log.info('opening...', { info: this.info });
105
-
106
- // TODO: handle reconnects
107
- await this._openWebSocket();
108
- log.info('opened', { info: this.info });
109
- }
110
-
111
- /**
112
- * Close connection and free resources.
113
- */
114
- protected override async _close() {
115
- log('closing...', { deviceKey: this._deviceKey });
116
- await this._reconnect;
117
- await this._closeWebSocket();
118
- log('closed', { deviceKey: this._deviceKey });
119
- }
120
-
121
- private async _openWebSocket() {
122
- const url = new URL(`/ws/${this._identityKey.toHex()}/${this._deviceKey.toHex()}`, this._config.socketEndpoint);
123
- this._ws = new WebSocket(url);
124
- Object.assign<WebSocket, Partial<WebSocket>>(this._ws, {
125
- onopen: () => {
126
- log('opened', this.info);
127
- this._ready.wake();
128
- },
129
-
130
- onclose: () => {
131
- log('closed', this.info);
132
- },
133
-
134
- onerror: (event) => {
135
- log.catch(event.error, this.info);
136
- this._ready.throw(event.error);
137
- },
138
-
139
- /**
140
- * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
141
- */
142
- onmessage: async (event) => {
143
- const data = await toUint8Array(event.data);
144
- const message = buf.fromBinary(MessageSchema, data);
145
- log('received', { deviceKey: this._deviceKey, payload: protocol.getPayloadType(message) });
146
- if (message) {
147
- for (const listener of this._listeners) {
148
- try {
149
- await listener(message);
150
- } catch (err) {
151
- log.error('processing', { err, payload: protocol.getPayloadType(message) });
152
- }
153
- }
154
- }
155
- },
156
- });
157
-
158
- await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
159
- }
160
-
161
- private async _closeWebSocket() {
162
- this._ready.reset();
163
- this._ws!.close();
164
- this._ws = undefined;
165
- }
166
-
167
- /**
168
- * Send message.
169
- * NOTE: The message is guaranteed to be delivered but the service must respond with a message to confirm processing.
170
- */
171
- // TODO(burdon): Implement ACK?
172
- public async send(message: Message): Promise<void> {
173
- await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
174
- invariant(this._ws);
175
- invariant(!message.source || message.source.peerKey === this._deviceKey.toHex());
176
- log('sending...', { deviceKey: this._deviceKey, payload: protocol.getPayloadType(message) });
177
- this._ws.send(buf.toBinary(MessageSchema, message));
178
- }
179
- }