peer-term 1.0.0 → 1.1.1
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/package.json +1 -1
- package/src/index.js +255 -2
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -181,7 +181,10 @@ class Session {
|
|
|
181
181
|
this.isClientConnected = false;
|
|
182
182
|
this.awaitingRejoin = false;
|
|
183
183
|
this.destroyed = false;
|
|
184
|
+
this.intentionalClose = false;
|
|
185
|
+
this.reconnectTimer = null;
|
|
184
186
|
this.createdAt = Date.now();
|
|
187
|
+
this.relayUrl = null; // Track which relay URL we connected to
|
|
185
188
|
|
|
186
189
|
// Phase 4: WebRTC state
|
|
187
190
|
this.webrtc = null;
|
|
@@ -221,6 +224,7 @@ class Session {
|
|
|
221
224
|
async _tryConnect(url) {
|
|
222
225
|
return new Promise((resolve, reject) => {
|
|
223
226
|
this.ws = new WebSocket(url);
|
|
227
|
+
this.relayUrl = url;
|
|
224
228
|
|
|
225
229
|
let isConnected = false;
|
|
226
230
|
|
|
@@ -354,6 +358,15 @@ class Session {
|
|
|
354
358
|
break;
|
|
355
359
|
}
|
|
356
360
|
|
|
361
|
+
case 'rejoined': {
|
|
362
|
+
this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
|
|
363
|
+
// Restart heartbeat if client is connected
|
|
364
|
+
if (this.isClientConnected) {
|
|
365
|
+
this.startHeartbeat();
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
357
370
|
case 'error': {
|
|
358
371
|
this.log(`Error: ${msg.msg}`);
|
|
359
372
|
break;
|
|
@@ -364,9 +377,16 @@ class Session {
|
|
|
364
377
|
this.ws.on('close', () => {
|
|
365
378
|
if (!isConnected) {
|
|
366
379
|
reject(new Error('WebSocket closed before connection was established'));
|
|
367
|
-
} else if (
|
|
368
|
-
|
|
380
|
+
} else if (this.destroyed) {
|
|
381
|
+
// Already destroyed, nothing to do
|
|
382
|
+
} else if (this.intentionalClose) {
|
|
383
|
+
this.log('Connection to relay closed (intentional).');
|
|
369
384
|
this.destroy();
|
|
385
|
+
} else {
|
|
386
|
+
// Unexpected disconnect — start reconnect loop
|
|
387
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
388
|
+
this.stopHeartbeat();
|
|
389
|
+
this._startReconnecting();
|
|
370
390
|
}
|
|
371
391
|
});
|
|
372
392
|
|
|
@@ -564,11 +584,242 @@ class Session {
|
|
|
564
584
|
this.log('Opening terminal viewer...');
|
|
565
585
|
}
|
|
566
586
|
|
|
587
|
+
// ─── Host Reconnect Logic ───────────────────────────────────────────
|
|
588
|
+
_startReconnecting() {
|
|
589
|
+
if (this.destroyed || this.intentionalClose) return;
|
|
590
|
+
|
|
591
|
+
let attempts = 0;
|
|
592
|
+
const maxAttempts = 24; // 24 × 5s = 2 minutes
|
|
593
|
+
|
|
594
|
+
this.reconnectTimer = setInterval(() => {
|
|
595
|
+
attempts++;
|
|
596
|
+
if (attempts > maxAttempts) {
|
|
597
|
+
this.log('❌ Could not reconnect. Session expired.');
|
|
598
|
+
this._stopReconnecting();
|
|
599
|
+
this.destroy();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.log(`⏳ Reconnect attempt ${attempts}/${maxAttempts}...`);
|
|
604
|
+
|
|
605
|
+
// Close any in-flight reconnect socket from the previous tick
|
|
606
|
+
if (this._pendingReconnectWs) {
|
|
607
|
+
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
608
|
+
this._pendingReconnectWs = null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const newWs = new WebSocket(this.relayUrl);
|
|
613
|
+
this._pendingReconnectWs = newWs;
|
|
614
|
+
|
|
615
|
+
newWs.on('open', () => {
|
|
616
|
+
newWs.send(JSON.stringify({ type: 'host-rejoin', code: this.code }));
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
newWs.on('message', (raw) => {
|
|
620
|
+
let msg;
|
|
621
|
+
try {
|
|
622
|
+
msg = JSON.parse(raw.toString());
|
|
623
|
+
} catch {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (msg.type === 'rejoined') {
|
|
628
|
+
// Success — replace the old ws with this new one
|
|
629
|
+
this._pendingReconnectWs = null;
|
|
630
|
+
this.ws = newWs;
|
|
631
|
+
this._stopReconnecting();
|
|
632
|
+
this.log(`✅ Reconnected. Session restored.`);
|
|
633
|
+
|
|
634
|
+
// Re-attach the full message handler by wiring up events
|
|
635
|
+
this._attachWsHandlers(newWs);
|
|
636
|
+
|
|
637
|
+
// Restart heartbeat if client is connected
|
|
638
|
+
if (this.isClientConnected) {
|
|
639
|
+
this.startHeartbeat();
|
|
640
|
+
}
|
|
641
|
+
} else if (msg.type === 'error') {
|
|
642
|
+
this.log(`Rejoin failed: ${msg.msg}`);
|
|
643
|
+
this._pendingReconnectWs = null;
|
|
644
|
+
this._stopReconnecting();
|
|
645
|
+
this.destroy();
|
|
646
|
+
try { newWs.close(); } catch {}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
newWs.on('error', () => {
|
|
651
|
+
// Connection failed, next retry in 5s
|
|
652
|
+
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
653
|
+
try { newWs.close(); } catch {}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
newWs.on('close', () => {
|
|
657
|
+
// Clean up reference if this was the pending socket
|
|
658
|
+
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
659
|
+
});
|
|
660
|
+
} catch (e) {
|
|
661
|
+
// Connection failed, next retry in 5s
|
|
662
|
+
}
|
|
663
|
+
}, 5000);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
_stopReconnecting() {
|
|
667
|
+
if (this.reconnectTimer) {
|
|
668
|
+
clearInterval(this.reconnectTimer);
|
|
669
|
+
this.reconnectTimer = null;
|
|
670
|
+
}
|
|
671
|
+
if (this._pendingReconnectWs) {
|
|
672
|
+
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
673
|
+
this._pendingReconnectWs = null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Re-attach message/close/error handlers to a new WebSocket after rejoin.
|
|
679
|
+
* This mirrors the handlers set in _tryConnect but skips the initial registration flow.
|
|
680
|
+
*/
|
|
681
|
+
_attachWsHandlers(newWs) {
|
|
682
|
+
newWs.on('message', async (raw) => {
|
|
683
|
+
let msg;
|
|
684
|
+
try {
|
|
685
|
+
msg = JSON.parse(raw.toString());
|
|
686
|
+
} catch {
|
|
687
|
+
this.log('Invalid message from relay');
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
switch (msg.type) {
|
|
692
|
+
case 'client-connected': {
|
|
693
|
+
if (this.awaitingRejoin) {
|
|
694
|
+
this.log('Client reconnected.');
|
|
695
|
+
this.awaitingRejoin = false;
|
|
696
|
+
} else {
|
|
697
|
+
this.log('Client connected! Starting key exchange...');
|
|
698
|
+
}
|
|
699
|
+
this.isClientConnected = true;
|
|
700
|
+
this.missedPings = 0;
|
|
701
|
+
|
|
702
|
+
this.keyPair = await generateKeyPair();
|
|
703
|
+
const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
|
|
704
|
+
this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
case 'key-exchange': {
|
|
709
|
+
if (!this.keyPair) return;
|
|
710
|
+
this.logDebug('Deriving shared secret...');
|
|
711
|
+
|
|
712
|
+
const peerPublicKey = await importPublicKey(msg.publicKey);
|
|
713
|
+
this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
|
|
714
|
+
this.log('Encrypted tunnel active');
|
|
715
|
+
|
|
716
|
+
this.startHeartbeat();
|
|
717
|
+
|
|
718
|
+
if (!this.ptyProcess) {
|
|
719
|
+
this.spawnTerminal();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
this._initiateWebRTC();
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
case 'signal': {
|
|
727
|
+
if (this.webrtc && this.sharedKey) {
|
|
728
|
+
try {
|
|
729
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
730
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
731
|
+
} catch (err) {
|
|
732
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
case 'heartbeat': {
|
|
739
|
+
this.missedPings = 0;
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
case 'data': {
|
|
744
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
745
|
+
try {
|
|
746
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
747
|
+
|
|
748
|
+
if (msg.meta === 'resize') {
|
|
749
|
+
try {
|
|
750
|
+
const resizeData = JSON.parse(plaintext);
|
|
751
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
752
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
753
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
754
|
+
}
|
|
755
|
+
} catch {}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (this.readOnly) return;
|
|
760
|
+
this.ptyProcess.write(plaintext);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
763
|
+
}
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
case 'peer-disconnected': {
|
|
768
|
+
this.log('Client disconnected. Rejoin window: 2 minutes.');
|
|
769
|
+
this.isClientConnected = false;
|
|
770
|
+
this.awaitingRejoin = true;
|
|
771
|
+
this.sharedKey = null;
|
|
772
|
+
this.keyPair = null;
|
|
773
|
+
this.stopHeartbeat();
|
|
774
|
+
this._cleanupWebRTC();
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
case 'session-expired': {
|
|
779
|
+
this.log('Rejoin window expired. Session ended.');
|
|
780
|
+
this.destroy();
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
case 'rejoined': {
|
|
785
|
+
this.log(`✅ Reconnected. Session restored. (code: ${msg.code})`);
|
|
786
|
+
if (this.isClientConnected) {
|
|
787
|
+
this.startHeartbeat();
|
|
788
|
+
}
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
case 'error': {
|
|
793
|
+
this.log(`Error: ${msg.msg}`);
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
newWs.on('close', () => {
|
|
800
|
+
if (this.destroyed) return;
|
|
801
|
+
if (this.intentionalClose) {
|
|
802
|
+
this.log('Connection to relay closed (intentional).');
|
|
803
|
+
this.destroy();
|
|
804
|
+
} else {
|
|
805
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
806
|
+
this.stopHeartbeat();
|
|
807
|
+
this._startReconnecting();
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
newWs.on('error', (err) => {
|
|
812
|
+
this.log(`WS error: ${err.message}`);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
567
816
|
// ─── Destroy ────────────────────────────────────────────────────────
|
|
568
817
|
destroy() {
|
|
569
818
|
if (this.destroyed) return;
|
|
570
819
|
this.destroyed = true;
|
|
820
|
+
this.intentionalClose = true;
|
|
571
821
|
this.stopHeartbeat();
|
|
822
|
+
this._stopReconnecting();
|
|
572
823
|
// Clean up viewer
|
|
573
824
|
if (this.viewerSocket) {
|
|
574
825
|
try { this.viewerSocket.destroy(); } catch {}
|
|
@@ -585,6 +836,8 @@ class Session {
|
|
|
585
836
|
this.ptyProcess = null;
|
|
586
837
|
}
|
|
587
838
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
839
|
+
// Send session-ended so relay knows this is intentional and destroys session immediately
|
|
840
|
+
try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
|
|
588
841
|
this.ws.close();
|
|
589
842
|
}
|
|
590
843
|
this.log('Session ended.');
|