@dxos/echo-pipeline 0.3.9-main.eb5df3b → 0.3.9-main.ee839a8

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.
@@ -4,7 +4,10 @@
4
4
 
5
5
  import expect from 'expect';
6
6
 
7
- import { sleep } from '@dxos/async';
7
+ import { Trigger, asyncTimeout, sleep } from '@dxos/async';
8
+ import { type Message, NetworkAdapter, type PeerId, Repo } from '@dxos/automerge/automerge-repo';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
8
11
  import { StorageType, createStorage } from '@dxos/random-access-storage';
9
12
  import { describe, test } from '@dxos/test';
10
13
 
@@ -39,4 +42,81 @@ describe('AutomergeHost', () => {
39
42
  await handle2.whenReady();
40
43
  expect(handle2.docSync().text).toEqual('Hello world');
41
44
  });
45
+
46
+ test('basic networking', async () => {
47
+ type Context = { client: Trigger<TestAdapter>; host: Trigger<TestAdapter> };
48
+ const context: Context = {
49
+ client: new Trigger<TestAdapter>(),
50
+ host: new Trigger<TestAdapter>(),
51
+ };
52
+
53
+ class TestAdapter extends NetworkAdapter {
54
+ constructor(public readonly context: Context, public readonly role: 'host' | 'client') {
55
+ super();
56
+ }
57
+
58
+ // NOTE: Emitting `ready` event in NetworkAdapter`s constructor causes a race condition
59
+ // because `Repo` waits for `ready` event (which it never receives) before it starts using the adapter.
60
+ ready() {
61
+ this.emit('ready', { network: this });
62
+ }
63
+
64
+ override connect(peerId: PeerId) {
65
+ this.peerId = peerId;
66
+ context[this.role].wake(this);
67
+ context[this.role === 'host' ? 'client' : 'host']
68
+ .wait()
69
+ .then((adapter) => {
70
+ invariant(adapter.peerId, 'Peer id is not set');
71
+ this.emit('peer-candidate', { peerId: adapter.peerId });
72
+ })
73
+ .catch((error) => {
74
+ log.catch(error);
75
+ });
76
+ }
77
+
78
+ override send(message: Message) {
79
+ context[this.role === 'host' ? 'client' : 'host']
80
+ .wait()
81
+ .then((adapter) => {
82
+ adapter.receive(message);
83
+ })
84
+ .catch((error) => {
85
+ log.catch(error);
86
+ });
87
+ }
88
+
89
+ override disconnect() {
90
+ this.peerId = undefined;
91
+ this.context[this.role].reset();
92
+ }
93
+
94
+ receive(message: Message) {
95
+ invariant(this.peerId, 'Peer id is not set');
96
+ this.emit('message', message);
97
+ }
98
+ }
99
+
100
+ const hostAdapter = new TestAdapter(context, 'host');
101
+ const clientAdapter = new TestAdapter(context, 'client');
102
+
103
+ const host = new Repo({
104
+ network: [hostAdapter],
105
+ });
106
+ const client = new Repo({
107
+ network: [clientAdapter],
108
+ });
109
+ hostAdapter.ready();
110
+ clientAdapter.ready();
111
+
112
+ const handle = host.create();
113
+ const text = 'Hello world';
114
+ handle.change((doc: any) => {
115
+ doc.text = text;
116
+ });
117
+
118
+ const docOnClient = client.find(handle.url);
119
+ await asyncTimeout(docOnClient.whenReady(), 3_000);
120
+ expect(docOnClient.docSync().text).toEqual(text);
121
+ });
42
122
  });
@@ -14,8 +14,11 @@ import {
14
14
  } from '@dxos/automerge/automerge-repo';
15
15
  import { Stream } from '@dxos/codec-protobuf';
16
16
  import { invariant } from '@dxos/invariant';
17
- import { type SyncRepoRequest, type SyncRepoResponse } from '@dxos/protocols/proto/dxos/echo/service';
17
+ import { log } from '@dxos/log';
18
+ import { type HostInfo, type SyncRepoRequest, type SyncRepoResponse } from '@dxos/protocols/proto/dxos/echo/service';
19
+ import { type PeerInfo } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
18
20
  import { type Directory } from '@dxos/random-access-storage';
21
+ import { AutomergeReplicator } from '@dxos/teleport-extension-automerge-replicator';
19
22
  import { arrayToBuffer, bufferToArray } from '@dxos/util';
20
23
 
