diodejs 0.4.2 → 0.4.4

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/bindPort.js CHANGED
@@ -8,6 +8,8 @@ const DiodeRPC = require('./rpc');
8
8
  const nativeCrypto = require('./nativeCrypto');
9
9
  const logger = require('./logger');
10
10
 
11
+ const BIND_PORT_LISTENER_STATE = Symbol.for('diodejs.bindPort.listenerState');
12
+
11
13
  // Custom Duplex stream to handle the Diode connection
12
14
  class DiodeSocket extends Duplex {
13
15
  constructor(ref, rpc) {
@@ -129,6 +131,104 @@ class BindPort {
129
131
  return this.connection;
130
132
  }
131
133
 
134
+ _connectionKey(connection) {
135
+ if (!connection) return '';
136
+ if (connection._managerHostKey) return connection._managerHostKey;
137
+ if (connection.host && connection.port) return `${connection.host}:${connection.port}`;
138
+ return '';
139
+ }
140
+
141
+ _isUsableConnection(connection) {
142
+ return connection && connection.socket && !connection.socket.destroyed;
143
+ }
144
+
145
+ _clearDeviceRelayCache(deviceIdHex) {
146
+ const cache = this.connection && this.connection.deviceRelayCache;
147
+ if (!cache || typeof cache.delete !== 'function') {
148
+ return;
149
+ }
150
+ const normalized = this._stripHexPrefix(deviceIdHex || '').toLowerCase();
151
+ cache.delete(normalized);
152
+ cache.delete(`0x${normalized}`);
153
+ }
154
+
155
+ async _getApiRelayCandidates(deviceId, deviceIdHex) {
156
+ const candidates = [];
157
+ const seen = new Set();
158
+ const push = (connection) => {
159
+ if (!this._isUsableConnection(connection)) {
160
+ return;
161
+ }
162
+ const key = this._connectionKey(connection) || String(candidates.length);
163
+ if (seen.has(key)) {
164
+ return;
165
+ }
166
+ seen.add(key);
167
+ candidates.push(connection);
168
+ };
169
+
170
+ try {
171
+ push(await this._resolveConnectionForDevice(deviceId));
172
+ } catch (error) {
173
+ logger.warn(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
174
+ }
175
+
176
+ if (typeof (this.connection && this.connection.getNearestConnection) === 'function') {
177
+ push(this.connection.getNearestConnection());
178
+ }
179
+
180
+ if (typeof (this.connection && this.connection.getConnections) === 'function') {
181
+ for (const connection of this.connection.getConnections()) {
182
+ push(connection);
183
+ }
184
+ } else {
185
+ push(this.connection);
186
+ }
187
+
188
+ return candidates;
189
+ }
190
+
191
+ async _openApiPortWithRelayFallback(deviceId, deviceIdHex, formattedTargetPort, flags = 'rw') {
192
+ let candidates = await this._getApiRelayCandidates(deviceId, deviceIdHex);
193
+ let clearedCache = false;
194
+ let lastError = null;
195
+
196
+ for (let index = 0; index < candidates.length; index += 1) {
197
+ const connection = candidates[index];
198
+ const rpc = this._getRpcFor(connection);
199
+ const relayKey = this._connectionKey(connection) || 'unknown relay';
200
+
201
+ try {
202
+ const ref = await rpc.portOpen(deviceId, formattedTargetPort, flags);
203
+ if (ref) {
204
+ if (index > 0) {
205
+ logger.info(() => `Port ${formattedTargetPort} opened via fallback relay ${relayKey}`);
206
+ }
207
+ return { connection, rpc, ref };
208
+ }
209
+ lastError = new Error(`portopen returned no ref via ${relayKey}`);
210
+ } catch (error) {
211
+ lastError = error;
212
+ }
213
+
214
+ logger.warn(() => `Port ${formattedTargetPort} did not open via ${relayKey}: ${lastError}`);
215
+ if (!clearedCache) {
216
+ clearedCache = true;
217
+ this._clearDeviceRelayCache(deviceIdHex);
218
+ const refreshed = await this._getApiRelayCandidates(deviceId, deviceIdHex);
219
+ for (const candidate of refreshed) {
220
+ const key = this._connectionKey(candidate) || String(candidates.length);
221
+ const exists = candidates.some((existing) => (this._connectionKey(existing) || '') === key);
222
+ if (!exists) {
223
+ candidates.push(candidate);
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ throw lastError || new Error('No relay connection available');
230
+ }
231
+
132
232
  async _openTlsHandshakeChannel(connection, rpc, ref) {
133
233
  const diodeSocket = new DiodeSocket(ref, rpc);
134
234
  const certPem = connection.getDeviceCertificate();
@@ -230,8 +330,26 @@ class BindPort {
230
330
  }
231
331
 
232
332
  _setupMessageListener() {
233
- // Listen for data events from the device
234
- this.connection.on('unsolicited', (message, sourceConnection) => {
333
+ const rootConnection = this.connection;
334
+ if (!rootConnection || typeof rootConnection.on !== 'function') {
335
+ return;
336
+ }
337
+
338
+ let state = rootConnection[BIND_PORT_LISTENER_STATE];
339
+ if (state) {
340
+ state.instances.add(this);
341
+ return;
342
+ }
343
+
344
+ state = {
345
+ instances: new Set([this]),
346
+ };
347
+ rootConnection[BIND_PORT_LISTENER_STATE] = state;
348
+
349
+ // Listen for data events from the device. This is shared per connection
350
+ // manager so multiple BindPort instances do not duplicate payloads into
351
+ // the same client socket.
352
+ state.onUnsolicited = (message, sourceConnection) => {
235
353
  const connection = sourceConnection || this.connection;
236
354
  if (!connection || typeof connection.getClientSocket !== 'function') {
237
355
  logger.warn(() => 'Received unsolicited message without a valid connection context');
@@ -276,7 +394,11 @@ class BindPort {
276
394
  if (clientSocket.diodeSocket) {
277
395
  clientSocket.diodeSocket._destroy(null, () => {});
278
396
  }
279
- clientSocket.end();
397
+ if (typeof clientSocket.end === 'function') {
398
+ clientSocket.end();
399
+ } else if (typeof clientSocket.close === 'function') {
400
+ clientSocket.close();
401
+ }
280
402
  connection.deleteClientSocket(dataRef);
281
403
  logger.info(() => `Port closed for ref: ${dataRef.toString('hex')}`);
282
404
  }
@@ -285,19 +407,26 @@ class BindPort {
285
407
  logger.warn(() => `Unknown unsolicited message type: ${messageType}`);
286
408
  }
287
409
  }
288
- });
410
+ };
411
+ rootConnection.on('unsolicited', state.onUnsolicited);
289
412
 
290
413
  // Handle device disconnect
291
- this.connection.on('end', () => {
414
+ state.onEnd = () => {
292
415
  logger.info(() => 'Disconnected from Diode.io server');
293
- this.closeAllServers();
294
- });
416
+ for (const instance of state.instances) {
417
+ instance.closeAllServers();
418
+ }
419
+ };
420
+ rootConnection.on('end', state.onEnd);
295
421
 
296
422
  // Handle connection errors
297
- this.connection.on('error', (err) => {
423
+ state.onError = (err) => {
298
424
  logger.error(() => `Connection error: ${err}`);
299
- this.closeAllServers();
300
- });
425
+ for (const instance of state.instances) {
426
+ instance.closeAllServers();
427
+ }
428
+ };
429
+ rootConnection.on('error', state.onError);
301
430
  }
302
431
 
303
432
  addPort(localPort, targetPort, deviceIdHex, protocol = 'tls', transport = undefined) {
@@ -471,39 +600,23 @@ class BindPort {
471
600
  let entry = server.clientRefs && server.clientRefs[clientKey];
472
601
 
473
602
  if (!entry) {
474
- let connection;
475
603
  try {
476
- connection = await this._resolveConnectionForDevice(deviceId);
477
- } catch (error) {
478
- logger.error(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
479
- return;
480
- }
481
- if (!connection) {
482
- logger.error(() => `No relay connection available for device ${deviceIdHex}`);
483
- return;
484
- }
485
- const rpc = this._getRpcFor(connection);
486
- try {
487
- const ref = await rpc.portOpen(deviceId, formattedTargetPort, 'rw');
488
- if (!ref) {
489
- logger.error(() => `Error opening port ${formattedTargetPort} on deviceId: ${deviceIdHex}`);
490
- return;
491
- } else {
492
- logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for udp client ${clientKey}`);
493
- if (!server.clientRefs) server.clientRefs = {};
494
- entry = { ref, connection };
495
- server.clientRefs[clientKey] = entry;
496
-
497
- // Store the client info
498
- connection.addClientSocket(ref, {
499
- address: rinfo.address,
500
- port: rinfo.port,
501
- protocol: 'udp',
502
- write: (data) => {
503
- server.send(data, rinfo.port, rinfo.address);
504
- }
505
- });
506
- }
604
+ const opened = await this._openApiPortWithRelayFallback(deviceId, deviceIdHex, formattedTargetPort, 'rw');
605
+ const { ref, connection } = opened;
606
+ logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for udp client ${clientKey}`);
607
+ if (!server.clientRefs) server.clientRefs = {};
608
+ entry = { ref, connection };
609
+ server.clientRefs[clientKey] = entry;
610
+
611
+ // Store the client info
612
+ connection.addClientSocket(ref, {
613
+ address: rinfo.address,
614
+ port: rinfo.port,
615
+ protocol: 'udp',
616
+ write: (data) => {
617
+ server.send(data, rinfo.port, rinfo.address);
618
+ }
619
+ });
507
620
  } catch (error) {
508
621
  logger.error(() => `Error opening port ${formattedTargetPort} on device: ${error}`);
509
622
  return;
@@ -539,24 +652,69 @@ class BindPort {
539
652
  const server = net.createServer(async (clientSocket) => {
540
653
  logger.info(() => `Client connected to local server on port ${localPort}`);
541
654
  clientSocket.setNoDelay(true);
542
-
543
- let connection;
544
- try {
545
- connection = await this._resolveConnectionForDevice(deviceId);
546
- } catch (error) {
547
- logger.error(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
548
- clientSocket.destroy();
549
- return;
550
- }
551
- if (!connection) {
552
- logger.error(() => `No relay connection available for device ${deviceIdHex}`);
553
- clientSocket.destroy();
554
- return;
655
+ if (useNative) {
656
+ clientSocket.pause();
555
657
  }
556
- const rpc = this._getRpcFor(connection);
658
+
659
+ let connection = null;
660
+ let rpc = null;
557
661
 
558
662
  let ref;
663
+ let clientClosed = false;
664
+ let remoteCleanupStarted = false;
665
+ let tlsSocketWrapper = null;
666
+
667
+ const closeRemoteRef = async () => {
668
+ if (remoteCleanupStarted) {
669
+ return;
670
+ }
671
+
672
+ if (!ref || !connection || !rpc) {
673
+ return;
674
+ }
675
+ remoteCleanupStarted = true;
676
+
677
+ const storedSocket = typeof connection.getClientSocket === 'function'
678
+ ? connection.getClientSocket(ref)
679
+ : null;
680
+ if (storedSocket && storedSocket !== clientSocket && typeof storedSocket.end === 'function') {
681
+ try { storedSocket.end(); } catch {}
682
+ }
683
+ if (typeof connection.deleteClientSocket === 'function') {
684
+ try { connection.deleteClientSocket(ref); } catch {}
685
+ }
686
+
687
+ try {
688
+ await rpc.portClose(ref);
689
+ logger.info(() => `Port closed on device for ref: ${ref.toString('hex')}`);
690
+ } catch (error) {
691
+ logger.error(() => `Error closing port on device: ${error}`);
692
+ }
693
+ };
694
+
695
+ clientSocket.once('close', () => {
696
+ clientClosed = true;
697
+ closeRemoteRef();
698
+ });
699
+ clientSocket.once('error', (err) => {
700
+ logger.error(() => `Client socket error: ${err}`);
701
+ });
702
+
559
703
  if (useNative) {
704
+ try {
705
+ connection = await this._resolveConnectionForDevice(deviceId);
706
+ } catch (error) {
707
+ logger.error(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
708
+ clientSocket.destroy();
709
+ return;
710
+ }
711
+ if (!connection) {
712
+ logger.error(() => `No relay connection available for device ${deviceIdHex}`);
713
+ clientSocket.destroy();
714
+ return;
715
+ }
716
+ rpc = this._getRpcFor(connection);
717
+
560
718
  // Open a new native relay port on the device for this client
561
719
  let physicalPort;
562
720
  try {
@@ -647,26 +805,30 @@ class BindPort {
647
805
  });
648
806
  clientSocket.on('end', cleanup);
649
807
  relaySocket.on('end', cleanup);
808
+ clientSocket.resume();
650
809
 
651
810
  return;
652
811
  }
653
812
 
654
813
  // Legacy API relay
655
814
  try {
656
- ref = await rpc.portOpen(deviceId, formattedTargetPort, 'rw');
657
- if (!ref) {
658
- logger.error(() => `Error opening port ${formattedTargetPort} on deviceId: ${deviceIdHex}`);
659
- clientSocket.destroy();
660
- return;
661
- } else {
662
- logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for client`);
663
- }
815
+ const opened = await this._openApiPortWithRelayFallback(deviceId, deviceIdHex, formattedTargetPort, 'rw');
816
+ connection = opened.connection;
817
+ rpc = opened.rpc;
818
+ ref = opened.ref;
819
+ logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for client`);
664
820
  } catch (error) {
665
821
  logger.error(() => `Error opening port ${formattedTargetPort} on device: ${error}`);
666
822
  clientSocket.destroy();
667
823
  return;
668
824
  }
669
825
 
826
+ if (clientClosed || clientSocket.destroyed) {
827
+ logger.warn(() => `Local client disconnected before port ${formattedTargetPort} opened; closing ref ${ref.toString('hex')}`);
828
+ await closeRemoteRef();
829
+ return;
830
+ }
831
+
670
832
  if (protocol === 'tls') {
671
833
  // For tls protocol, create a proper tls connection
672
834
  try {
@@ -713,16 +875,27 @@ class BindPort {
713
875
  diodeSocket,
714
876
  tlsSocket,
715
877
  end: () => {
716
- tlsSocket.end();
717
- diodeSocket._destroy(null, () => {});
878
+ try { tlsSocket.end(); } catch {}
879
+ try { diodeSocket._destroy(null, () => {}); } catch {}
718
880
  }
719
881
  };
882
+ tlsSocketWrapper = socketWrapper;
720
883
 
721
884
  // Store the socket wrapper
722
885
  connection.addClientSocket(ref, socketWrapper);
886
+ tlsSocket.once('close', () => {
887
+ if (!clientSocket.destroyed) {
888
+ clientSocket.end();
889
+ }
890
+ closeRemoteRef();
891
+ });
723
892
 
724
893
  } catch (error) {
725
894
  logger.error(() => `Error setting up tls connection: ${error}`);
895
+ if (tlsSocketWrapper && typeof tlsSocketWrapper.end === 'function') {
896
+ tlsSocketWrapper.end();
897
+ }
898
+ await closeRemoteRef();
726
899
  clientSocket.destroy();
727
900
  return;
728
901
  }
@@ -742,24 +915,9 @@ class BindPort {
742
915
  }
743
916
 
744
917
  // Handle client socket closure (common for all protocols)
745
- clientSocket.on('end', async () => {
918
+ clientSocket.once('end', () => {
746
919
  logger.info(() => 'Client disconnected');
747
- if (ref && connection.hasClientSocket(ref)) {
748
- try {
749
- await rpc.portClose(ref);
750
- logger.info(() => `Port closed on device for ref: ${ref.toString('hex')}`);
751
- connection.deleteClientSocket(ref);
752
- } catch (error) {
753
- logger.error(() => `Error closing port on device: ${error}`);
754
- }
755
- } else {
756
- logger.warn(() => 'Ref is invalid or no longer in clientSockets.');
757
- }
758
- });
759
-
760
- // Handle client socket errors
761
- clientSocket.on('error', (err) => {
762
- logger.error(() => `Client socket error: ${err}`);
920
+ closeRemoteRef();
763
921
  });
764
922
  });
765
923
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diodejs",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "A JavaScript client for interacting with the Diode network. It provides functionalities to bind and publish ports, send RPC commands, and handle responses.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/publishPort.js CHANGED
@@ -399,6 +399,7 @@ class PublishPort extends EventEmitter {
399
399
  if (session.localSocket && typeof session.localSocket.resume === 'function') {
400
400
  session.localSocket.resume();
401
401
  }
402
+ this._flushNativeTCPRelayPending(session);
402
403
 
403
404
  if (session.protocol === 'udp' && session.relaySocket && session.session) {
404
405
  const probe = nativeCrypto.createUdpPacket(session.session, Buffer.alloc(0));
@@ -502,6 +503,7 @@ class PublishPort extends EventEmitter {
502
503
  relaySocket: null,
503
504
  localSocket: null,
504
505
  timer: null,
506
+ pendingRelayChunks: [],
505
507
  };
506
508
  session.timer = setTimeout(() => {
507
509
  if (!session.ready) {
@@ -546,6 +548,35 @@ class PublishPort extends EventEmitter {
546
548
  this.nativeSessions.delete(session.physicalPort);
547
549
  }
548
550
 
551
+ _consumeNativeTCPRelayData(session, data) {
552
+ if (!session || !session.session || !session.localSocket) {
553
+ return true;
554
+ }
555
+ try {
556
+ const messages = nativeCrypto.consumeTcpFrames(session.session, data);
557
+ for (const msg of messages) {
558
+ session.localSocket.write(msg);
559
+ }
560
+ return true;
561
+ } catch (error) {
562
+ logger.error(() => `TCP decrypt error (${session.deviceId}): ${error}`);
563
+ return false;
564
+ }
565
+ }
566
+
567
+ _flushNativeTCPRelayPending(session) {
568
+ if (!session || session.protocol !== 'tcp' || !Array.isArray(session.pendingRelayChunks)) {
569
+ return;
570
+ }
571
+ const pending = session.pendingRelayChunks.splice(0, session.pendingRelayChunks.length);
572
+ for (const chunk of pending) {
573
+ if (!this._consumeNativeTCPRelayData(session, chunk)) {
574
+ this._cleanupNativeSession(session);
575
+ return;
576
+ }
577
+ }
578
+ }
579
+
549
580
  handleNativeTCPRelay(sessionId, physicalPortRef, session, connection) {
550
581
  const rpc = this._getRpcFor(connection);
551
582
  const { physicalPort, port, host, deviceId } = session;
@@ -611,14 +642,11 @@ class PublishPort extends EventEmitter {
611
642
  localSocket.on('end', cleanup);
612
643
 
613
644
  relaySocket.on('data', (data) => {
614
- if (!session.ready || !session.session) return;
615
- try {
616
- const messages = nativeCrypto.consumeTcpFrames(session.session, data);
617
- for (const msg of messages) {
618
- localSocket.write(msg);
619
- }
620
- } catch (error) {
621
- logger.error(() => `TCP decrypt error (${deviceId}): ${error}`);
645
+ if (!session.ready || !session.session) {
646
+ session.pendingRelayChunks.push(Buffer.from(data));
647
+ return;
648
+ }
649
+ if (!this._consumeNativeTCPRelayData(session, data)) {
622
650
  cleanup();
623
651
  }
624
652
  });
@@ -0,0 +1,242 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const EventEmitter = require('events');
4
+ const net = require('net');
5
+ const { once } = require('events');
6
+
7
+ const BindPort = require('../bindPort');
8
+
9
+ function makeRef(hex) {
10
+ return Buffer.from(hex.padStart(8, '0'), 'hex');
11
+ }
12
+
13
+ function makeRelay(hostKey, result, calls) {
14
+ const clientSockets = new Map();
15
+ const portCloseCalls = [];
16
+ return {
17
+ _managerHostKey: hostKey,
18
+ socket: { destroyed: false },
19
+ clientSockets,
20
+ portCloseCalls,
21
+ RPC: {
22
+ portOpen: async (_deviceId, port, flags) => {
23
+ calls.push({ hostKey, port, flags });
24
+ if (result instanceof Error) {
25
+ throw result;
26
+ }
27
+ return typeof result === 'function' ? result() : result;
28
+ },
29
+ portClose: async (ref) => {
30
+ portCloseCalls.push(ref);
31
+ },
32
+ },
33
+ addClientSocket(ref, socket) {
34
+ clientSockets.set(ref.toString('hex'), socket);
35
+ },
36
+ getClientSocket(ref) {
37
+ return clientSockets.get(ref.toString('hex'));
38
+ },
39
+ deleteClientSocket(ref) {
40
+ return clientSockets.delete(ref.toString('hex'));
41
+ },
42
+ hasClientSocket(ref) {
43
+ return clientSockets.has(ref.toString('hex'));
44
+ },
45
+ };
46
+ }
47
+
48
+ function deferred() {
49
+ let resolve;
50
+ let reject;
51
+ const promise = new Promise((res, rej) => {
52
+ resolve = res;
53
+ reject = rej;
54
+ });
55
+ return { promise, resolve, reject };
56
+ }
57
+
58
+ async function waitFor(predicate, timeoutMs = 1000) {
59
+ const startedAt = Date.now();
60
+ while (Date.now() - startedAt < timeoutMs) {
61
+ if (predicate()) {
62
+ return;
63
+ }
64
+ await new Promise((resolve) => setTimeout(resolve, 10));
65
+ }
66
+ assert.equal(predicate(), true);
67
+ }
68
+
69
+ class FakeManager extends EventEmitter {
70
+ constructor({ relays, resolvedRelay, nearestRelay, resolveError = null }) {
71
+ super();
72
+ this.relays = relays;
73
+ this.resolvedRelay = resolvedRelay;
74
+ this.nearestRelay = nearestRelay;
75
+ this.resolveError = resolveError;
76
+ this.deviceRelayCache = new Map();
77
+ }
78
+
79
+ async getConnectionForDevice() {
80
+ if (this.resolveError) {
81
+ throw this.resolveError;
82
+ }
83
+ return this.resolvedRelay;
84
+ }
85
+
86
+ getNearestConnection() {
87
+ return this.nearestRelay;
88
+ }
89
+
90
+ getConnections() {
91
+ return this.relays.slice();
92
+ }
93
+ }
94
+
95
+ test('API portopen falls back to another relay and clears stale device cache', async () => {
96
+ const calls = [];
97
+ const bad = makeRelay('bad.relay:41046', undefined, calls);
98
+ const goodRef = makeRef('01020304');
99
+ const good = makeRelay('good.relay:41046', goodRef, calls);
100
+ const manager = new FakeManager({
101
+ relays: [bad, good],
102
+ resolvedRelay: bad,
103
+ nearestRelay: bad,
104
+ });
105
+ manager.deviceRelayCache.set('8a72468957504d50247a260deb0218d504dd091b', {
106
+ hostKey: 'bad.relay:41046',
107
+ });
108
+
109
+ const bind = new BindPort(manager, {});
110
+ const result = await bind._openApiPortWithRelayFallback(
111
+ Buffer.from('8a72468957504d50247a260deb0218d504dd091b', 'hex'),
112
+ '8a72468957504d50247a260deb0218d504dd091b',
113
+ 'tls:8088',
114
+ 'rw'
115
+ );
116
+
117
+ assert.equal(result.connection, good);
118
+ assert.equal(result.ref, goodRef);
119
+ assert.deepEqual(calls.map((entry) => entry.hostKey), [
120
+ 'bad.relay:41046',
121
+ 'good.relay:41046',
122
+ ]);
123
+ assert.equal(manager.deviceRelayCache.has('8a72468957504d50247a260deb0218d504dd091b'), false);
124
+ });
125
+
126
+ test('API portopen tries connected relays when device relay lookup fails', async () => {
127
+ const calls = [];
128
+ const ref = makeRef('05060708');
129
+ const fallback = makeRelay('fallback.relay:41046', ref, calls);
130
+ const manager = new FakeManager({
131
+ relays: [fallback],
132
+ resolvedRelay: null,
133
+ nearestRelay: null,
134
+ resolveError: new Error('ticket lookup failed'),
135
+ });
136
+
137
+ const bind = new BindPort(manager, {});
138
+ const result = await bind._openApiPortWithRelayFallback(
139
+ Buffer.from('8a72468957504d50247a260deb0218d504dd091b', 'hex'),
140
+ '8a72468957504d50247a260deb0218d504dd091b',
141
+ 'tls:8088',
142
+ 'rw'
143
+ );
144
+
145
+ assert.equal(result.connection, fallback);
146
+ assert.equal(result.ref, ref);
147
+ assert.deepEqual(calls, [{
148
+ hostKey: 'fallback.relay:41046',
149
+ port: 'tls:8088',
150
+ flags: 'rw',
151
+ }]);
152
+ });
153
+
154
+ test('multiple BindPort instances share one manager listener set', () => {
155
+ const manager = new FakeManager({
156
+ relays: [],
157
+ resolvedRelay: null,
158
+ nearestRelay: null,
159
+ });
160
+
161
+ new BindPort(manager, {});
162
+ new BindPort(manager, {});
163
+
164
+ assert.equal(manager.listenerCount('unsolicited'), 1);
165
+ assert.equal(manager.listenerCount('end'), 1);
166
+ assert.equal(manager.listenerCount('error'), 1);
167
+ });
168
+
169
+ test('API bind closes ref when local TCP client disconnects before portopen returns', async () => {
170
+ const calls = [];
171
+ const opened = deferred();
172
+ const ref = makeRef('0a0b0c0d');
173
+ const relay = makeRelay('relay.example:41046', () => opened.promise, calls);
174
+ const manager = new FakeManager({
175
+ relays: [relay],
176
+ resolvedRelay: relay,
177
+ nearestRelay: relay,
178
+ });
179
+
180
+ const bind = new BindPort(manager, {
181
+ 0: {
182
+ targetPort: 8088,
183
+ deviceIdHex: '8a72468957504d50247a260deb0218d504dd091b',
184
+ protocol: 'tcp',
185
+ }
186
+ });
187
+ bind.addPort(0, 8088, '8a72468957504d50247a260deb0218d504dd091b', 'tcp');
188
+ const server = bind.servers.get(0);
189
+
190
+ try {
191
+ await once(server, 'listening');
192
+ const client = net.connect(server.address().port, '127.0.0.1');
193
+ await once(client, 'connect');
194
+ client.destroy();
195
+ await once(client, 'close');
196
+
197
+ opened.resolve(ref);
198
+ await waitFor(() => relay.portCloseCalls.length === 1);
199
+
200
+ assert.equal(relay.portCloseCalls[0].toString('hex'), ref.toString('hex'));
201
+ assert.equal(relay.hasClientSocket(ref), false);
202
+ } finally {
203
+ server.close();
204
+ }
205
+ });
206
+
207
+ test('API bind closes ref when local TCP client disconnects after portopen returns', async () => {
208
+ const calls = [];
209
+ const ref = makeRef('0e0f1011');
210
+ const relay = makeRelay('relay.example:41046', ref, calls);
211
+ const manager = new FakeManager({
212
+ relays: [relay],
213
+ resolvedRelay: relay,
214
+ nearestRelay: relay,
215
+ });
216
+
217
+ const bind = new BindPort(manager, {
218
+ 0: {
219
+ targetPort: 8088,
220
+ deviceIdHex: '8a72468957504d50247a260deb0218d504dd091b',
221
+ protocol: 'tcp',
222
+ }
223
+ });
224
+ bind.addPort(0, 8088, '8a72468957504d50247a260deb0218d504dd091b', 'tcp');
225
+ const server = bind.servers.get(0);
226
+
227
+ try {
228
+ await once(server, 'listening');
229
+ const client = net.connect(server.address().port, '127.0.0.1');
230
+ await once(client, 'connect');
231
+ await waitFor(() => relay.hasClientSocket(ref));
232
+
233
+ client.end();
234
+ await once(client, 'close');
235
+ await waitFor(() => relay.portCloseCalls.length === 1);
236
+
237
+ assert.equal(relay.portCloseCalls[0].toString('hex'), ref.toString('hex'));
238
+ assert.equal(relay.hasClientSocket(ref), false);
239
+ } finally {
240
+ server.close();
241
+ }
242
+ });
@@ -6,6 +6,7 @@ const tls = require('tls');
6
6
  const dgram = require('dgram');
7
7
 
8
8
  const PublishPort = require('../publishPort');
9
+ const nativeCrypto = require('../nativeCrypto');
9
10
 
10
11
  class FakeStreamSocket extends EventEmitter {
11
12
  constructor() {
@@ -13,11 +14,14 @@ class FakeStreamSocket extends EventEmitter {
13
14
  this.destroyed = false;
14
15
  this.remoteAddress = undefined;
15
16
  this.remotePort = undefined;
17
+ this.writes = [];
16
18
  }
17
19
 
18
20
  setNoDelay() {}
19
21
  pause() {}
20
- write() {}
22
+ write(data) {
23
+ this.writes.push(data);
24
+ }
21
25
  end() {}
22
26
  destroy() {
23
27
  this.destroyed = true;
@@ -307,6 +311,63 @@ test('native TCP publish connects local socket to configured host', () => {
307
311
  assert.deepEqual(connectCalls[1], { port: 8089, host: '10.0.0.8' });
308
312
  });
309
313
 
314
+ test('native TCP publish buffers relay data until handshake is ready', () => {
315
+ const connection = new FakeConnection();
316
+ const publishPort = new PublishPort(connection, {
317
+ 8089: { mode: 'public', host: '10.0.0.8' },
318
+ });
319
+ const originalConnect = net.connect;
320
+ const originalConsumeTcpFrames = nativeCrypto.consumeTcpFrames;
321
+ const sockets = [];
322
+
323
+ net.connect = (options, callback) => {
324
+ const socket = new FakeStreamSocket();
325
+ socket.options = options;
326
+ sockets.push(socket);
327
+ process.nextTick(() => {
328
+ if (typeof callback === 'function') {
329
+ callback();
330
+ }
331
+ socket.emit('connect');
332
+ });
333
+ return socket;
334
+ };
335
+ nativeCrypto.consumeTcpFrames = () => [Buffer.from('plain-http')];
336
+
337
+ try {
338
+ const session = {
339
+ physicalPort: 41000,
340
+ port: 8089,
341
+ host: '10.0.0.8',
342
+ protocol: 'tcp',
343
+ deviceId: '0x' + '11'.repeat(20),
344
+ ready: false,
345
+ session: null,
346
+ pendingRelayChunks: [],
347
+ };
348
+ publishPort.handleNativeTCPRelay(makeSessionId('07'), 41000, session, connection);
349
+
350
+ const relaySocket = sockets[0];
351
+ const localSocket = sockets[1];
352
+ relaySocket.emit('data', Buffer.from('early-encrypted'));
353
+
354
+ assert.equal(session.pendingRelayChunks.length, 1);
355
+ assert.equal(localSocket.writes.length, 0);
356
+
357
+ session.ready = true;
358
+ session.session = {};
359
+ publishPort._flushNativeTCPRelayPending(session);
360
+
361
+ assert.equal(session.pendingRelayChunks.length, 0);
362
+ assert.equal(localSocket.writes.length, 1);
363
+ assert.equal(localSocket.writes[0].toString(), 'plain-http');
364
+ } finally {
365
+ nativeCrypto.consumeTcpFrames = originalConsumeTcpFrames;
366
+ net.connect = originalConnect;
367
+ publishPort.stopListening();
368
+ }
369
+ });
370
+
310
371
  test('native UDP publish connects local socket to configured host', () => {
311
372
  const connection = new FakeConnection();
312
373
  const publishPort = new PublishPort(connection, {