@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
182 lines (149 loc) • 4.17 kB
text/typescript
import { BaseLeaderElection } from './LeaderElection'
interface LeaderMessage {
type: `heartbeat` | `election` | `leadership-claim`
tabId: string
timestamp: number
}
export class BroadcastChannelLeader extends BaseLeaderElection {
private channelName: string
private tabId: string
private channel: BroadcastChannel | null = null
private heartbeatInterval: number | null = null
private electionTimeout: number | null = null
private lastLeaderHeartbeat = 0
private readonly heartbeatIntervalMs = 5000
private readonly electionTimeoutMs = 10000
constructor(channelName = `offline-executor-leader`) {
super()
this.channelName = channelName
this.tabId = crypto.randomUUID()
this.setupChannel()
}
private setupChannel(): void {
if (!this.isBroadcastChannelSupported()) {
return
}
this.channel = new BroadcastChannel(this.channelName)
this.channel.addEventListener(`message`, this.handleMessage)
}
private handleMessage = (event: MessageEvent<LeaderMessage>): void => {
const { type, tabId, timestamp } = event.data
if (tabId === this.tabId) {
return
}
switch (type) {
case `heartbeat`:
if (this.isLeaderState && tabId < this.tabId) {
this.releaseLeadership()
} else if (!this.isLeaderState) {
this.lastLeaderHeartbeat = timestamp
this.cancelElection()
}
break
case `election`:
if (this.isLeaderState) {
this.sendHeartbeat()
} else if (tabId > this.tabId) {
this.startElection()
}
break
case `leadership-claim`:
if (this.isLeaderState && tabId < this.tabId) {
this.releaseLeadership()
}
break
}
}
async requestLeadership(): Promise<boolean> {
if (!this.isBroadcastChannelSupported()) {
return false
}
if (this.isLeaderState) {
return true
}
this.startElection()
return new Promise((resolve) => {
setTimeout(() => {
resolve(this.isLeaderState)
}, 1000)
})
}
private startElection(): void {
if (this.electionTimeout) {
return
}
this.sendMessage({
type: `election`,
tabId: this.tabId,
timestamp: Date.now(),
})
this.electionTimeout = window.setTimeout(() => {
const timeSinceLastHeartbeat = Date.now() - this.lastLeaderHeartbeat
if (timeSinceLastHeartbeat > this.electionTimeoutMs) {
this.claimLeadership()
}
this.electionTimeout = null
}, this.electionTimeoutMs)
}
private cancelElection(): void {
if (this.electionTimeout) {
clearTimeout(this.electionTimeout)
this.electionTimeout = null
}
}
private claimLeadership(): void {
this.notifyLeadershipChange(true)
this.sendMessage({
type: `leadership-claim`,
tabId: this.tabId,
timestamp: Date.now(),
})
this.startHeartbeat()
}
private startHeartbeat(): void {
if (this.heartbeatInterval) {
return
}
this.sendHeartbeat()
this.heartbeatInterval = window.setInterval(() => {
this.sendHeartbeat()
}, this.heartbeatIntervalMs)
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
private sendHeartbeat(): void {
this.sendMessage({
type: `heartbeat`,
tabId: this.tabId,
timestamp: Date.now(),
})
}
private sendMessage(message: LeaderMessage): void {
if (this.channel) {
this.channel.postMessage(message)
}
}
releaseLeadership(): void {
this.stopHeartbeat()
this.cancelElection()
this.notifyLeadershipChange(false)
}
private isBroadcastChannelSupported(): boolean {
return typeof BroadcastChannel !== `undefined`
}
static isSupported(): boolean {
return typeof BroadcastChannel !== `undefined`
}
dispose(): void {
this.releaseLeadership()
if (this.channel) {
this.channel.removeEventListener(`message`, this.handleMessage)
this.channel.close()
this.channel = null
}
}
}