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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +255 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-term",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Share your terminal instantly using a 6-digit code. Encrypted. No config.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 (!this.destroyed) {
368
- this.log('Connection to relay lost.');
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.');