1
This commit is contained in:
commit
6fdf336b9e
128 files changed
+36789
No files matched your search
@@ -0,0 +1,285 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user