@dxos/teleport 0.1.14

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 (51) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +0 -0
  3. package/dist/lib/browser/index.mjs +544 -0
  4. package/dist/lib/browser/index.mjs.map +7 -0
  5. package/dist/lib/browser/meta.json +1 -0
  6. package/dist/lib/browser/testing.mjs +621 -0
  7. package/dist/lib/browser/testing.mjs.map +7 -0
  8. package/dist/lib/node/index.cjs +580 -0
  9. package/dist/lib/node/index.cjs.map +7 -0
  10. package/dist/lib/node/meta.json +1 -0
  11. package/dist/lib/node/testing.cjs +653 -0
  12. package/dist/lib/node/testing.cjs.map +7 -0
  13. package/dist/types/src/index.d.ts +3 -0
  14. package/dist/types/src/index.d.ts.map +1 -0
  15. package/dist/types/src/muxing/framer.d.ts +29 -0
  16. package/dist/types/src/muxing/framer.d.ts.map +1 -0
  17. package/dist/types/src/muxing/framer.test.d.ts +2 -0
  18. package/dist/types/src/muxing/framer.test.d.ts.map +1 -0
  19. package/dist/types/src/muxing/index.d.ts +4 -0
  20. package/dist/types/src/muxing/index.d.ts.map +1 -0
  21. package/dist/types/src/muxing/muxer.d.ts +60 -0
  22. package/dist/types/src/muxing/muxer.d.ts.map +1 -0
  23. package/dist/types/src/muxing/muxer.test.d.ts +2 -0
  24. package/dist/types/src/muxing/muxer.test.d.ts.map +1 -0
  25. package/dist/types/src/muxing/rpc-port.d.ts +11 -0
  26. package/dist/types/src/muxing/rpc-port.d.ts.map +1 -0
  27. package/dist/types/src/muxing/rpc-port.test.d.ts +2 -0
  28. package/dist/types/src/muxing/rpc-port.test.d.ts.map +1 -0
  29. package/dist/types/src/teleport.d.ts +47 -0
  30. package/dist/types/src/teleport.d.ts.map +1 -0
  31. package/dist/types/src/teleport.test.d.ts +2 -0
  32. package/dist/types/src/teleport.test.d.ts.map +1 -0
  33. package/dist/types/src/test-extension.d.ts +12 -0
  34. package/dist/types/src/test-extension.d.ts.map +1 -0
  35. package/dist/types/src/testing.d.ts +29 -0
  36. package/dist/types/src/testing.d.ts.map +1 -0
  37. package/package.json +47 -0
  38. package/src/index.ts +6 -0
  39. package/src/muxing/framer.test.ts +160 -0
  40. package/src/muxing/framer.ts +132 -0
  41. package/src/muxing/index.ts +7 -0
  42. package/src/muxing/muxer.test.ts +185 -0
  43. package/src/muxing/muxer.ts +301 -0
  44. package/src/muxing/rpc-port.test.ts +18 -0
  45. package/src/muxing/rpc-port.ts +15 -0
  46. package/src/teleport.test.ts +81 -0
  47. package/src/teleport.ts +277 -0
  48. package/src/test-extension.ts +65 -0
  49. package/src/testing.ts +75 -0
  50. package/testing.d.ts +11 -0
  51. package/testing.js +5 -0
