This commit is contained in:
zwgczx committed 2026-04-07 16:27:49 +08:00
commit 6fdf336b9e
128 files changed
+36789

No files matched your search

+285
View File
@@ -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
}