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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +240 -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.0",
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,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.');