@@ -0,0 +1,81 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ import { expect } from 'chai';
6
+
7
+ import { latch } from '@dxos/async';
8
+ import { log } from '@dxos/log';
9
+ import { afterTest, describe, test } from '@dxos/test';
10
+
11
+ import { TestExtension } from './test-extension';
12
+ import { TestBuilder } from './testing';
13
+
14
+ describe('Teleport', () => {
15
+ test('sends rpc via TestExtension', async () => {
16
+ const builder = new TestBuilder();
17
+ afterTest(() => builder.destroy());
18
+ const { peer1, peer2 } = await builder.createPipedPeers();
19
+
20
+ const extension1 = new TestExtension();
21
+ peer1.teleport!.addExtension('example.testing.rpc', extension1);
22
+ const extension2 = new TestExtension();
23
+ peer2.teleport!.addExtension('example.testing.rpc', extension2);
24
+
25
+ await extension1.test();
26
+ log('test1 done');
27
+ await extension2.test();
28
+ log('test2 done');
29
+
30
+ expect(extension1.extensionContext?.initiator).to.equal(true);
31
+ expect(extension2.extensionContext?.initiator).to.equal(false);
32
+ });
33
+
34
+ test('disconnect', async () => {
35
+ const builder = new TestBuilder();
36
+ afterTest(() => builder.destroy());
37
+ const { peer1, peer2 } = await builder.createPipedPeers();
38
+
39
+ const extension1 = new TestExtension();
40
+ peer1.teleport!.addExtension('example.testing.rpc', extension1);
41
+ const extension2 = new TestExtension();
42
+ peer2.teleport!.addExtension('example.testing.rpc', extension2);
43
+ await extension1.test();
44
+ log('test1 done');
45
+ await extension2.test();
46
+ log('test2 done');
47
+
48
+ await peer2.destroy();
49
+
50
+ await extension2.closed.wait({ timeout: 100 });
51
+ await extension1.closed.wait({ timeout: 100 });
52
+ });
53
+
54
+ test('destroy is idempotent', async () => {
55
+ const builder = new TestBuilder();
56
+ afterTest(() => builder.destroy());
57
+ const { peer1, peer2 } = await builder.createPipedPeers();
58
+
59
+ const extension1 = new TestExtension();
60
+ peer1.teleport!.addExtension('example.testing.rpc', extension1);
61
+ const extension2 = new TestExtension();
62
+ peer2.teleport!.addExtension('example.testing.rpc', extension2);
63
+ await extension1.test();
64
+ log('test1 done');
65
+ await extension2.test();
66
+ log('test2 done');
67
+
68
+ {
69
+ // latch to ensure all 4 destroy calls are made.
70
+ const [done, inc, errorHandler] = latch({ count: 4 });
71
+ peer1.destroy().then(inc, errorHandler);
72
+ peer1.destroy().then(inc, errorHandler);
73
+ peer2.destroy().then(inc, errorHandler);
74
+ peer2.destroy().then(inc, errorHandler);
75
+ await done();
76
+ }
77
+
78
+ await extension2.closed.wait({ timeout: 100 });
79
+ await extension1.closed.wait({ timeout: 100 });
80
+ });
81
+ });
@@ -0,0 +1,277 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ import assert from 'node:assert';
6
+ import { Duplex } from 'node:stream';
7
+
8
+ import { asyncTimeout, scheduleTaskInterval, runInContextAsync, synchronized, scheduleTask } from '@dxos/async';
9
+ import { Context } from '@dxos/context';
10
+ import { failUndefined } from '@dxos/debug';
11
+ import { PublicKey } from '@dxos/keys';
12
+ import { log } from '@dxos/log';
13
+ import { schema } from '@dxos/protocols';
14
+ import { ControlService } from '@dxos/protocols/proto/dxos/mesh/teleport/control';
15
+ import { createProtoRpcPeer, ProtoRpcPeer, RpcClosedError } from '@dxos/rpc';
16
+ import { Callback } from '@dxos/util';
17
+
18
+ import { CreateChannelOpts, Muxer, RpcPort } from './muxing';
19
+
20
+ export type TeleportParams = {
21
+ initiator: boolean;
22
+ localPeerId: PublicKey;
23
+ remotePeerId: PublicKey;
24
+ };
25
+
26
+ export class Teleport {
27
+ public readonly initiator: boolean;
28
+ public readonly localPeerId: PublicKey;
29
+ public readonly remotePeerId: PublicKey;
30
+
31
+ private readonly _ctx = new Context({
32
+ onError: (err) => {
33
+ void this.destroy(err).catch(() => {
34
+ log.error('Error during destroy', err);
35
+ });
36
+ }
37
+ });
38
+
39
+ private readonly _muxer = new Muxer();
40
+
41
+ private readonly _control = new ControlExtension({
42
+ heartbeatInterval: 3000,
43
+ heartbeatTimeout: 3000
44
+ });
45
+
46
+ private readonly _extensions = new Map<string, TeleportExtension>();
47
+ private readonly _remoteExtensions = new Set<string>();
48
+
49
+ private _open = false;
50
+
51
+ constructor({ initiator, localPeerId, remotePeerId }: TeleportParams) {
52
+ assert(typeof initiator === 'boolean');
53
+ assert(PublicKey.isPublicKey(localPeerId));
54
+ assert(PublicKey.isPublicKey(remotePeerId));
55
+ assert(typeof initiator === 'boolean');
56
+ this.initiator = initiator;
57
+ this.localPeerId = localPeerId;
58
+ this.remotePeerId = remotePeerId;
59
+
60
+ this._control.onExtensionRegistered.set(async (name) => {
61
+ log('remote extension', { name });
62
+ assert(!this._remoteExtensions.has(name), 'Remote extension already exists');
63
+ this._remoteExtensions.add(name);
64
+
65
+ if (this._extensions.has(name)) {
66
+ try {
67
+ await this._openExtension(name);
68
+ } catch (err: any) {
69
+ await this.destroy(err);
70
+ }
71
+ }
72
+ });
73
+
74
+ {
75
+ // Destroy Teleport when the stream is closed.
76
+ this._muxer.stream.on('close', async () => {
77
+ await this.destroy();
78
+ });
79
+
80
+ this._muxer.stream.on('error', async (err) => {
81
+ await this.destroy(err);
82
+ });
83
+ }
84
+ }
85
+
86
+ get stream(): Duplex {
87
+ return this._muxer.stream;
88
+ }
89
+
90
+ /**
91
+ * Blocks until the handshake is complete.
92
+ */
93
+ async open() {
94
+ this._setExtension('dxos.mesh.teleport.control', this._control);
95
+ await this._openExtension('dxos.mesh.teleport.control');
96
+ this._open = true;
97
+ }
98
+
99
+ async close(err?: Error) {
100
+ // TODO(dmaretskyi): Try soft close.
101
+
102
+ await this.destroy(err);
103
+ }
104
+
105
+ @synchronized
106
+ async destroy(err?: Error) {
107
+ if (this._ctx.disposed) {
108
+ return;
109
+ }
110
+
111
+ await this._ctx.dispose();
112
+
113
+ for (const extension of this._extensions.values()) {
114
+ try {
115
+ await extension.onClose(err);
116
+ } catch (err: any) {
117
+ log.catch(err);
118
+ }
119
+ }
120
+
121
+ this._muxer.destroy(err);
122
+ }
123
+
124
+ addExtension(name: string, extension: TeleportExtension) {
125
+ if (!this._open) {
126
+ throw new Error('Not open');
127
+ }
128
+
129
+ log('addExtension', { name });
130
+ this._setExtension(name, extension);
131
+
132
+ // Perform the registration in a separate tick as this might block while the remote side is opening the extension.
133
+ scheduleTask(this._ctx, async () => {
134
+ try {
135
+ await this._control.registerExtension(name);
136
+ } catch (err) {
137
+ if (err instanceof RpcClosedError) {
138
+ return;
139
+ }
140
+ throw err;
141
+ }
142
+ });
143
+
144
+ if (this._remoteExtensions.has(name)) {
145
+ // Open the extension in a separate tick.
146
+ scheduleTask(this._ctx, async () => {
147
+ await this._openExtension(name);
148
+ });
149
+ }
150
+ }
151
+
152
+ private _setExtension(extensionName: string, extension: TeleportExtension) {
153
+ assert(!extensionName.includes('/'), 'Invalid extension name');
154
+ assert(!this._extensions.has(extensionName), 'Extension already exists');
155
+ this._extensions.set(extensionName, extension);
156
+ }
157
+
158
+ private async _openExtension(extensionName: string) {
159
+ log('open extension', { extensionName });
160
+ const extension = this._extensions.get(extensionName) ?? failUndefined();
161
+
162
+ const context: ExtensionContext = {
163
+ initiator: this.initiator,
164
+ localPeerId: this.localPeerId,
165
+ remotePeerId: this.remotePeerId,
166
+ createPort: (channelName: string, opts?: CreateChannelOpts) => {
167
+ assert(!channelName.includes('/'), 'Invalid channel name');
168
+ return this._muxer.createPort(`${extensionName}/${channelName}`, opts);
169
+ },
170
+ createStream: (channelName: string, opts?: CreateChannelOpts) => {
171
+ assert(!channelName.includes('/'), 'Invalid channel name');
172
+ return this._muxer.createStream(`${extensionName}/${channelName}`, opts);
173
+ },
174
+ close: (err) => {
175
+ void runInContextAsync(this._ctx, async () => {
176
+ await this.close(err);
177
+ });
178
+ }
179
+ };
180
+
181
+ await extension.onOpen(context);
182
+ log('extension opened', { extensionName });
183
+ }
184
+ }
185
+
186
+ export type ExtensionContext = {
187
+ /**
188
+ * One of the peers will be designated an initiator.
189
+ */
190
+ initiator: boolean;
191
+ localPeerId: PublicKey;
192
+ remotePeerId: PublicKey;
193
+ createStream(tag: string, opts?: CreateChannelOpts): Duplex;
194
+ createPort(tag: string, opts?: CreateChannelOpts): RpcPort;
195
+ close(err?: Error): void;
196
+ };
197
+
198
+ export interface TeleportExtension {
199
+ onOpen(context: ExtensionContext): Promise<void>;
200
+ onClose(err?: Error): Promise<void>;
201
+ }
202
+
203
+ type ControlExtensionOpts = {
204
+ heartbeatInterval: number;
205
+ heartbeatTimeout: number;
206
+ };
207
+
208
+ class ControlExtension implements TeleportExtension {
209
+ private readonly _ctx = new Context({
210
+ onError: (err) => {
211
+ this._extensionContext.close(err);
212
+ }
213
+ });
214
+
215
+ private _extensionContext!: ExtensionContext;
216
+ private _rpc!: ProtoRpcPeer<{ Control: ControlService }>;
217
+
218
+ public readonly onExtensionRegistered = new Callback<(extensionName: string) => void>();
219
+ public readonly onTimeout = new Callback<() => void>();
220
+
221
+ constructor(private readonly opts: ControlExtensionOpts) {}
222
+
223
+ async onOpen(extensionContext: ExtensionContext): Promise<void> {
224
+ this._extensionContext = extensionContext;
225
+
226
+ // NOTE: Make sure that RPC timeout is greater than the heartbeat timeout.
227
+ // TODO(dmaretskyi): Allow overwriting the timeout on individual RPC calls?
228
+ this._rpc = createProtoRpcPeer<ControlRpcBundle, ControlRpcBundle>({
229
+ requested: {
230
+ Control: schema.getService('dxos.mesh.teleport.control.ControlService')
231
+ },
232
+ exposed: {
233
+ Control: schema.getService('dxos.mesh.teleport.control.ControlService')
234
+ },
235
+ handlers: {
236
+ Control: {
237
+ registerExtension: async (request) => {
238
+ this.onExtensionRegistered.call(request.name);
239
+ },
240
+ heartbeat: async (request) => {
241
+ // Ok.
242
+ }
243
+ }
244
+ },
245
+ port: extensionContext.createPort('rpc', {
246
+ contentType: 'application/x-protobuf; messageType="dxos.rpc.Message"'
247
+ })
248
+ });
249
+
250
+ await this._rpc.open();
251
+
252
+ scheduleTaskInterval(
253
+ this._ctx,
254
+ async () => {
255
+ try {
256
+ await asyncTimeout(this._rpc.rpc.Control.heartbeat(), this.opts.heartbeatTimeout);
257
+ } catch (err: any) {
258
+ this.onTimeout.call();
259
+ }
260
+ },
261
+ this.opts.heartbeatInterval
262
+ );
263
+ }
264
+
265
+ async onClose(err?: Error): Promise<void> {
266
+ await this._ctx.dispose();
267
+ await this._rpc.close();
268
+ }
269
+
270
+ async registerExtension(name: string) {
271
+ await this._rpc.rpc.Control.registerExtension({ name });
272
+ }
273
+ }
274
+
275
+ type ControlRpcBundle = {
276
+ Control: ControlService;
277
+ };
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ import assert from 'node:assert';
6
+
7
+ import { asyncTimeout, Trigger } from '@dxos/async';
8
+ import { log } from '@dxos/log';
9
+ import { schema } from '@dxos/protocols';
10
+ import { TestService } from '@dxos/protocols/proto/example/testing/rpc';
11
+ import { createProtoRpcPeer, ProtoRpcPeer } from '@dxos/rpc';
12
+
13
+ import { ExtensionContext, TeleportExtension } from './teleport';
14
+
15
+ export class TestExtension implements TeleportExtension {
16
+ public readonly closed = new Trigger();
17
+ public extensionContext: ExtensionContext | undefined;
18
+ private _rpc!: ProtoRpcPeer<{ TestService: TestService }>;
19
+ private _opened = new Trigger();
20
+
21
+ async onOpen(context: ExtensionContext) {
22
+ log('onOpen', { localPeerId: context.localPeerId, remotePeerId: context.remotePeerId });
23
+ this.extensionContext = context;
24
+ this._rpc = createProtoRpcPeer<{ TestService: TestService }, { TestService: TestService }>({
25
+ port: context.createPort('rpc', {
26
+ contentType: 'application/x-protobuf; messageType="dxos.rpc.Message"'
27
+ }),
28
+ requested: {
29
+ TestService: schema.getService('example.testing.rpc.TestService')
30
+ },
31
+ exposed: {
32
+ TestService: schema.getService('example.testing.rpc.TestService')
33
+ },
34
+ handlers: {
35
+ TestService: {
36
+ voidCall: async (request) => {
37
+ // Ok.
38
+ },
39
+ testCall: async (request) => {
40
+ return {
41
+ data: request.data
42
+ };
43
+ }
44
+ }
45
+ },
46
+ timeout: 1000
47
+ });
48
+
49
+ await this._rpc.open();
50
+
51
+ this._opened.wake();
52
+ }
53
+
54
+ async onClose(err?: Error) {
55
+ log('onClose', { err });
56
+ this.closed.wake();
57
+ await this._rpc.close();
58
+ }
59
+
60
+ async test() {
61
+ await this._opened.wait({ timeout: 500 });
62
+ const res = await asyncTimeout(this._rpc.rpc.TestService.testCall({ data: 'test' }), 500);
63
+ assert(res.data === 'test');
64
+ }
65
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,75 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ import { pipeline } from 'node:stream';
6
+
7
+ import { PublicKey } from '@dxos/keys';
8
+ import { log } from '@dxos/log';
9
+
10
+ import { Teleport } from './teleport';
11
+
12
+ export class TestBuilder {
13
+ private readonly _peers = new Array<TestPeer>();
14
+
15
+ createPeer(peerId?: PublicKey): TestPeer {
16
+ const peer = new TestPeer(peerId);
17
+ this._peers.push(peer);
18
+ return peer;
19
+ }
20
+
21
+ async destroy() {
22
+ await Promise.all(this._peers.map((agent) => agent.destroy()));
23
+ }
24
+
25
+ /**
26
+ * Simulates two peers connected via P2P network.
27
+ */
28
+ async createPipedPeers({ peerId1, peerId2 }: { peerId1?: PublicKey; peerId2?: PublicKey } = {}) {
29
+ const peer1 = this.createPeer(peerId1);
30
+ const peer2 = this.createPeer(peerId2);
31
+
32
+ peer1.initializeTeleport({ initiator: true, remotePeerId: peer2.peerId });
33
+ peer2.initializeTeleport({ initiator: false, remotePeerId: peer1.peerId });
34
+
35
+ peer1.pipeline(peer2);
36
+ peer2.pipeline(peer1);
37
+
38
+ await Promise.all([peer1.teleport!.open(), peer2.teleport!.open()]);
39
+
40
+ return { peer1, peer2 };
41
+ }
42
+ }
43
+
44
+ export class TestPeer {
45
+ public teleport?: Teleport;
46
+
47
+ constructor(public readonly peerId: PublicKey = PublicKey.random()) {}
48
+
49
+ initializeTeleport({ initiator, remotePeerId }: { initiator: boolean; remotePeerId: PublicKey }) {
50
+ if (this.teleport) {
51
+ return this;
52
+ }
53
+ this.teleport = new Teleport({
54
+ initiator,
55
+ localPeerId: this.peerId,
56
+ remotePeerId
57
+ });
58
+ return this;
59
+ }
60
+
61
+ pipeline(peer: TestPeer) {
62
+ if (!this.teleport || !peer.teleport) {
63
+ throw new Error('Teleport not initialized');
64
+ }
65
+ pipeline(this.teleport.stream, peer.teleport.stream, (err) => {
66
+ if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
67
+ log.catch(err);
68
+ }
69
+ });
70
+ }
71
+
72
+ async destroy() {
73
+ await this.teleport?.destroy();
74
+ }
75
+ }
package/testing.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Enable testing imports:
7
+ * ```tsc
8
+ * import { ... } from '@dxos/teleport/testing';
9
+ * ```
10
+ */
11
+ export * from './dist/types/src/testing';
package/testing.js ADDED
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ module.exports = require('./dist/lib/node/testing.cjs');