286 lines
8.6 KiB
TypeScript
286 lines
8.6 KiB
TypeScript
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<LockPayload>
|
|
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<string, unknown>) => {
|
|
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<typeof setInterval> | 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<string>()
|
|
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<string>()
|
|
for (const projectId of projectIds) {
|
|
if (hasActiveProjectSessionLock(projectId)) {
|
|
result.add(String(projectId || '').trim())
|
|
}
|
|
}
|
|
return result
|
|
}
|