@dxos/echo-pipeline 0.4.4-main.fcf0b00 → 0.4.4-next.ac77e71

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 (28) hide show
  1. package/dist/lib/browser/{chunk-26G7ZQMP.mjs → chunk-WIB35LJH.mjs} +305 -254
  2. package/dist/lib/browser/{chunk-26G7ZQMP.mjs.map → chunk-WIB35LJH.mjs.map} +4 -4
  3. package/dist/lib/browser/index.mjs +5 -1
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/testing/index.mjs +1 -1
  6. package/dist/lib/node/{chunk-V62AY27P.cjs → chunk-37RERU2L.cjs} +295 -248
  7. package/dist/lib/node/{chunk-V62AY27P.cjs.map → chunk-37RERU2L.cjs.map} +4 -4
  8. package/dist/lib/node/index.cjs +31 -27
  9. package/dist/lib/node/index.cjs.map +2 -2
  10. package/dist/lib/node/meta.json +1 -1
  11. package/dist/lib/node/testing/index.cjs +16 -16
  12. package/dist/types/src/automerge/automerge-host.d.ts +5 -31
  13. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  14. package/dist/types/src/automerge/automerge-storage-adapter.d.ts +16 -0
  15. package/dist/types/src/automerge/automerge-storage-adapter.d.ts.map +1 -0
  16. package/dist/types/src/automerge/index.d.ts +4 -1
  17. package/dist/types/src/automerge/index.d.ts.map +1 -1
  18. package/dist/types/src/automerge/local-host-network-adapter.d.ts +23 -0
  19. package/dist/types/src/automerge/local-host-network-adapter.d.ts.map +1 -0
  20. package/dist/types/src/automerge/mesh-network-adapter.d.ts +18 -0
  21. package/dist/types/src/automerge/mesh-network-adapter.d.ts.map +1 -0
  22. package/package.json +34 -34
  23. package/src/automerge/automerge-host.test.ts +102 -2
  24. package/src/automerge/automerge-host.ts +27 -303
  25. package/src/automerge/automerge-storage-adapter.ts +105 -0
  26. package/src/automerge/index.ts +4 -1
  27. package/src/automerge/local-host-network-adapter.ts +109 -0
  28. package/src/automerge/mesh-network-adapter.ts +107 -0
