Skip to content
File

Blob: src/client/terminal/use-shared-session-detection.ts

typescript124 lines
1import { useEffect, useRef } from "react";
2import { useWorkspaceStore } from "../workspace/store";
3 
4/**
5 * Detects when another browser window/tab is attached to the same session
6 * using the BroadcastChannel API. Updates the zustand store's
7 * `isSharedSession` flag accordingly.
8 *
9 * Only detects same-browser, same-origin sharing. Cross-device or
10 * cross-browser sharing is not detectable without server-side tracking.
11 */
12 
13interface PresenceMessage {
14 type: "announce" | "query" | "leave";
15 sessionId: string;
16 tabId: string;
17}
18 
19interface PeerEntry {
20 sessionId: string;
21 lastSeen: number;
22}
23 
24const CHANNEL_NAME = "ccccocc:session-presence";
25const HEARTBEAT_MS = 10_000;
26const STALE_THRESHOLD_MS = 15_000;
27 
28export function useSharedSessionDetection(sessionId: string): void {
29 const setSharedSession = useWorkspaceStore((s) => s.setSharedSession);
30 const tabIdRef = useRef(crypto.randomUUID());
31 
32 useEffect(() => {
33 if (!sessionId || typeof BroadcastChannel === "undefined") {
34 setSharedSession(false);
35 return;
36 }
37 
38 const tabId = tabIdRef.current;
39 const peers = new Map<string, PeerEntry>();
40 const channel = new BroadcastChannel(CHANNEL_NAME);
41 
42 function recalculate() {
43 let shared = false;
44 for (const peer of peers.values()) {
45 if (peer.sessionId === sessionId) {
46 shared = true;
47 break;
48 }
49 }
50 setSharedSession(shared);
51 }
52 
53 function post(msg: PresenceMessage) {
54 try {
55 channel.postMessage(msg);
56 } catch {
57 // Channel may be closed during cleanup race
58 }
59 }
60 
61 channel.onmessage = (event: MessageEvent) => {
62 const msg = event.data as PresenceMessage;
63 if (!msg || msg.tabId === tabId) return;
64 
65 switch (msg.type) {
66 case "announce":
67 peers.set(msg.tabId, {
68 sessionId: msg.sessionId,
69 lastSeen: Date.now(),
70 });
71 recalculate();
72 break;
73 
74 case "query":
75 peers.set(msg.tabId, {
76 sessionId: msg.sessionId,
77 lastSeen: Date.now(),
78 });
79 // Respond so the querying tab discovers us
80 post({ type: "announce", sessionId, tabId });
81 recalculate();
82 break;
83 
84 case "leave":
85 peers.delete(msg.tabId);
86 recalculate();
87 break;
88 }
89 };
90 
91 // Announce ourselves and query for existing peers
92 post({ type: "announce", sessionId, tabId });
93 post({ type: "query", sessionId, tabId });
94 
95 // Heartbeat: re-announce and evict stale peers
96 const heartbeat = setInterval(() => {
97 post({ type: "announce", sessionId, tabId });
98 
99 const now = Date.now();
100 for (const [id, peer] of peers) {
101 if (now - peer.lastSeen > STALE_THRESHOLD_MS) {
102 peers.delete(id);
103 }
104 }
105 recalculate();
106 }, HEARTBEAT_MS);
107 
108 // Announce departure on tab close
109 const onUnload = () => {
110 post({ type: "leave", sessionId, tabId });
111 };
112 window.addEventListener("beforeunload", onUnload);
113 
114 return () => {
115 clearInterval(heartbeat);
116 window.removeEventListener("beforeunload", onUnload);
117 post({ type: "leave", sessionId, tabId });
118 channel.close();
119 peers.clear();
120 setSharedSession(false);
121 };
122 }, [sessionId, setSharedSession]);
123}