const LOCK_TTL_MS = 12_000 const HEARTBEAT_MS = 4_000 const SESSION_PROBE_WAIT_MS = 80 export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:' const CHANNEL_NAME = 'jgjs-project-lock-channel' const TAB_SESSION_ID_KEY = 'jgjs-project-tab-session-id' const runtimeInstanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` type LockPayload = { sessionId: string projectId: string updatedAt: number } const now = () => Date.now() const lockKeyOf = (projectId: string) => `${PROJECT_LOCK_KEY_PREFIX}${projectId}` const parseLockPayload = (raw: string | null): LockPayload | null => { if (!raw) return null try { const data = JSON.parse(raw) as Partial if (!data || typeof data !== 'object') return null if (typeof data.sessionId !== 'string' || typeof data.projectId !== 'string') return null if (typeof data.updatedAt !== 'number' || !Number.isFinite(data.updatedAt)) return null return { sessionId: data.sessionId, projectId: data.projectId, updatedAt: data.updatedAt } } catch { return null } } const isExpired = (payload: LockPayload) => now() - payload.updatedAt > LOCK_TTL_MS const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}` const debugLock = (message: string, payload?: Record) => { try { if (payload) { console.debug('[project-lock]', message, payload) } else { console.debug('[project-lock]', message) } } catch { // ignore console errors } } const getOrCreateTabSessionId = () => { try { const existing = String(window.sessionStorage.getItem(TAB_SESSION_ID_KEY) || '').trim() if (existing) return existing const created = randomSessionId() window.sessionStorage.setItem(TAB_SESSION_ID_KEY, created) return created } catch { return randomSessionId() } } type InitProjectLockParams = { projectId: string onConflict: (conflicted: boolean) => void } export const initProjectSessionLock = (params: InitProjectLockParams) => { const projectId = String(params.projectId || '').trim() const onConflict = params.onConflict let sessionId = getOrCreateTabSessionId() const key = lockKeyOf(projectId) let conflicted = false let heartbeatTimer: ReturnType | null = null let bc: BroadcastChannel | null = null let released = false const emitConflict = (next: boolean) => { if (conflicted === next) return conflicted = next debugLock('emit conflict', { projectId, sessionId, runtimeInstanceId, conflicted: next }) onConflict(next) } const writeHeartbeat = () => { if (conflicted || released) return const payload: LockPayload = { sessionId, projectId, updatedAt: now() } localStorage.setItem(key, JSON.stringify(payload)) debugLock('write heartbeat', { projectId, sessionId, runtimeInstanceId, key }) bc?.postMessage({ type: 'heartbeat', projectId, sessionId }) } const clearOwnLock = () => { const current = parseLockPayload(localStorage.getItem(key)) if (current?.sessionId === sessionId) { localStorage.removeItem(key) debugLock('clear own lock', { projectId, sessionId, runtimeInstanceId, key }) } bc?.postMessage({ type: 'release', projectId, sessionId }) } const detectConflict = () => { const current = parseLockPayload(localStorage.getItem(key)) debugLock('detect conflict', { projectId, sessionId, runtimeInstanceId, current, expired: current ? isExpired(current) : null }) if (!current || isExpired(current)) { emitConflict(false) writeHeartbeat() return } console.log(sessionId) emitConflict(current.sessionId !== sessionId) } const onStorage = (event: StorageEvent) => { if (event.key !== key) return detectConflict() } const onBeforeUnload = () => { clearOwnLock() } const startHeartbeat = () => { if (heartbeatTimer || conflicted || released) return heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS) } if (typeof BroadcastChannel !== 'undefined') { bc = new BroadcastChannel(CHANNEL_NAME) bc.onmessage = (event: MessageEvent<{ type?: string projectId?: string sessionId?: string candidateSessionId?: string runtimeId?: string responderRuntimeId?: string targetRuntimeId?: string }>) => { const payload = event.data if (!payload || payload.projectId !== projectId) return if (payload.type === 'session-probe') { if (payload.candidateSessionId !== sessionId) return if (payload.runtimeId === runtimeInstanceId) return debugLock('session probe received', { projectId, sessionId, runtimeInstanceId, fromRuntimeId: payload.runtimeId }) bc?.postMessage({ type: 'session-probe-ack', projectId, candidateSessionId: sessionId, responderRuntimeId: runtimeInstanceId, targetRuntimeId: payload.runtimeId }) return } if (payload.type === 'session-probe-ack') { return } if (payload.sessionId === sessionId) return if (payload.type === 'heartbeat') { detectConflict() } } } const ensureUniqueSessionId = async () => { if (!bc) return const responders = new Set() const onMessage = (event: MessageEvent<{ type?: string projectId?: string candidateSessionId?: string responderRuntimeId?: string targetRuntimeId?: string }>) => { const payload = event.data if (!payload || payload.type !== 'session-probe-ack') return if (payload.projectId !== projectId) return if (payload.candidateSessionId !== sessionId) return if (payload.targetRuntimeId !== runtimeInstanceId) return if (payload.responderRuntimeId) { responders.add(payload.responderRuntimeId) } } bc.addEventListener('message', onMessage as EventListener) try { bc.postMessage({ type: 'session-probe', projectId, candidateSessionId: sessionId, runtimeId: runtimeInstanceId }) await new Promise(resolve => window.setTimeout(resolve, SESSION_PROBE_WAIT_MS)) } finally { bc.removeEventListener('message', onMessage as EventListener) } if (released || responders.size === 0) return const shouldRotate = Array.from(responders).some(responderRuntimeId => responderRuntimeId < runtimeInstanceId) debugLock('session probe result', { projectId, sessionId, runtimeInstanceId, responders: Array.from(responders), shouldRotate }) if (!shouldRotate) return const nextSessionId = randomSessionId() try { window.sessionStorage.setItem(TAB_SESSION_ID_KEY, nextSessionId) } catch { // ignore sessionStorage write errors } debugLock('rotate duplicated session id', { projectId, previousSessionId: sessionId, nextSessionId, runtimeInstanceId }) sessionId = nextSessionId } void (async () => { try { await ensureUniqueSessionId() if (released) return const existing = parseLockPayload(localStorage.getItem(key)) debugLock('init lock state', { projectId, sessionId, runtimeInstanceId, existing }) if (existing && !isExpired(existing) && existing.sessionId !== sessionId) { emitConflict(true) } else { writeHeartbeat() } startHeartbeat() } catch (error) { console.error('init project session lock failed:', error) } })() window.addEventListener('storage', onStorage) window.addEventListener('beforeunload', onBeforeUnload) return { get conflicted() { return conflicted }, release: () => { released = true if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer = null } window.removeEventListener('storage', onStorage) window.removeEventListener('beforeunload', onBeforeUnload) clearOwnLock() if (bc) { bc.close() bc = null } } } } export const hasActiveProjectSessionLock = (projectIdRaw: string) => { const projectId = String(projectIdRaw || '').trim() if (!projectId) return false try { const payload = parseLockPayload(localStorage.getItem(lockKeyOf(projectId))) return Boolean(payload && !isExpired(payload)) } catch { return false } } export const collectActiveProjectSessionLocks = (projectIds: string[]) => { const result = new Set() for (const projectId of projectIds) { if (hasActiveProjectSessionLock(projectId)) { result.add(String(projectId || '').trim()) } } return result }