UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

182 lines (149 loc) 4.17 kB
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 } } }