peer-term 1.0.0 → 1.1.0
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 +240 -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,227 @@ 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
|
+
try {
|
|
606
|
+
const newWs = new WebSocket(this.relayUrl);
|
|
607
|
+
|
|
608
|
+
newWs.on('open', () => {
|
|
609
|
+
newWs.send(JSON.stringify({ type: 'host-rejoin', code: this.code }));
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
newWs.on('message', (raw) => {
|
|
613
|
+
let msg;
|
|
614
|
+
try {
|
|
615
|
+
msg = JSON.parse(raw.toString());
|
|
616
|
+
} catch {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (msg.type === 'rejoined') {
|
|
621
|
+
// Success — replace the old ws with this new one
|
|
622
|
+
this.ws = newWs;
|
|
623
|
+
this._stopReconnecting();
|
|
624
|
+
this.log(`✅ Reconnected. Session restored.`);
|
|
625
|
+
|
|
626
|
+
// Re-attach the full message handler by wiring up events
|
|
627
|
+
this._attachWsHandlers(newWs);
|
|
628
|
+
|
|
629
|
+
// Restart heartbeat if client is connected
|
|
630
|
+
if (this.isClientConnected) {
|
|
631
|
+
this.startHeartbeat();
|
|
632
|
+
}
|
|
633
|
+
} else if (msg.type === 'error') {
|
|
634
|
+
this.log(`Rejoin failed: ${msg.msg}`);
|
|
635
|
+
this._stopReconnecting();
|
|
636
|
+
this.destroy();
|
|
637
|
+
try { newWs.close(); } catch {}
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
newWs.on('error', () => {
|
|
642
|
+
// Connection failed, next retry in 5s
|
|
643
|
+
try { newWs.close(); } catch {}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
newWs.on('close', () => {
|
|
647
|
+
// If we haven't adopted this ws, nothing to do — retry will fire
|
|
648
|
+
});
|
|
649
|
+
} catch (e) {
|
|
650
|
+
// Connection failed, next retry in 5s
|
|
651
|
+
}
|
|
652
|
+
}, 5000);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
_stopReconnecting() {
|
|
656
|
+
if (this.reconnectTimer) {
|
|
657
|
+
clearInterval(this.reconnectTimer);
|
|
658
|
+
this.reconnectTimer = null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Re-attach message/close/error handlers to a new WebSocket after rejoin.
|
|
664
|
+
* This mirrors the handlers set in _tryConnect but skips the initial registration flow.
|
|
665
|
+
*/
|
|
666
|
+
_attachWsHandlers(newWs) {
|
|
667
|
+
newWs.on('message', async (raw) => {
|
|
668
|
+
let msg;
|
|
669
|
+
try {
|
|
670
|
+
msg = JSON.parse(raw.toString());
|
|
671
|
+
} catch {
|
|
672
|
+
this.log('Invalid message from relay');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
switch (msg.type) {
|
|
677
|
+
case 'client-connected': {
|
|
678
|
+
if (this.awaitingRejoin) {
|
|
679
|
+
this.log('Client reconnected.');
|
|
680
|
+
this.awaitingRejoin = false;
|
|
681
|
+
} else {
|
|
682
|
+
this.log('Client connected! Starting key exchange...');
|
|
683
|
+
}
|
|
684
|
+
this.isClientConnected = true;
|
|
685
|
+
this.missedPings = 0;
|
|
686
|
+
|
|
687
|
+
this.keyPair = await generateKeyPair();
|
|
688
|
+
const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
|
|
689
|
+
this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
case 'key-exchange': {
|
|
694
|
+
if (!this.keyPair) return;
|
|
695
|
+
this.logDebug('Deriving shared secret...');
|
|
696
|
+
|
|
697
|
+
const peerPublicKey = await importPublicKey(msg.publicKey);
|
|
698
|
+
this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
|
|
699
|
+
this.log('Encrypted tunnel active');
|
|
700
|
+
|
|
701
|
+
this.startHeartbeat();
|
|
702
|
+
|
|
703
|
+
if (!this.ptyProcess) {
|
|
704
|
+
this.spawnTerminal();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
this._initiateWebRTC();
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
case 'signal': {
|
|
712
|
+
if (this.webrtc && this.sharedKey) {
|
|
713
|
+
try {
|
|
714
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
715
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
716
|
+
} catch (err) {
|
|
717
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
case 'heartbeat': {
|
|
724
|
+
this.missedPings = 0;
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
case 'data': {
|
|
729
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
730
|
+
try {
|
|
731
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
732
|
+
|
|
733
|
+
if (msg.meta === 'resize') {
|
|
734
|
+
try {
|
|
735
|
+
const resizeData = JSON.parse(plaintext);
|
|
736
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
737
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
738
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
739
|
+
}
|
|
740
|
+
} catch {}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (this.readOnly) return;
|
|
745
|
+
this.ptyProcess.write(plaintext);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
748
|
+
}
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
case 'peer-disconnected': {
|
|
753
|
+
this.log('Client disconnected. Rejoin window: 2 minutes.');
|
|
754
|
+
this.isClientConnected = false;
|
|
755
|
+
this.awaitingRejoin = true;
|
|
756
|
+
this.sharedKey = null;
|
|
757
|
+
this.keyPair = null;
|
|
758
|
+
this.stopHeartbeat();
|
|
759
|
+
this._cleanupWebRTC();
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
case 'session-expired': {
|
|
764
|
+
this.log('Rejoin window expired. Session ended.');
|
|
765
|
+
this.destroy();
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
case 'rejoined': {
|
|
770
|
+
this.log(`✅ Reconnected. Session restored. (code: ${msg.code})`);
|
|
771
|
+
if (this.isClientConnected) {
|
|
772
|
+
this.startHeartbeat();
|
|
773
|
+
}
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
case 'error': {
|
|
778
|
+
this.log(`Error: ${msg.msg}`);
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
newWs.on('close', () => {
|
|
785
|
+
if (this.destroyed) return;
|
|
786
|
+
if (this.intentionalClose) {
|
|
787
|
+
this.log('Connection to relay closed (intentional).');
|
|
788
|
+
this.destroy();
|
|
789
|
+
} else {
|
|
790
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
791
|
+
this.stopHeartbeat();
|
|
792
|
+
this._startReconnecting();
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
newWs.on('error', (err) => {
|
|
797
|
+
this.log(`WS error: ${err.message}`);
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
567
801
|
// ─── Destroy ────────────────────────────────────────────────────────
|
|
568
802
|
destroy() {
|
|
569
803
|
if (this.destroyed) return;
|
|
570
804
|
this.destroyed = true;
|
|
805
|
+
this.intentionalClose = true;
|
|
571
806
|
this.stopHeartbeat();
|
|
807
|
+
this._stopReconnecting();
|
|
572
808
|
// Clean up viewer
|
|
573
809
|
if (this.viewerSocket) {
|
|
574
810
|
try { this.viewerSocket.destroy(); } catch {}
|
|
@@ -585,6 +821,8 @@ class Session {
|
|
|
585
821
|
this.ptyProcess = null;
|
|
586
822
|
}
|
|
587
823
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
824
|
+
// Send session-ended so relay knows this is intentional and destroys session immediately
|
|
825
|
+
try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
|
|
588
826
|
this.ws.close();
|
|
589
827
|
}
|
|
590
828
|
this.log('Session ended.');
|