@@ -0,0 +1,105 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+ //
5
+ // Copyright 2023 DXOS.org
6
+ //
7
+
8
+ import { StorageAdapter, type Chunk, type StorageKey } from '@dxos/automerge/automerge-repo';
9
+ import { type Directory } from '@dxos/random-access-storage';
10
+ import { arrayToBuffer, bufferToArray } from '@dxos/util';
11
+
12
+ export class AutomergeStorageAdapter extends StorageAdapter {
13
+ // TODO(mykola): Hack for restricting automerge Repo to access storage if Host is `closed`.
14
+ // Automerge Repo do not have any lifetime management.
15
+ private _state: 'opened' | 'closed' = 'opened';
16
+
17
+ constructor(private readonly _directory: Directory) {
18
+ super();
19
+ }
20
+
21
+ override async load(key: StorageKey): Promise<Uint8Array | undefined> {
22
+ if (this._state !== 'opened') {
23
+ return undefined;
24
+ }
25
+ const filename = this._getFilename(key);
26
+ const file = this._directory.getOrCreateFile(filename);
27
+ const { size } = await file.stat();
28
+ if (!size || size === 0) {
29
+ return undefined;
30
+ }
31
+ const buffer = await file.read(0, size);
32
+ return bufferToArray(buffer);
33
+ }
34
+
35
+ override async save(key: StorageKey, data: Uint8Array): Promise<void> {
36
+ if (this._state !== 'opened') {
37
+ return undefined;
38
+ }
39
+ const filename = this._getFilename(key);
40
+ const file = this._directory.getOrCreateFile(filename);
41
+ await file.write(0, arrayToBuffer(data));
42
+ await file.truncate?.(data.length);
43
+
44
+ await file.flush?.();
45
+ }
46
+
47
+ override async remove(key: StorageKey): Promise<void> {
48
+ if (this._state !== 'opened') {
49
+ return undefined;
50
+ }
51
+ // TODO(dmaretskyi): Better deletion.
52
+ const filename = this._getFilename(key);
53
+ const file = this._directory.getOrCreateFile(filename);
54
+ await file.destroy();
55
+ }
56
+
57
+ override async loadRange(keyPrefix: StorageKey): Promise<Chunk[]> {
58
+ if (this._state !== 'opened') {
59
+ return [];
60
+ }
61
+ const filename = this._getFilename(keyPrefix);
62
+ const entries = await this._directory.list();
63
+ return Promise.all(
64
+ entries
65
+ .filter((entry) => entry.startsWith(filename))
66
+ .map(async (entry): Promise<Chunk> => {
67
+ const file = this._directory.getOrCreateFile(entry);
68
+ const { size } = await file.stat();
69
+ const buffer = await file.read(0, size);
70
+ return {
71
+ key: this._getKeyFromFilename(entry),
72
+ data: bufferToArray(buffer),
73
+ };
74
+ }),
75
+ );
76
+ }
77
+
78
+ override async removeRange(keyPrefix: StorageKey): Promise<void> {
79
+ if (this._state !== 'opened') {
80
+ return undefined;
81
+ }
82
+ const filename = this._getFilename(keyPrefix);
83
+ const entries = await this._directory.list();
84
+ await Promise.all(
85
+ entries
86
+ .filter((entry) => entry.startsWith(filename))
87
+ .map(async (entry): Promise<void> => {
88
+ const file = this._directory.getOrCreateFile(entry);
89
+ await file.destroy();
90
+ }),
91
+ );
92
+ }
93
+
94
+ async close(): Promise<void> {
95
+ this._state = 'closed';
96
+ }
97
+
98
+ private _getFilename(key: StorageKey): string {
99
+ return key.map((k) => k.replaceAll('%', '%25').replaceAll('-', '%2D')).join('-');
100
+ }
101
+
102
+ private _getKeyFromFilename(filename: string): StorageKey {
103
+ return filename.split('-').map((k) => k.replaceAll('%2D', '-').replaceAll('%25', '%'));
104
+ }
105
+ }
@@ -2,4 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export { AutomergeHost, AutomergeStorageAdapter } from './automerge-host';
5
+ export * from './automerge-host';
6
+ export * from './automerge-storage-adapter';
7
+ export * from './local-host-network-adapter';
8
+ export * from './mesh-network-adapter';
@@ -0,0 +1,109 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Trigger } from '@dxos/async';
6
+ import { NetworkAdapter, type Message, type PeerId, cbor } from '@dxos/automerge/automerge-repo';
7
+ import { Stream } from '@dxos/codec-protobuf';
8
+ import { invariant } from '@dxos/invariant';
9
+ import { log } from '@dxos/log';
10
+ import { type HostInfo, type SyncRepoRequest, type SyncRepoResponse } from '@dxos/protocols/proto/dxos/echo/service';
11
+
12
+ type ClientSyncState = {
13
+ connected: boolean;
14
+ send: (message: Message) => void;
15
+ disconnect: () => void;
16
+ };
17
+
18
+ /**
19
+ * Used to replicate with apps running on the same device.
20
+ */
21
+ export class LocalHostNetworkAdapter extends NetworkAdapter {
22
+ private readonly _peers: Map<PeerId, ClientSyncState> = new Map();
23
+
24
+ /**
25
+ * Emits `ready` event. That signals to `Repo` that it can start using the adapter.
26
+ */
27
+ ready() {
28
+ // NOTE: Emitting `ready` event in NetworkAdapter`s constructor causes a race condition
29
+ // because `Repo` waits for `ready` event (which it never receives) before it starts using the adapter.
30
+ this.emit('ready', {
31
+ network: this,
32
+ });
33
+ }
34
+
35
+ private _connected = new Trigger();
36
+
37
+ override connect(peerId: PeerId): void {
38
+ this.peerId = peerId;
39
+ this._connected.wake();
40
+ // No-op. Client always connects first
41
+ }
42
+
43
+ override send(message: Message): void {
44
+ const peer = this._peers.get(message.targetId);
45
+ invariant(peer, 'Peer not found.');
46
+ peer.send(message);
47
+ }
48
+
49
+ async close() {
50
+ this._peers.forEach((peer) => peer.disconnect());
51
+ this.emit('close');
52
+ }
53
+
54
+ override disconnect(): void {
55
+ // TODO(mykola): `disconnect` is not used anywhere in `Repo` from `@automerge/automerge-repo`. Should we remove it?
56
+ // No-op
57
+ }
58
+
59
+ syncRepo({ id, syncMessage }: SyncRepoRequest): Stream<SyncRepoResponse> {
60
+ const peerId = this._getPeerId(id);
61
+
62
+ return new Stream(({ next, close }) => {
63
+ invariant(!this._peers.has(peerId), 'Peer already connected.');
64
+ this._peers.set(peerId, {
65
+ connected: true,
66
+ send: (message) => {
67
+ next({
68
+ syncMessage: cbor.encode(message),
69
+ });
70
+ },
71
+ disconnect: () => {
72
+ this._peers.delete(peerId);
73
+ close();
74
+ this.emit('peer-disconnected', {
75
+ peerId,
76
+ });
77
+ },
78
+ });
79
+
80
+ this._connected
81
+ .wait({ timeout: 1_000 })
82
+ .then(() => {
83
+ this.emit('peer-candidate', {
84
+ peerMetadata: {},
85
+ peerId,
86
+ });
87
+ })
88
+ .catch((err) => log.catch(err));
89
+ });
90
+ }
91
+
92
+ async sendSyncMessage({ id, syncMessage }: SyncRepoRequest): Promise<void> {
93
+ await this._connected.wait({ timeout: 1_000 });
94
+ const message = cbor.decode(syncMessage!) as Message;
95
+ this.emit('message', message);
96
+ }
97
+
98
+ async getHostInfo(): Promise<HostInfo> {
99
+ await this._connected.wait({ timeout: 1_000 });
100
+ invariant(this.peerId, 'Peer id not set.');
101
+ return {
102
+ peerId: this.peerId,
103
+ };
104
+ }
105
+
106
+ private _getPeerId(id: string): PeerId {
107
+ return id as PeerId;
108
+ }
109
+ }
@@ -0,0 +1,107 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Trigger } from '@dxos/async';
6
+ import { NetworkAdapter, type Message, type PeerId, cbor } from '@dxos/automerge/automerge-repo';
7
+ import { invariant } from '@dxos/invariant';
8
+ import { log } from '@dxos/log';
9
+ import { type PeerInfo } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
10
+ import { AutomergeReplicator } from '@dxos/teleport-extension-automerge-replicator';
11
+
12
+ /**
13
+ * Used to replicate with other peers over the network.
14
+ */
15
+ export class MeshNetworkAdapter extends NetworkAdapter {
16
+ private readonly _extensions: Map<string, AutomergeReplicator> = new Map();
17
+ private _connected = new Trigger();
18
+
19
+ /**
20
+ * Emits `ready` event. That signals to `Repo` that it can start using the adapter.
21
+ */
22
+ ready() {
23
+ // NOTE: Emitting `ready` event in NetworkAdapter`s constructor causes a race condition
24
+ // because `Repo` waits for `ready` event (which it never receives) before it starts using the adapter.
25
+ this.emit('ready', {
26
+ network: this,
27
+ });
28
+ }
29
+
30
+ override connect(peerId: PeerId): void {
31
+ this.peerId = peerId;
32
+ this._connected.wake();
33
+ }
34
+
35
+ override send(message: Message): void {
36
+ const receiverId = message.targetId;
37
+ const extension = this._extensions.get(receiverId);
38
+ invariant(extension, 'Extension not found.');
39
+ extension.sendSyncMessage({ payload: cbor.encode(message) }).catch((err) => log.catch(err));
40
+ }
41
+
42
+ override disconnect(): void {
43
+ // No-op
44
+ }
45
+
46
+ createExtension(): AutomergeReplicator {
47
+ invariant(this.peerId, 'Peer id not set.');
48
+
49
+ let peerInfo: PeerInfo;
50
+ const extension = new AutomergeReplicator(
51
+ {
52
+ peerId: this.peerId,
53
+ },
54
+ {
55
+ onStartReplication: async (info, remotePeerId /** Teleport ID */) => {
56
+ await this._connected.wait();
57
+
58
+ // Note: We store only one extension per peer.
59
+ // There can be a case where two connected peers have more than one teleport connection between them
60
+ // and each of them uses different teleport connections to send messages.
61
+ // It works because we receive messages from all teleport connections and Automerge Repo dedup them.
62
+ // TODO(mykola): Use only one teleport connection per peer.
63
+
64
+ // TODO(dmaretskyi): Critical bug.
65
+ // - two peers get connected via swarm 1
66
+ // - they get connected via swarm 2
67
+ // - swarm 1 gets disconnected
68
+ // - automerge repo thinks that peer 2 got disconnected even though swarm 2 is still active
69
+
70
+ log('onStartReplication', { id: info.id, thisPeerId: this.peerId, remotePeerId: remotePeerId.toHex() });
71
+ if (!this._extensions.has(info.id)) {
72
+ peerInfo = info;
73
+ // TODO(mykola): Fix race condition?
74
+ this._extensions.set(info.id, extension);
75
+
76
+ log('peer-candidate', { id: info.id, thisPeerId: this.peerId, remotePeerId: remotePeerId.toHex() });
77
+ this.emit('peer-candidate', {
78
+ // TODO(mykola): Hack, stop abusing `peerMetadata` field.
79
+ peerMetadata: {
80
+ dxos_deviceKey: remotePeerId.toHex(),
81
+ } as any,
82
+ peerId: info.id as PeerId,
83
+ });
84
+ }
85
+ },
86
+ onSyncMessage: async ({ payload }) => {
87
+ if (!peerInfo) {
88
+ return;
89
+ }
90
+ const message = cbor.decode(payload) as Message;
91
+ // Note: automerge Repo dedup messages.
92
+ this.emit('message', message);
93
+ },
94
+ onClose: async () => {
95
+ if (!peerInfo) {
96
+ return;
97
+ }
98
+ this.emit('peer-disconnected', {
99
+ peerId: peerInfo.id as PeerId,
100
+ });
101
+ this._extensions.delete(peerInfo.id);
102
+ },
103
+ },
104
+ );
105
+ return extension;
106
+ }
107
+ }