@dxos/echo-pipeline 0.3.11-main.00a28cb → 0.3.11-main.0999ea0
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.
- package/dist/lib/browser/{chunk-44D2XTQP.mjs → chunk-PB5T4DLC.mjs} +151 -23
- package/dist/lib/browser/{chunk-44D2XTQP.mjs.map → chunk-PB5T4DLC.mjs.map} +3 -3
- package/dist/lib/browser/index.mjs +1 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +2 -2
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-PZONK23P.cjs → chunk-WGNVVL2H.cjs} +149 -25
- package/dist/lib/node/{chunk-PZONK23P.cjs.map → chunk-WGNVVL2H.cjs.map} +3 -3
- package/dist/lib/node/index.cjs +26 -28
- package/dist/lib/node/index.cjs.map +2 -2
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +17 -17
- package/dist/lib/node/testing/index.cjs.map +3 -3
- package/dist/types/src/automerge/automerge-host.d.ts +24 -2
- package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
- package/dist/types/src/automerge/index.d.ts +1 -1
- package/dist/types/src/automerge/index.d.ts.map +1 -1
- package/dist/types/src/space/space-manager.d.ts +2 -2
- package/dist/types/src/space/space-manager.d.ts.map +1 -1
- package/package.json +33 -33
- package/src/automerge/automerge-host.test.ts +291 -34
- package/src/automerge/automerge-host.ts +110 -16
- package/src/automerge/index.ts +1 -1
- package/src/space/space-manager.ts +3 -3
- package/src/testing/test-agent-builder.ts +1 -1
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { randomBytes } from 'crypto';
|
|
6
6
|
import expect from 'expect';
|
|
7
|
+
import waitForExpect from 'wait-for-expect';
|
|
7
8
|
|
|
8
9
|
import { Trigger, asyncTimeout, sleep } from '@dxos/async';
|
|
9
10
|
import { type Message, NetworkAdapter, type PeerId, Repo } from '@dxos/automerge/automerge-repo';
|
|
10
11
|
import { invariant } from '@dxos/invariant';
|
|
11
12
|
import { log } from '@dxos/log';
|
|
12
13
|
import { StorageType, createStorage } from '@dxos/random-access-storage';
|
|
13
|
-
import {
|
|
14
|
+
import { TestBuilder as TeleportBuilder, TestPeer as TeleportPeer } from '@dxos/teleport/testing';
|
|
15
|
+
import { afterTest, describe, test } from '@dxos/test';
|
|
14
16
|
import { arrayToBuffer, bufferToArray } from '@dxos/util';
|
|
15
17
|
|
|
16
|
-
import { AutomergeHost, AutomergeStorageAdapter } from './automerge-host';
|
|
18
|
+
import { AutomergeHost, AutomergeStorageAdapter, MeshNetworkAdapter } from './automerge-host';
|
|
17
19
|
|
|
18
20
|
describe('AutomergeHost', () => {
|
|
19
21
|
test('can create documents', () => {
|
|
@@ -46,13 +48,12 @@ describe('AutomergeHost', () => {
|
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
test('basic networking', async () => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const clientAdapter = new TestAdapter(context, 'client');
|
|
51
|
+
const hostAdapter: TestAdapter = new TestAdapter({
|
|
52
|
+
send: (message: Message) => clientAdapter.receive(message),
|
|
53
|
+
});
|
|
54
|
+
const clientAdapter: TestAdapter = new TestAdapter({
|
|
55
|
+
send: (message: Message) => hostAdapter.receive(message),
|
|
56
|
+
});
|
|
56
57
|
|
|
57
58
|
const host = new Repo({
|
|
58
59
|
network: [hostAdapter],
|
|
@@ -62,6 +63,10 @@ describe('AutomergeHost', () => {
|
|
|
62
63
|
});
|
|
63
64
|
hostAdapter.ready();
|
|
64
65
|
clientAdapter.ready();
|
|
66
|
+
await hostAdapter.onConnect.wait();
|
|
67
|
+
await clientAdapter.onConnect.wait();
|
|
68
|
+
hostAdapter.peerCandidate(clientAdapter.peerId!);
|
|
69
|
+
clientAdapter.peerCandidate(hostAdapter.peerId!);
|
|
65
70
|
|
|
66
71
|
const handle = host.create();
|
|
67
72
|
const text = 'Hello world';
|
|
@@ -70,11 +75,147 @@ describe('AutomergeHost', () => {
|
|
|
70
75
|
});
|
|
71
76
|
|
|
72
77
|
const docOnClient = client.find(handle.url);
|
|
73
|
-
await asyncTimeout(docOnClient.
|
|
74
|
-
|
|
78
|
+
expect((await asyncTimeout(docOnClient.doc(), 1000)).text).toEqual(text);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('recovering from a lost connection', async () => {
|
|
82
|
+
let connectionState: 'on' | 'off' = 'on';
|
|
83
|
+
|
|
84
|
+
const hostAdapter: TestAdapter = new TestAdapter({
|
|
85
|
+
send: (message: Message) => connectionState === 'on' && sleep(10).then(() => clientAdapter.receive(message)),
|
|
86
|
+
});
|
|
87
|
+
const clientAdapter: TestAdapter = new TestAdapter({
|
|
88
|
+
send: (message: Message) => connectionState === 'on' && sleep(10).then(() => hostAdapter.receive(message)),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const host = new Repo({
|
|
92
|
+
network: [hostAdapter],
|
|
93
|
+
});
|
|
94
|
+
const client = new Repo({
|
|
95
|
+
network: [clientAdapter],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Establish connection.
|
|
99
|
+
hostAdapter.ready();
|
|
100
|
+
clientAdapter.ready();
|
|
101
|
+
await hostAdapter.onConnect.wait();
|
|
102
|
+
await clientAdapter.onConnect.wait();
|
|
103
|
+
hostAdapter.peerCandidate(clientAdapter.peerId!);
|
|
104
|
+
clientAdapter.peerCandidate(hostAdapter.peerId!);
|
|
105
|
+
|
|
106
|
+
const handle = host.create();
|
|
107
|
+
const docOnClient = client.find(handle.url);
|
|
108
|
+
{
|
|
109
|
+
const sanityText = 'Hello world';
|
|
110
|
+
handle.change((doc: any) => {
|
|
111
|
+
doc.sanityText = sanityText;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect((await asyncTimeout(docOnClient.doc(), 1000)).sanityText).toEqual(sanityText);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Disrupt connection.
|
|
118
|
+
const offlineText = 'This has been written while the connection was off';
|
|
119
|
+
{
|
|
120
|
+
connectionState = 'off';
|
|
121
|
+
|
|
122
|
+
handle.change((doc: any) => {
|
|
123
|
+
doc.offlineText = offlineText;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await sleep(100);
|
|
127
|
+
expect((await asyncTimeout(docOnClient.doc(), 1000)).offlineText).toBeUndefined();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Re-establish connection.
|
|
131
|
+
const onlineText = 'This has been written after the connection was re-established';
|
|
132
|
+
{
|
|
133
|
+
connectionState = 'on';
|
|
134
|
+
hostAdapter.peerDisconnected(clientAdapter.peerId!);
|
|
135
|
+
clientAdapter.peerDisconnected(hostAdapter.peerId!);
|
|
136
|
+
hostAdapter.peerCandidate(clientAdapter.peerId!);
|
|
137
|
+
clientAdapter.peerCandidate(hostAdapter.peerId!);
|
|
138
|
+
|
|
139
|
+
handle.change((doc: any) => {
|
|
140
|
+
doc.onlineText = onlineText;
|
|
141
|
+
});
|
|
142
|
+
await sleep(100);
|
|
143
|
+
expect((await asyncTimeout(docOnClient.doc(), 1000)).onlineText).toEqual(onlineText);
|
|
144
|
+
expect((await asyncTimeout(docOnClient.doc(), 1000)).offlineText).toEqual(offlineText);
|
|
145
|
+
}
|
|
75
146
|
});
|
|
76
147
|
|
|
77
|
-
test('
|
|
148
|
+
test('integration test with teleport', async () => {
|
|
149
|
+
const createAutomergeRepo = () => {
|
|
150
|
+
const meshAdapter = new MeshNetworkAdapter();
|
|
151
|
+
const repo = new Repo({
|
|
152
|
+
network: [meshAdapter],
|
|
153
|
+
});
|
|
154
|
+
meshAdapter.ready();
|
|
155
|
+
return { repo, meshAdapter };
|
|
156
|
+
};
|
|
157
|
+
const peer1 = createAutomergeRepo();
|
|
158
|
+
const peer2 = createAutomergeRepo();
|
|
159
|
+
const handle = peer1.repo.create();
|
|
160
|
+
|
|
161
|
+
const teleportBuilder = new TeleportBuilder();
|
|
162
|
+
afterTest(() => teleportBuilder.destroy());
|
|
163
|
+
|
|
164
|
+
const [teleportPeer1, teleportPeer2] = teleportBuilder.createPeers({ factory: () => new TeleportPeer() });
|
|
165
|
+
{
|
|
166
|
+
// Initiate connection.
|
|
167
|
+
const [connection1, connection2] = await teleportBuilder.connect(teleportPeer1, teleportPeer2);
|
|
168
|
+
connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
|
|
169
|
+
connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
|
|
170
|
+
|
|
171
|
+
// Test connection.
|
|
172
|
+
const text = 'Hello world';
|
|
173
|
+
handle.change((doc: any) => {
|
|
174
|
+
doc.text = text;
|
|
175
|
+
});
|
|
176
|
+
const docOnPeer2 = peer2.repo.find(handle.url);
|
|
177
|
+
await waitForExpect(async () => expect((await asyncTimeout(docOnPeer2.doc(), 1000)).text).toEqual(text), 1000);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const offlineText = 'This has been written while the connection was off';
|
|
181
|
+
{
|
|
182
|
+
// Disconnect peers.
|
|
183
|
+
await teleportBuilder.disconnect(teleportPeer1, teleportPeer2);
|
|
184
|
+
|
|
185
|
+
// Make offline changes.
|
|
186
|
+
const offlineText = 'This has been written while the connection was off';
|
|
187
|
+
handle.change((doc: any) => {
|
|
188
|
+
doc.offlineText = offlineText;
|
|
189
|
+
});
|
|
190
|
+
const docOnPeer2 = peer2.repo.find(handle.url);
|
|
191
|
+
await sleep(100);
|
|
192
|
+
expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).toBeUndefined();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
// Reconnect peers.
|
|
197
|
+
const [connection1, connection2] = await teleportBuilder.connect(teleportPeer1, teleportPeer2);
|
|
198
|
+
connection1.teleport.addExtension('automerge', peer1.meshAdapter.createExtension());
|
|
199
|
+
connection2.teleport.addExtension('automerge', peer2.meshAdapter.createExtension());
|
|
200
|
+
|
|
201
|
+
// Wait for offline changes to be synced.
|
|
202
|
+
const docOnPeer2 = peer2.repo.find(handle.url);
|
|
203
|
+
await waitForExpect(
|
|
204
|
+
async () => expect((await asyncTimeout(docOnPeer2.doc(), 1000)).offlineText).toEqual(offlineText),
|
|
205
|
+
1000,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Test connection.
|
|
209
|
+
const onlineText = 'This has been written after the connection was re-established';
|
|
210
|
+
handle.change((doc: any) => {
|
|
211
|
+
doc.onlineText = onlineText;
|
|
212
|
+
});
|
|
213
|
+
await waitForExpect(
|
|
214
|
+
async () => expect((await asyncTimeout(docOnPeer2.doc(), 1000)).onlineText).toEqual(onlineText),
|
|
215
|
+
1000,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
78
219
|
|
|
79
220
|
describe('storage', () => {
|
|
80
221
|
test('load range on node', async () => {
|
|
@@ -100,13 +241,135 @@ describe('AutomergeHost', () => {
|
|
|
100
241
|
]);
|
|
101
242
|
}
|
|
102
243
|
});
|
|
244
|
+
|
|
245
|
+
test('removeRange on node', async () => {
|
|
246
|
+
const root = `/tmp/${randomBytes(16).toString('hex')}`;
|
|
247
|
+
{
|
|
248
|
+
const storage = createStorage({ type: StorageType.NODE, root });
|
|
249
|
+
const adapter = new AutomergeStorageAdapter(storage.createDirectory());
|
|
250
|
+
await adapter.save(['test', '1'], bufferToArray(Buffer.from('one')));
|
|
251
|
+
await adapter.save(['test', '2'], bufferToArray(Buffer.from('two')));
|
|
252
|
+
await adapter.save(['bar', '1'], bufferToArray(Buffer.from('bar')));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
{
|
|
256
|
+
const storage = createStorage({ type: StorageType.NODE, root });
|
|
257
|
+
const adapter = new AutomergeStorageAdapter(storage.createDirectory());
|
|
258
|
+
await adapter.removeRange(['test']);
|
|
259
|
+
const range = await adapter.loadRange(['test']);
|
|
260
|
+
expect(range.map((chunk) => arrayToBuffer(chunk.data!).toString())).toEqual([]);
|
|
261
|
+
const range2 = await adapter.loadRange(['bar']);
|
|
262
|
+
expect(range2.map((chunk) => arrayToBuffer(chunk.data!).toString())).toEqual(['bar']);
|
|
263
|
+
expect(range2.map((chunk) => chunk.key)).toEqual([['bar', '1']]);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('replication though a 4 peer chain', async () => {
|
|
269
|
+
const pairAB = TestAdapter.createPair();
|
|
270
|
+
const pairBC = TestAdapter.createPair();
|
|
271
|
+
const pairCD = TestAdapter.createPair();
|
|
272
|
+
|
|
273
|
+
const repoA = new Repo({
|
|
274
|
+
peerId: 'A' as any,
|
|
275
|
+
network: [pairAB[0]],
|
|
276
|
+
sharePolicy: async () => true,
|
|
277
|
+
});
|
|
278
|
+
const _repoB = new Repo({
|
|
279
|
+
peerId: 'B' as any,
|
|
280
|
+
network: [pairAB[1], pairBC[0]],
|
|
281
|
+
sharePolicy: async () => true,
|
|
282
|
+
});
|
|
283
|
+
const _repoC = new Repo({
|
|
284
|
+
peerId: 'C' as any,
|
|
285
|
+
network: [pairBC[1], pairCD[0]],
|
|
286
|
+
sharePolicy: async () => true,
|
|
287
|
+
});
|
|
288
|
+
const repoD = new Repo({
|
|
289
|
+
peerId: 'D' as any,
|
|
290
|
+
network: [pairCD[1]],
|
|
291
|
+
sharePolicy: async () => true,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
for (const pair of [pairAB, pairBC, pairCD]) {
|
|
295
|
+
pair[0].ready();
|
|
296
|
+
pair[1].ready();
|
|
297
|
+
await pair[0].onConnect.wait();
|
|
298
|
+
await pair[1].onConnect.wait();
|
|
299
|
+
pair[0].peerCandidate(pair[1].peerId!);
|
|
300
|
+
pair[1].peerCandidate(pair[0].peerId!);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const docA = repoA.create();
|
|
304
|
+
// NOTE: Doesn't work if the doc is empty.
|
|
305
|
+
docA.change((doc: any) => {
|
|
306
|
+
doc.text = 'Hello world';
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// If we wait here for replication to finish naturally, the test will pass.
|
|
310
|
+
|
|
311
|
+
const docD = repoD.find(docA.url);
|
|
312
|
+
|
|
313
|
+
await docD.whenReady();
|
|
103
314
|
});
|
|
104
|
-
});
|
|
105
315
|
|
|
106
|
-
|
|
316
|
+
test('replication though a 3 peer chain', async () => {
|
|
317
|
+
const pairAB = TestAdapter.createPair();
|
|
318
|
+
const pairBC = TestAdapter.createPair();
|
|
319
|
+
|
|
320
|
+
const repoA = new Repo({
|
|
321
|
+
peerId: 'A' as any,
|
|
322
|
+
network: [pairAB[0]],
|
|
323
|
+
sharePolicy: async () => true,
|
|
324
|
+
});
|
|
325
|
+
const repoB = new Repo({
|
|
326
|
+
peerId: 'B' as any,
|
|
327
|
+
network: [pairAB[1], pairBC[0]],
|
|
328
|
+
sharePolicy: async () => true,
|
|
329
|
+
});
|
|
330
|
+
const repoC = new Repo({
|
|
331
|
+
peerId: 'C' as any,
|
|
332
|
+
network: [pairBC[1]],
|
|
333
|
+
sharePolicy: async () => true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
for (const pair of [pairAB, pairBC]) {
|
|
337
|
+
pair[0].ready();
|
|
338
|
+
pair[1].ready();
|
|
339
|
+
await pair[0].onConnect.wait();
|
|
340
|
+
await pair[1].onConnect.wait();
|
|
341
|
+
pair[0].peerCandidate(pair[1].peerId!);
|
|
342
|
+
pair[1].peerCandidate(pair[0].peerId!);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const docA = repoA.create();
|
|
346
|
+
// NOTE: Doesn't work if the doc is empty.
|
|
347
|
+
docA.change((doc: any) => {
|
|
348
|
+
doc.text = 'Hello world';
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const _docB = repoB.find(docA.url);
|
|
352
|
+
const docC = repoC.find(docA.url);
|
|
353
|
+
|
|
354
|
+
await docC.whenReady();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
107
357
|
|
|
108
358
|
class TestAdapter extends NetworkAdapter {
|
|
109
|
-
|
|
359
|
+
static createPair() {
|
|
360
|
+
const adapter1: TestAdapter = new TestAdapter({
|
|
361
|
+
send: (message: Message) => sleep(10).then(() => adapter2.receive(message)),
|
|
362
|
+
});
|
|
363
|
+
const adapter2: TestAdapter = new TestAdapter({
|
|
364
|
+
send: (message: Message) => sleep(10).then(() => adapter1.receive(message)),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return [adapter1, adapter2];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
public onConnect = new Trigger();
|
|
371
|
+
|
|
372
|
+
constructor(private readonly _params: { send: (message: Message) => void }) {
|
|
110
373
|
super();
|
|
111
374
|
}
|
|
112
375
|
|
|
@@ -118,32 +381,26 @@ class TestAdapter extends NetworkAdapter {
|
|
|
118
381
|
|
|
119
382
|
override connect(peerId: PeerId) {
|
|
120
383
|
this.peerId = peerId;
|
|
121
|
-
this.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
384
|
+
this.onConnect.wake();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
peerCandidate(peerId: PeerId) {
|
|
388
|
+
invariant(peerId, 'PeerId is required');
|
|
389
|
+
this.emit('peer-candidate', { peerId, peerMetadata: {} });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
peerDisconnected(peerId: PeerId) {
|
|
393
|
+
invariant(peerId, 'PeerId is required');
|
|
394
|
+
this.emit('peer-disconnected', { peerId });
|
|
131
395
|
}
|
|
132
396
|
|
|
133
397
|
override send(message: Message) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
.then((adapter) => {
|
|
137
|
-
adapter.receive(message);
|
|
138
|
-
})
|
|
139
|
-
.catch((error) => {
|
|
140
|
-
log.catch(error);
|
|
141
|
-
});
|
|
398
|
+
log('send', { from: message.senderId, to: message.targetId, type: message.type });
|
|
399
|
+
this._params.send(message);
|
|
142
400
|
}
|
|
143
401
|
|
|
144
402
|
override disconnect() {
|
|
145
403
|
this.peerId = undefined;
|
|
146
|
-
this.context[this.role].reset();
|
|
147
404
|
}
|
|
148
405
|
|
|
149
406
|
receive(message: Message) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { Trigger } from '@dxos/async';
|
|
5
6
|
import {
|
|
6
7
|
Repo,
|
|
7
8
|
NetworkAdapter,
|
|
@@ -15,12 +16,13 @@ import {
|
|
|
15
16
|
import { IndexedDBStorageAdapter } from '@dxos/automerge/automerge-repo-storage-indexeddb';
|
|
16
17
|
import { Stream } from '@dxos/codec-protobuf';
|
|
17
18
|
import { invariant } from '@dxos/invariant';
|
|
19
|
+
import { PublicKey } from '@dxos/keys';
|
|
18
20
|
import { log } from '@dxos/log';
|
|
19
21
|
import { type HostInfo, type SyncRepoRequest, type SyncRepoResponse } from '@dxos/protocols/proto/dxos/echo/service';
|
|
20
22
|
import { type PeerInfo } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
|
|
21
23
|
import { StorageType, type Directory } from '@dxos/random-access-storage';
|
|
22
24
|
import { AutomergeReplicator } from '@dxos/teleport-extension-automerge-replicator';
|
|
23
|
-
import { arrayToBuffer, bufferToArray } from '@dxos/util';
|
|
25
|
+
import { ComplexMap, ComplexSet, arrayToBuffer, bufferToArray, defaultMap } from '@dxos/util';
|
|
24
26
|
|
|
25
27
|
export class AutomergeHost {
|
|
26
28
|
private readonly _repo: Repo;
|
|
@@ -28,6 +30,11 @@ export class AutomergeHost {
|
|
|
28
30
|
private readonly _clientNetwork: LocalHostNetworkAdapter;
|
|
29
31
|
private readonly _storage: StorageAdapter;
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* spaceKey -> deviceKey[]
|
|
35
|
+
*/
|
|
36
|
+
private readonly _authorizedDevices = new ComplexMap<PublicKey, ComplexSet<PublicKey>>(PublicKey.hash);
|
|
37
|
+
|
|
31
38
|
constructor(storageDirectory: Directory) {
|
|
32
39
|
this._meshNetwork = new MeshNetworkAdapter();
|
|
33
40
|
this._clientNetwork = new LocalHostNetworkAdapter();
|
|
@@ -38,11 +45,52 @@ export class AutomergeHost {
|
|
|
38
45
|
? new IndexedDBStorageAdapter(storageDirectory.path, 'data')
|
|
39
46
|
: new AutomergeStorageAdapter(storageDirectory);
|
|
40
47
|
this._repo = new Repo({
|
|
48
|
+
peerId: `host-${PublicKey.random().toHex()}` as PeerId,
|
|
41
49
|
network: [this._clientNetwork, this._meshNetwork],
|
|
42
50
|
storage: this._storage,
|
|
43
51
|
|
|
44
52
|
// TODO(dmaretskyi): Share based on HALO permissions and space affinity.
|
|
45
|
-
|
|
53
|
+
// Hosts, running in the worker, don't share documents unless requested by other peers.
|
|
54
|
+
sharePolicy: async (peerId /* device key */, documentId /* space key */) => {
|
|
55
|
+
if (peerId.startsWith('client-')) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!documentId) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const doc = this._repo.handles[documentId]?.docSync();
|
|
64
|
+
if (!doc) {
|
|
65
|
+
log('doc not found for share policy check', { peerId, documentId });
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
if (!doc.experimental_spaceKey) {
|
|
71
|
+
log.warn('space key not found for share policy check', { peerId, documentId });
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const spaceKey = PublicKey.from(doc.experimental_spaceKey);
|
|
76
|
+
const authorizedDevices = this._authorizedDevices.get(spaceKey);
|
|
77
|
+
|
|
78
|
+
// TODO(mykola): Hack, stop abusing `peerMetadata` field.
|
|
79
|
+
const deviceKeyHex = (this.repo.peerMetadataByPeerId[peerId] as any)?.dxos_deviceKey;
|
|
80
|
+
if (!deviceKeyHex) {
|
|
81
|
+
log.warn('device key not found for share policy check', { peerId, documentId });
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const deviceKey = PublicKey.from(deviceKeyHex);
|
|
85
|
+
|
|
86
|
+
const isAuthorized = authorizedDevices?.has(deviceKey) ?? false;
|
|
87
|
+
log.info('share policy check', { peerId, documentId, deviceKey, spaceKey, isAuthorized });
|
|
88
|
+
return isAuthorized;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log.catch(err);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}, // Share everything.
|
|
46
94
|
});
|
|
47
95
|
this._clientNetwork.ready();
|
|
48
96
|
this._meshNetwork.ready();
|
|
@@ -53,6 +101,7 @@ export class AutomergeHost {
|
|
|
53
101
|
}
|
|
54
102
|
|
|
55
103
|
async close() {
|
|
104
|
+
this._storage instanceof AutomergeStorageAdapter && (await this._storage.close());
|
|
56
105
|
await this._clientNetwork.close();
|
|
57
106
|
}
|
|
58
107
|
|
|
@@ -68,7 +117,7 @@ export class AutomergeHost {
|
|
|
68
117
|
return this._clientNetwork.sendSyncMessage(request);
|
|
69
118
|
}
|
|
70
119
|
|
|
71
|
-
getHostInfo(): HostInfo {
|
|
120
|
+
async getHostInfo(): Promise<HostInfo> {
|
|
72
121
|
return this._clientNetwork.getHostInfo();
|
|
73
122
|
}
|
|
74
123
|
|
|
@@ -79,6 +128,10 @@ export class AutomergeHost {
|
|
|
79
128
|
createExtension(): AutomergeReplicator {
|
|
80
129
|
return this._meshNetwork.createExtension();
|
|
81
130
|
}
|
|
131
|
+
|
|
132
|
+
authorizeDevice(spaceKey: PublicKey, deviceKey: PublicKey) {
|
|
133
|
+
defaultMap(this._authorizedDevices, spaceKey, () => new ComplexSet(PublicKey.hash)).add(deviceKey);
|
|
134
|
+
}
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
type ClientSyncState = {
|
|
@@ -104,8 +157,11 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
|
|
|
104
157
|
});
|
|
105
158
|
}
|
|
106
159
|
|
|
160
|
+
private _connected = new Trigger();
|
|
161
|
+
|
|
107
162
|
override connect(peerId: PeerId): void {
|
|
108
163
|
this.peerId = peerId;
|
|
164
|
+
this._connected.wake();
|
|
109
165
|
// No-op. Client always connects first
|
|
110
166
|
}
|
|
111
167
|
|
|
@@ -146,18 +202,26 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
|
|
|
146
202
|
},
|
|
147
203
|
});
|
|
148
204
|
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
205
|
+
this._connected
|
|
206
|
+
.wait({ timeout: 1_000 })
|
|
207
|
+
.then(() => {
|
|
208
|
+
this.emit('peer-candidate', {
|
|
209
|
+
peerMetadata: {},
|
|
210
|
+
peerId,
|
|
211
|
+
});
|
|
212
|
+
})
|
|
213
|
+
.catch((err) => log.catch(err));
|
|
152
214
|
});
|
|
153
215
|
}
|
|
154
216
|
|
|
155
217
|
async sendSyncMessage({ id, syncMessage }: SyncRepoRequest): Promise<void> {
|
|
218
|
+
await this._connected.wait({ timeout: 1_000 });
|
|
156
219
|
const message = cbor.decode(syncMessage!) as Message;
|
|
157
220
|
this.emit('message', message);
|
|
158
221
|
}
|
|
159
222
|
|
|
160
|
-
getHostInfo(): HostInfo {
|
|
223
|
+
async getHostInfo(): Promise<HostInfo> {
|
|
224
|
+
await this._connected.wait({ timeout: 1_000 });
|
|
161
225
|
invariant(this.peerId, 'Peer id not set.');
|
|
162
226
|
return {
|
|
163
227
|
peerId: this.peerId,
|
|
@@ -172,7 +236,7 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
|
|
|
172
236
|
/**
|
|
173
237
|
* Used to replicate with other peers over the network.
|
|
174
238
|
*/
|
|
175
|
-
class MeshNetworkAdapter extends NetworkAdapter {
|
|
239
|
+
export class MeshNetworkAdapter extends NetworkAdapter {
|
|
176
240
|
private readonly _extensions: Map<string, AutomergeReplicator> = new Map();
|
|
177
241
|
|
|
178
242
|
/**
|
|
@@ -210,7 +274,7 @@ class MeshNetworkAdapter extends NetworkAdapter {
|
|
|
210
274
|
peerId: this.peerId,
|
|
211
275
|
},
|
|
212
276
|
{
|
|
213
|
-
onStartReplication: async (info) => {
|
|
277
|
+
onStartReplication: async (info, remotePeerId /** Teleport ID */) => {
|
|
214
278
|
// Note: We store only one extension per peer.
|
|
215
279
|
// There can be a case where two connected peers have more than one teleport connection between them
|
|
216
280
|
// and each of them uses different teleport connections to send messages.
|
|
@@ -224,6 +288,10 @@ class MeshNetworkAdapter extends NetworkAdapter {
|
|
|
224
288
|
// TODO(mykola): Fix race condition?
|
|
225
289
|
this._extensions.set(info.id, extension);
|
|
226
290
|
this.emit('peer-candidate', {
|
|
291
|
+
// TODO(mykola): Hack, stop abusing `peerMetadata` field.
|
|
292
|
+
peerMetadata: {
|
|
293
|
+
dxos_deviceKey: remotePeerId.toHex(),
|
|
294
|
+
} as any,
|
|
227
295
|
peerId: info.id as PeerId,
|
|
228
296
|
});
|
|
229
297
|
},
|
|
@@ -233,10 +301,13 @@ class MeshNetworkAdapter extends NetworkAdapter {
|
|
|
233
301
|
this.emit('message', message);
|
|
234
302
|
},
|
|
235
303
|
onClose: async () => {
|
|
236
|
-
peerInfo
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
304
|
+
if (!peerInfo) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.emit('peer-disconnected', {
|
|
308
|
+
peerId: peerInfo.id as PeerId,
|
|
309
|
+
});
|
|
310
|
+
this._extensions.delete(peerInfo.id);
|
|
240
311
|
},
|
|
241
312
|
},
|
|
242
313
|
);
|
|
@@ -245,11 +316,18 @@ class MeshNetworkAdapter extends NetworkAdapter {
|
|
|
245
316
|
}
|
|
246
317
|
|
|
247
318
|
export class AutomergeStorageAdapter extends StorageAdapter {
|
|
319
|
+
// TODO(mykola): Hack for restricting automerge Repo to access storage if Host is `closed`.
|
|
320
|
+
// Automerge Repo do not have any lifetime management.
|
|
321
|
+
private _state: 'opened' | 'closed' = 'opened';
|
|
322
|
+
|
|
248
323
|
constructor(private readonly _directory: Directory) {
|
|
249
324
|
super();
|
|
250
325
|
}
|
|
251
326
|
|
|
252
327
|
override async load(key: StorageKey): Promise<Uint8Array | undefined> {
|
|
328
|
+
if (this._state !== 'opened') {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
253
331
|
const filename = this._getFilename(key);
|
|
254
332
|
const file = this._directory.getOrCreateFile(filename);
|
|
255
333
|
const { size } = await file.stat();
|
|
@@ -261,6 +339,9 @@ export class AutomergeStorageAdapter extends StorageAdapter {
|
|
|
261
339
|
}
|
|
262
340
|
|
|
263
341
|
override async save(key: StorageKey, data: Uint8Array): Promise<void> {
|
|
342
|
+
if (this._state !== 'opened') {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
264
345
|
const filename = this._getFilename(key);
|
|
265
346
|
const file = this._directory.getOrCreateFile(filename);
|
|
266
347
|
await file.write(0, arrayToBuffer(data));
|
|
@@ -270,13 +351,19 @@ export class AutomergeStorageAdapter extends StorageAdapter {
|
|
|
270
351
|
}
|
|
271
352
|
|
|
272
353
|
override async remove(key: StorageKey): Promise<void> {
|
|
354
|
+
if (this._state !== 'opened') {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
273
357
|
// TODO(dmaretskyi): Better deletion.
|
|
274
358
|
const filename = this._getFilename(key);
|
|
275
359
|
const file = this._directory.getOrCreateFile(filename);
|
|
276
|
-
await file.
|
|
360
|
+
await file.destroy();
|
|
277
361
|
}
|
|
278
362
|
|
|
279
363
|
override async loadRange(keyPrefix: StorageKey): Promise<Chunk[]> {
|
|
364
|
+
if (this._state !== 'opened') {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
280
367
|
const filename = this._getFilename(keyPrefix);
|
|
281
368
|
const entries = await this._directory.list();
|
|
282
369
|
return Promise.all(
|
|
@@ -295,18 +382,25 @@ export class AutomergeStorageAdapter extends StorageAdapter {
|
|
|
295
382
|
}
|
|
296
383
|
|
|
297
384
|
override async removeRange(keyPrefix: StorageKey): Promise<void> {
|
|
385
|
+
if (this._state !== 'opened') {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
298
388
|
const filename = this._getFilename(keyPrefix);
|
|
299
389
|
const entries = await this._directory.list();
|
|
300
390
|
await Promise.all(
|
|
301
391
|
entries
|
|
302
392
|
.filter((entry) => entry.startsWith(filename))
|
|
303
393
|
.map(async (entry): Promise<void> => {
|
|
304
|
-
const file = this._directory.getOrCreateFile(
|
|
305
|
-
await file.
|
|
394
|
+
const file = this._directory.getOrCreateFile(entry);
|
|
395
|
+
await file.destroy();
|
|
306
396
|
}),
|
|
307
397
|
);
|
|
308
398
|
}
|
|
309
399
|
|
|
400
|
+
async close(): Promise<void> {
|
|
401
|
+
this._state = 'closed';
|
|
402
|
+
}
|
|
403
|
+
|
|
310
404
|
private _getFilename(key: StorageKey): string {
|
|
311
405
|
return key.map((k) => k.replaceAll('%', '%25').replaceAll('-', '%2D')).join('-');
|
|
312
406
|
}
|
package/src/automerge/index.ts
CHANGED