21
24
  export class AutomergeHost {
@@ -29,22 +32,28 @@ export class AutomergeHost {
29
32
  this._clientNetwork = new LocalHostNetworkAdapter();
30
33
  this._storage = new AutomergeStorageAdapter(storageDirectory);
31
34
  this._repo = new Repo({
32
- network: [
33
- // this._meshNetwork,
34
- this._clientNetwork,
35
- ],
36
-
35
+ network: [this._clientNetwork, this._meshNetwork],
37
36
  storage: this._storage,
38
37
 
39
38
  // TODO(dmaretskyi): Share based on HALO permissions and space affinity.
40
39
  sharePolicy: async (peerId, documentId) => true, // Share everything.
41
40
  });
41
+ this._clientNetwork.ready();
42
+ this._meshNetwork.ready();
42
43
  }
43
44
 
44
45
  get repo(): Repo {
45
46
  return this._repo;
46
47
  }
47
48
 
49
+ async close() {
50
+ await this._clientNetwork.close();
51
+ }
52
+
53
+ //
54
+ // Methods for client-services.
55
+ //
56
+
48
57
  syncRepo(request: SyncRepoRequest): Stream<SyncRepoResponse> {
49
58
  return this._clientNetwork.syncRepo(request);
50
59
  }
@@ -52,6 +61,18 @@ export class AutomergeHost {
52
61
  sendSyncMessage(request: SyncRepoRequest): Promise<void> {
53
62
  return this._clientNetwork.sendSyncMessage(request);
54
63
  }
64
+
65
+ getHostInfo(): HostInfo {
66
+ return this._clientNetwork.getHostInfo();
67
+ }
68
+
69
+ //
70
+ // Mesh replication.
71
+ //
72
+
73
+ createExtension(): AutomergeReplicator {
74
+ return this._meshNetwork.createExtension();
75
+ }
55
76
  }
56
77
 
57
78
  type ClientSyncState = {
@@ -66,15 +87,19 @@ type ClientSyncState = {
66
87
  class LocalHostNetworkAdapter extends NetworkAdapter {
67
88
  private readonly _peers: Map<PeerId, ClientSyncState> = new Map();
68
89
 
69
- constructor() {
70
- super();
71
-
90
+ /**
91
+ * Emits `ready` event. That signals to `Repo` that it can start using the adapter.
92
+ */
93
+ ready() {
94
+ // NOTE: Emitting `ready` event in NetworkAdapter`s constructor causes a race condition
95
+ // because `Repo` waits for `ready` event (which it never receives) before it starts using the adapter.
72
96
  this.emit('ready', {
73
97
  network: this,
74
98
  });
75
99
  }
76
100
 
77
101
  override connect(peerId: PeerId): void {
102
+ this.peerId = peerId;
78
103
  // No-op. Client always connects first
79
104
  }
80
105
 
@@ -84,8 +109,14 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
84
109
  peer.send(message);
85
110
  }
86
111
 
112
+ async close() {
113
+ this._peers.forEach((peer) => peer.disconnect());
114
+ this.emit('close');
115
+ }
116
+
87
117
  override disconnect(): void {
88
- throw new Error('Method not implemented.');
118
+ // TODO(mykola): `disconnect` is not used anywhere in `Repo` from `@automerge/automerge-repo`. Should we remove it?
119
+ // No-op
89
120
  }
90
121
 
91
122
  syncRepo({ id, syncMessage }: SyncRepoRequest): Stream<SyncRepoResponse> {
@@ -120,6 +151,13 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
120
151
  this.emit('message', message);
121
152
  }
122
153
 
154
+ getHostInfo(): HostInfo {
155
+ invariant(this.peerId, 'Peer id not set.');
156
+ return {
157
+ peerId: this.peerId,
158
+ };
159
+ }
160
+
123
161
  private _getPeerId(id: string): PeerId {
124
162
  return id as PeerId;
125
163
  }
@@ -129,16 +167,64 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
129
167
  * Used to replicate with other peers over the network.
130
168
  */
131
169
  class MeshNetworkAdapter extends NetworkAdapter {
170
+ private readonly _extensions: Map<string, AutomergeReplicator> = new Map();
171
+
172
+ /**
173
+ * Emits `ready` event. That signals to `Repo` that it can start using the adapter.
174
+ */
175
+ ready() {
176
+ // NOTE: Emitting `ready` event in NetworkAdapter`s constructor causes a race condition
177
+ // because `Repo` waits for `ready` event (which it never receives) before it starts using the adapter.
178
+ this.emit('ready', {
179
+ network: this,
180
+ });
181
+ }
182
+
132
183
  override connect(peerId: PeerId): void {
133
- throw new Error('Method not implemented.');
184
+ this.peerId = peerId;
134
185
  }
135
186
 
136
187
  override send(message: Message): void {
137
- throw new Error('Method not implemented.');
188
+ const receiverId = message.targetId;
189
+ const extension = this._extensions.get(receiverId);
190
+ invariant(extension, 'Extension not found.');
191
+ extension.sendSyncMessage({ payload: cbor.encode(message) }).catch((err) => log.catch(err));
138
192
  }
139
193
 
140
194
  override disconnect(): void {
141
- throw new Error('Method not implemented.');
195
+ // No-op
196
+ }
197
+
198
+ createExtension(): AutomergeReplicator {
199
+ invariant(this.peerId, 'Peer id not set.');
200
+
201
+ let peerInfo: PeerInfo;
202
+ const extension = new AutomergeReplicator(
203
+ {
204
+ peerId: this.peerId,
205
+ },
206
+ {
207
+ onStartReplication: async (info) => {
208
+ peerInfo = info;
209
+ // TODO(mykola): Fix race condition?
210
+ this._extensions.set(info.id, extension);
211
+ this.emit('peer-candidate', {
212
+ peerId: info.id as PeerId,
213
+ });
214
+ },
215
+ onSyncMessage: async ({ payload }) => {
216
+ const message = cbor.decode(payload) as Message;
217
+ this.emit('message', message);
218
+ },
219
+ onClose: async () => {
220
+ peerInfo &&
221
+ this.emit('peer-disconnected', {
222
+ peerId: peerInfo.id as PeerId,
223
+ });
224
+ },
225
+ },
226
+ );
227
+ return extension;
142
228
  }
143
229
  }
144
230
 
@@ -151,6 +237,9 @@ class AutomergeStorageAdapter extends StorageAdapter {
151
237
  const filename = this._getFilename(key);
152
238
  const file = this._directory.getOrCreateFile(filename);
153
239
  const { size } = await file.stat();
240
+ if (!size || size === 0) {
241
+ return undefined;
242
+ }
154
243
  const buffer = await file.read(0, size);
155
244
  return bufferToArray(buffer);
156
245
  }
@@ -18,6 +18,7 @@ import {
18
18
  type MutationReceipt,
19
19
  type WriteRequest,
20
20
  type SyncRepoResponse,
21
+ type HostInfo,
21
22
  } from '@dxos/protocols/proto/dxos/echo/service';
22
23
  import { ComplexMap } from '@dxos/util';
23
24
 
@@ -205,6 +206,10 @@ export class DataServiceHost {
205
206
  await this._flush();
206
207
  }
207
208
 
209
+ getHostInfo(): Promise<HostInfo> {
210
+ throw new Error('Method not implemented.');
211
+ }
212
+
208
213
  syncRepo(request: SyncRepoRequest): Stream<SyncRepoResponse> {
209
214
  throw new Error('Method not implemented.');
210
215
  }
@@ -16,6 +16,7 @@ import {
16
16
  type FlushRequest,
17
17
  type SyncRepoRequest,
18
18
  type SyncRepoResponse,
19
+ type HostInfo,
19
20
  } from '@dxos/protocols/proto/dxos/echo/service';
20
21
  import { ComplexMap } from '@dxos/util';
21
22
 
@@ -81,11 +82,17 @@ export class DataServiceImpl implements DataService {
81
82
  return host.flush();
82
83
  }
83
84
 
85
+ // Automerge specific.
86
+
87
+ async getHostInfo(request: void): Promise<HostInfo> {
88
+ return this._automergeHost.getHostInfo();
89
+ }
90
+
84
91
  syncRepo(request: SyncRepoRequest): Stream<SyncRepoResponse> {
85
- throw new Error('Method not implemented.');
92
+ return this._automergeHost.syncRepo(request);
86
93
  }
87
94
 
88
95
  sendSyncMessage(request: SyncRepoRequest): Promise<void> {
89
- throw new Error('Method not implemented.');
96
+ return this._automergeHost.sendSyncMessage(request);
90
97
  }
91
98
  }
@@ -39,6 +39,9 @@ export type ConstructSpaceParams = {
39
39
  metadata: SpaceMetadata;
40
40
  swarmIdentity: SwarmIdentity;
41
41
  memberKey: PublicKey;
42
+ /**
43
+ * Called when connection auth passed successful.
44
+ */
42
45
  onNetworkConnection: (session: Teleport) => void;
43
46
  onAuthFailure?: (session: Teleport) => void;
44
47
  };