mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
## Summary Improves the web UI's React Doctor audit score by separating reusable helpers from React component modules, tightening effect/state ownership, and extracting real component boundaries in the install wizard, stage activity view, run-files diff browser, RunDetail route, and Runs workspace. The branch removes the previously deferred RunDetail and Runs giant-component diagnostics without changing RunDetail UX, route contracts, action ordering, or Runs workspace behavior. | Metric | Main baseline | Initial PR | Current PR | |--------|---------------|------------|------------| | React Doctor score | 63 | 71 | 99 | | React Doctor errors | 123 | 0 | 0 | | React Doctor warnings | 241 | 163 | 3 | | React Doctor diagnostics | 364 | 163 | 3 | ## Changes - Moves exported helper logic out of component files so Fast Refresh/component-export rules no longer dominate the audit. - Adds a targeted React Doctor config exception for React Router route modules, where non-component exports like route metadata are intentional. - Refactors low-risk state/effect patterns: keyed interview question state, reducer-backed editable run title state, event-owned preview opening, route-keyed insights editor initialization, refresh timer ownership, and selection/derived list cleanup. - Reworks `InstallApp` around an install reducer, a controller hook for install lifecycle state, and focused wizard step components for LLM, server, object-store, sandbox, and GitHub setup. - Moves `RunStages` selected-stage activity into a keyed boundary for panel/debug detail state while preserving stage activity filters across navigation. - Extracts the `RunFiles` loaded diff-browser view from route/query coordination so the route owns data/URL state and the loaded view owns rendering. - Splits `RunDetail` into route-local header, actions, tab shell, docked controls, model, and lifecycle-toast modules; the actions menu now uses grouped descriptors instead of a large boolean/callback prop matrix. - Extracts Runs workspace preference ownership into `useRunsWorkspacePreferences` and moves toolbar rendering into `RunsToolbar`, leaving the route focused on data, DnD state, filtering, and view selection. - Guards `InsightsEditor` query execution with a latest-run id and timeout cleanup so stale or unmounted mock query runs cannot overwrite newer results. - Adds regression coverage for archived-run deletion from RunDetail and stale-result handling in InsightsEditor. - Improves semantic/accessibility coverage with labeled controls, native meter/section semantics, decorative status dots, and clearer unavailable copy. - Removes dead UI code and applies local suppressions only where the rule is a documented false positive or an intentional imperative integration boundary. ## Remaining React Doctor warnings Current score is 99 with 0 errors and 3 warnings. The remaining warnings are intentionally left for separate judgment rather than mechanical churn: - `prefer-useReducer` (3): `AutomationsNew`, `InsightsEditor`, and `CreateSecretForm` need reducers only if they encode real coupled transitions, not simple field setters. ## Verification - `cd apps/fabro-web && bun test app/routes/run-detail.test.ts` -> `22 pass`, `0 fail` - `cd apps/fabro-web && bun test app/routes/insights-editor.test.tsx app/routes/runs.preferences.test.tsx` -> `7 pass`, `0 fail` - `cd apps/fabro-web && bun run typecheck` - `cd apps/fabro-web && bun test --isolate` -> `490 pass`, `0 fail` - `cd apps/fabro-web && bunx react-doctor@latest --full --json > /tmp/fabro-react-doctor-runs-insights.json` -> score `99`, `0` errors, `3` warnings - Earlier branch verification also included `cd apps/fabro-web && bun run build` - `git diff --check` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 (context not reported, default reasoning) via [Codex](https://openai.com/codex)
1177 lines
34 KiB
TypeScript
1177 lines
34 KiB
TypeScript
import { queryKeys } from "./query-keys";
|
|
import {
|
|
createBrowserEventSource,
|
|
type EventInvalidation,
|
|
type EventPayload,
|
|
type EventSourceLike,
|
|
type KeyOrMatcher,
|
|
type MutateFn,
|
|
sseKeyDedupeId,
|
|
} from "./sse";
|
|
import { getNumber, getString, isRecord, type UnknownRecord } from "./unknown";
|
|
|
|
export const CROSS_TAB_SSE_CHANNEL = "fabro:sse:v1";
|
|
const HEARTBEAT_MS = 1000;
|
|
const LEADER_STALE_MS = 4000;
|
|
const ELECTION_JITTER_MS = 150;
|
|
|
|
const MESSAGE_VERSION = 1 as const;
|
|
const EVENT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
const EVENT_DEDUPE_MAX = 1000;
|
|
|
|
type TabVisibility = "visible" | "hidden";
|
|
type CandidateReason = "hidden-leader" | "stale-leader" | "release" | "no-leader";
|
|
|
|
interface BaseMessage {
|
|
type: string;
|
|
version: typeof MESSAGE_VERSION;
|
|
tabId: string;
|
|
sentAt: number;
|
|
}
|
|
|
|
interface HelloMessage extends BaseMessage {
|
|
type: "hello";
|
|
}
|
|
|
|
interface HeartbeatMessage extends BaseMessage {
|
|
type: "heartbeat";
|
|
leaderId: string;
|
|
generation: number;
|
|
visibility: TabVisibility;
|
|
}
|
|
|
|
interface CandidateMessage extends BaseMessage {
|
|
type: "candidate";
|
|
candidateId: string;
|
|
candidateGeneration: number;
|
|
visibility: TabVisibility;
|
|
observedLeaderId: string | null;
|
|
observedGeneration: number;
|
|
reason: CandidateReason;
|
|
}
|
|
|
|
interface LeaderChangedMessage extends BaseMessage {
|
|
type: "leader-changed";
|
|
leaderId: string;
|
|
generation: number;
|
|
visibility: TabVisibility;
|
|
}
|
|
|
|
interface ReleaseMessage extends BaseMessage {
|
|
type: "release";
|
|
leaderId: string;
|
|
generation: number;
|
|
}
|
|
|
|
interface ResyncMessage extends BaseMessage {
|
|
type: "resync";
|
|
leaderId: string | null;
|
|
generation: number;
|
|
reason: CandidateReason;
|
|
}
|
|
|
|
interface EventMessage extends BaseMessage {
|
|
type: "event";
|
|
leaderId: string;
|
|
generation: number;
|
|
payload: EventPayload;
|
|
}
|
|
|
|
export type CrossTabSseMessage =
|
|
| HelloMessage
|
|
| HeartbeatMessage
|
|
| CandidateMessage
|
|
| LeaderChangedMessage
|
|
| ReleaseMessage
|
|
| ResyncMessage
|
|
| EventMessage;
|
|
|
|
export interface BroadcastChannelLike {
|
|
onmessage: ((event: { data: unknown }) => void) | null;
|
|
postMessage(message: CrossTabSseMessage): void;
|
|
close(): void;
|
|
}
|
|
|
|
interface TimingOptions {
|
|
heartbeatMs: number;
|
|
leaderStaleMs: number;
|
|
electionJitterMs: number;
|
|
}
|
|
|
|
export interface CrossTabSseCoordinatorOptions {
|
|
tabId?: string;
|
|
channelFactory?: (name: string) => BroadcastChannelLike;
|
|
eventSourceFactory?: (url: string) => EventSourceLike;
|
|
getVisibility?: () => TabVisibility;
|
|
addVisibilityChangeListener?: (handler: () => void) => () => void;
|
|
addPagehideListener?: (handler: () => void) => () => void;
|
|
now?: () => number;
|
|
timing?: Partial<TimingOptions>;
|
|
}
|
|
|
|
interface SubscribeOptions<TPayload extends EventPayload> {
|
|
subscriptionKey: string;
|
|
mutate: MutateFn;
|
|
resolveInvalidation: (payload: TPayload) => EventInvalidation;
|
|
resyncKeys: () => KeyOrMatcher[];
|
|
fallbackSubscribe: () => () => void;
|
|
debounceMs?: number;
|
|
}
|
|
|
|
export interface SubscribeToCrossTabSseOptions<TPayload extends EventPayload>
|
|
extends SubscribeOptions<TPayload> {
|
|
coordinator?: CrossTabSseCoordinator;
|
|
}
|
|
|
|
interface FallbackEntry {
|
|
count: number;
|
|
subscribe: () => () => void;
|
|
cleanup?: () => void;
|
|
}
|
|
|
|
interface LocalSubscription {
|
|
refcount: number;
|
|
mutators: Map<MutateFn, number>;
|
|
fallbacks: Map<MutateFn, FallbackEntry>;
|
|
pendingKeys: Map<string, KeyOrMatcher>;
|
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
debounceMs: number;
|
|
resolveInvalidation: (payload: EventPayload) => EventInvalidation;
|
|
resyncKeys: () => KeyOrMatcher[];
|
|
}
|
|
|
|
interface LeaderState {
|
|
leaderId: string;
|
|
generation: number;
|
|
visibility: TabVisibility;
|
|
lastSeen: number;
|
|
}
|
|
|
|
class RecentEventCache {
|
|
private readonly seen = new Map<string, number>();
|
|
|
|
constructor(
|
|
private readonly maxSize: number,
|
|
private readonly ttlMs: number,
|
|
) {}
|
|
|
|
remember(key: string | undefined, now: number): boolean {
|
|
if (!key) return true;
|
|
this.evictExpired(now);
|
|
if (this.seen.has(key)) return false;
|
|
|
|
this.seen.set(key, now);
|
|
while (this.seen.size > this.maxSize) {
|
|
const oldest = this.seen.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
this.seen.delete(oldest);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private evictExpired(now: number) {
|
|
for (const [key, seenAt] of this.seen) {
|
|
if (now - seenAt <= this.ttlMs) return;
|
|
this.seen.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class CrossTabSseCoordinator {
|
|
readonly tabId: string;
|
|
|
|
private readonly channelFactory: (name: string) => BroadcastChannelLike;
|
|
private readonly getVisibility: () => TabVisibility;
|
|
private readonly addVisibilityChangeListener: (handler: () => void) => () => void;
|
|
private readonly addPagehideListener: (handler: () => void) => () => void;
|
|
private readonly now: () => number;
|
|
private readonly timing: TimingOptions;
|
|
private readonly recentEvents = new RecentEventCache(EVENT_DEDUPE_MAX, EVENT_DEDUPE_TTL_MS);
|
|
private readonly subscriptions = new Map<string, LocalSubscription>();
|
|
private readonly candidates = new Map<string, CandidateMessage>();
|
|
|
|
private sourceFactory: (url: string) => EventSourceLike;
|
|
private channel: BroadcastChannelLike | null = null;
|
|
private source: EventSourceLike | null = null;
|
|
private initialized = false;
|
|
private coordinationUnavailable = false;
|
|
private fallbackMode = false;
|
|
private degradingToFallback = false;
|
|
private isLeader = false;
|
|
private leader: LeaderState | null = null;
|
|
private generation = 0;
|
|
private ownCandidate: CandidateMessage | null = null;
|
|
private candidateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private noLeaderTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
private leaderCheckTimer: ReturnType<typeof setInterval> | null = null;
|
|
private removeVisibilityListener: (() => void) | null = null;
|
|
private removePagehideListener: (() => void) | null = null;
|
|
|
|
constructor(options: CrossTabSseCoordinatorOptions = {}) {
|
|
this.tabId = options.tabId ?? createTabId();
|
|
this.channelFactory = options.channelFactory ?? createBrowserBroadcastChannel;
|
|
this.sourceFactory = options.eventSourceFactory ?? createBrowserEventSource;
|
|
this.getVisibility = options.getVisibility ?? getBrowserVisibility;
|
|
this.addVisibilityChangeListener =
|
|
options.addVisibilityChangeListener ?? addBrowserVisibilityChangeListener;
|
|
this.addPagehideListener = options.addPagehideListener ?? addBrowserPagehideListener;
|
|
this.now = options.now ?? Date.now;
|
|
this.timing = {
|
|
heartbeatMs: options.timing?.heartbeatMs ?? HEARTBEAT_MS,
|
|
leaderStaleMs: options.timing?.leaderStaleMs ?? LEADER_STALE_MS,
|
|
electionJitterMs: options.timing?.electionJitterMs ?? ELECTION_JITTER_MS,
|
|
};
|
|
}
|
|
|
|
subscribe<TPayload extends EventPayload>(options: SubscribeOptions<TPayload>): () => void {
|
|
const subscription = this.addLocalSubscription(options);
|
|
|
|
if (this.coordinationUnavailable) {
|
|
this.fallbackMode = true;
|
|
this.startFallbacksFor(subscription);
|
|
} else if (!this.initialized && !this.initialize()) {
|
|
this.coordinationUnavailable = true;
|
|
this.fallbackMode = true;
|
|
this.startFallbacksFor(subscription);
|
|
} else if (this.fallbackMode) {
|
|
this.startFallbacksFor(subscription);
|
|
} else {
|
|
this.ensureLeadershipProgress();
|
|
}
|
|
|
|
let active = true;
|
|
return () => {
|
|
if (!active) return;
|
|
active = false;
|
|
this.removeLocalSubscription(options.subscriptionKey, options.mutate);
|
|
};
|
|
}
|
|
|
|
close() {
|
|
this.releaseLeadership({ broadcast: false, resync: false });
|
|
this.clearCandidate();
|
|
this.clearNoLeaderTimer();
|
|
this.shutdownTimersAndChannel();
|
|
this.closeFallbacks();
|
|
this.clearSubscriptionTimers();
|
|
this.subscriptions.clear();
|
|
this.candidates.clear();
|
|
this.resetIdleState();
|
|
}
|
|
|
|
private initialize(): boolean {
|
|
try {
|
|
this.channel = this.channelFactory(CROSS_TAB_SSE_CHANNEL);
|
|
} catch {
|
|
this.channel = null;
|
|
return false;
|
|
}
|
|
|
|
this.channel.onmessage = (event) => this.handleMessage(event.data);
|
|
this.initialized = true;
|
|
this.removeVisibilityListener = this.addVisibilityChangeListener(() => {
|
|
this.handleVisibilityChange();
|
|
});
|
|
this.removePagehideListener = this.addPagehideListener(() => {
|
|
this.handlePagehide();
|
|
});
|
|
this.leaderCheckTimer = setInterval(() => {
|
|
this.checkLeaderFreshness();
|
|
}, this.timing.heartbeatMs);
|
|
|
|
return this.post({ type: "hello", version: MESSAGE_VERSION, tabId: this.tabId, sentAt: this.now() });
|
|
}
|
|
|
|
private addLocalSubscription<TPayload extends EventPayload>(
|
|
options: SubscribeOptions<TPayload>,
|
|
): LocalSubscription {
|
|
let subscription = this.subscriptions.get(options.subscriptionKey);
|
|
if (!subscription) {
|
|
subscription = {
|
|
refcount: 0,
|
|
mutators: new Map(),
|
|
fallbacks: new Map(),
|
|
pendingKeys: new Map(),
|
|
debounceTimer: null,
|
|
debounceMs: options.debounceMs ?? 300,
|
|
resolveInvalidation: options.resolveInvalidation as (payload: EventPayload) => EventInvalidation,
|
|
resyncKeys: options.resyncKeys,
|
|
};
|
|
this.subscriptions.set(options.subscriptionKey, subscription);
|
|
} else {
|
|
subscription.resolveInvalidation =
|
|
options.resolveInvalidation as (payload: EventPayload) => EventInvalidation;
|
|
subscription.resyncKeys = options.resyncKeys;
|
|
subscription.debounceMs = options.debounceMs ?? subscription.debounceMs;
|
|
}
|
|
|
|
subscription.refcount += 1;
|
|
subscription.mutators.set(
|
|
options.mutate,
|
|
(subscription.mutators.get(options.mutate) ?? 0) + 1,
|
|
);
|
|
|
|
const fallback = subscription.fallbacks.get(options.mutate);
|
|
if (fallback) {
|
|
fallback.count += 1;
|
|
fallback.subscribe = options.fallbackSubscribe;
|
|
} else {
|
|
subscription.fallbacks.set(options.mutate, {
|
|
count: 1,
|
|
subscribe: options.fallbackSubscribe,
|
|
});
|
|
}
|
|
|
|
return subscription;
|
|
}
|
|
|
|
private removeLocalSubscription(subscriptionKey: string, mutate: MutateFn) {
|
|
const subscription = this.subscriptions.get(subscriptionKey);
|
|
if (!subscription) return;
|
|
|
|
const mutateCount = subscription.mutators.get(mutate) ?? 0;
|
|
if (mutateCount <= 1) {
|
|
subscription.mutators.delete(mutate);
|
|
} else {
|
|
subscription.mutators.set(mutate, mutateCount - 1);
|
|
}
|
|
|
|
const fallback = subscription.fallbacks.get(mutate);
|
|
if (fallback) {
|
|
fallback.count -= 1;
|
|
if (fallback.count <= 0) {
|
|
fallback.cleanup?.();
|
|
subscription.fallbacks.delete(mutate);
|
|
}
|
|
}
|
|
|
|
subscription.refcount -= 1;
|
|
if (subscription.refcount <= 0) {
|
|
this.clearSubscriptionTimer(subscription);
|
|
this.subscriptions.delete(subscriptionKey);
|
|
}
|
|
|
|
if (this.subscriptions.size === 0) {
|
|
this.releaseLeadership({ broadcast: true, resync: false });
|
|
this.clearCandidate();
|
|
this.clearNoLeaderTimer();
|
|
this.shutdownTimersAndChannel();
|
|
this.resetIdleState();
|
|
}
|
|
}
|
|
|
|
private handleMessage(data: unknown) {
|
|
const message = parseMessage(data);
|
|
if (!message || message.tabId === this.tabId) return;
|
|
|
|
switch (message.type) {
|
|
case "hello":
|
|
if (this.isLeader) this.sendHeartbeat();
|
|
break;
|
|
case "heartbeat":
|
|
this.handleLeaderAnnouncement(message, { resyncOnChange: true });
|
|
break;
|
|
case "candidate":
|
|
this.handleCandidate(message);
|
|
break;
|
|
case "leader-changed":
|
|
this.handleLeaderAnnouncement(message, { resyncOnChange: true });
|
|
break;
|
|
case "release":
|
|
this.handleRelease(message);
|
|
break;
|
|
case "resync":
|
|
this.handleResync(message);
|
|
break;
|
|
case "event":
|
|
this.handleBroadcastEvent(message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private handleLeaderAnnouncement(
|
|
message: HeartbeatMessage | LeaderChangedMessage,
|
|
{ resyncOnChange }: { resyncOnChange: boolean },
|
|
) {
|
|
const incoming: LeaderState = {
|
|
leaderId: message.leaderId,
|
|
generation: message.generation,
|
|
visibility: message.visibility,
|
|
lastSeen: this.now(),
|
|
};
|
|
|
|
if (this.isLeader && incoming.leaderId !== this.tabId) {
|
|
const own: LeaderState = {
|
|
leaderId: this.tabId,
|
|
generation: this.generation,
|
|
visibility: this.currentVisibility(),
|
|
lastSeen: this.now(),
|
|
};
|
|
if (
|
|
incoming.generation > own.generation ||
|
|
(incoming.generation === own.generation && leaderHasHigherPriority(incoming, own))
|
|
) {
|
|
this.releaseLeadership({ broadcast: false, resync: true });
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const previous = this.leader;
|
|
if (this.ownCandidate && incoming.generation === this.ownCandidate.candidateGeneration) {
|
|
const sawIncomingCandidate = this.candidates.has(
|
|
`${incoming.generation}:${incoming.leaderId}`,
|
|
);
|
|
const shouldAcceptFreshVisibleLeader =
|
|
this.ownCandidate.reason === "no-leader" &&
|
|
incoming.visibility === "visible" &&
|
|
!sawIncomingCandidate;
|
|
|
|
if (
|
|
!shouldAcceptFreshVisibleLeader &&
|
|
!leaderHasHigherPriority(incoming, leaderStateForCandidate(this.ownCandidate, this.now()))
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!this.shouldAcceptLeader(incoming)) return;
|
|
|
|
this.leader = incoming;
|
|
this.clearNoLeaderTimer();
|
|
this.generation = Math.max(this.generation, incoming.generation);
|
|
this.pruneStaleCandidates();
|
|
if (this.ownCandidate && incoming.generation >= this.ownCandidate.candidateGeneration) {
|
|
this.clearCandidate();
|
|
}
|
|
|
|
const changed =
|
|
!previous ||
|
|
previous.leaderId !== incoming.leaderId ||
|
|
previous.generation !== incoming.generation;
|
|
|
|
if (changed && resyncOnChange) {
|
|
this.resyncAll();
|
|
}
|
|
|
|
if (incoming.visibility === "hidden" && this.currentVisibility() === "visible") {
|
|
this.enterCandidacy("hidden-leader", incoming);
|
|
}
|
|
}
|
|
|
|
private handleCandidate(message: CandidateMessage) {
|
|
this.candidates.set(candidateKey(message), message);
|
|
this.pruneStaleCandidates();
|
|
|
|
if (
|
|
this.isLeader &&
|
|
message.observedLeaderId === this.tabId &&
|
|
message.observedGeneration >= this.generation
|
|
) {
|
|
this.releaseLeadership({ broadcast: true, resync: true });
|
|
}
|
|
|
|
if (
|
|
this.ownCandidate &&
|
|
message.candidateGeneration === this.ownCandidate.candidateGeneration &&
|
|
candidateHasHigherPriority(message, this.ownCandidate)
|
|
) {
|
|
this.clearCandidate();
|
|
}
|
|
}
|
|
|
|
private handleRelease(message: ReleaseMessage) {
|
|
const current = this.leader;
|
|
if (
|
|
current &&
|
|
message.leaderId === current.leaderId &&
|
|
message.generation >= current.generation
|
|
) {
|
|
this.leader = null;
|
|
this.generation = Math.max(this.generation, message.generation);
|
|
this.pruneStaleCandidates();
|
|
this.enterCandidacy("release", {
|
|
leaderId: message.leaderId,
|
|
generation: message.generation,
|
|
visibility: "hidden",
|
|
lastSeen: this.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
private handleResync(message: ResyncMessage) {
|
|
if (this.leader && message.generation < this.leader.generation) return;
|
|
this.resyncAll();
|
|
}
|
|
|
|
private handleBroadcastEvent(message: EventMessage) {
|
|
if (!this.isCurrentLeader(message.leaderId, message.generation)) return;
|
|
if (!this.recentEvents.remember(eventDedupeKey(message.payload), this.now())) return;
|
|
this.dispatchPayload(message.payload);
|
|
}
|
|
|
|
private ensureLeadershipProgress() {
|
|
if (this.subscriptions.size === 0 || this.isLeader || this.ownCandidate) return;
|
|
|
|
if (!this.leader) {
|
|
this.scheduleNoLeaderCandidacy();
|
|
return;
|
|
}
|
|
|
|
if (this.leader.visibility === "hidden" && this.currentVisibility() === "visible") {
|
|
this.enterCandidacy("hidden-leader", this.leader);
|
|
}
|
|
}
|
|
|
|
private checkLeaderFreshness() {
|
|
if (this.subscriptions.size === 0 || this.fallbackMode) return;
|
|
if (this.isLeader) return;
|
|
|
|
const current = this.leader;
|
|
if (!current) {
|
|
this.scheduleNoLeaderCandidacy();
|
|
return;
|
|
}
|
|
|
|
if (!this.leaderIsFresh(current)) {
|
|
this.leader = null;
|
|
this.generation = Math.max(this.generation, current.generation);
|
|
this.pruneStaleCandidates();
|
|
this.resyncAll();
|
|
this.enterCandidacy("stale-leader", current);
|
|
return;
|
|
}
|
|
|
|
if (current.visibility === "hidden" && this.currentVisibility() === "visible") {
|
|
this.enterCandidacy("hidden-leader", current);
|
|
}
|
|
}
|
|
|
|
private enterCandidacy(reason: CandidateReason, observedLeader: LeaderState | null = this.leader) {
|
|
if (this.subscriptions.size === 0 || this.fallbackMode) return;
|
|
|
|
if (
|
|
reason === "no-leader" &&
|
|
this.leader &&
|
|
this.leader.visibility === "visible" &&
|
|
this.leaderIsFresh(this.leader)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const observedGeneration = observedLeader?.generation ?? this.generation;
|
|
const candidateGeneration = observedGeneration + 1;
|
|
if (
|
|
this.ownCandidate &&
|
|
this.ownCandidate.candidateGeneration >= candidateGeneration
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.clearCandidate();
|
|
this.clearNoLeaderTimer();
|
|
const candidate: CandidateMessage = {
|
|
type: "candidate",
|
|
version: MESSAGE_VERSION,
|
|
tabId: this.tabId,
|
|
sentAt: this.now(),
|
|
candidateId: this.tabId,
|
|
candidateGeneration,
|
|
visibility: this.currentVisibility(),
|
|
observedLeaderId: observedLeader?.leaderId ?? null,
|
|
observedGeneration,
|
|
reason,
|
|
};
|
|
|
|
this.ownCandidate = candidate;
|
|
this.candidates.set(candidateKey(candidate), candidate);
|
|
if (!this.post(candidate)) return;
|
|
this.candidateTimer = setTimeout(() => {
|
|
this.completeCandidacy(candidate);
|
|
}, this.timing.electionJitterMs);
|
|
}
|
|
|
|
private completeCandidacy(candidate: CandidateMessage) {
|
|
if (this.ownCandidate !== candidate || this.fallbackMode) return;
|
|
|
|
for (const other of this.candidates.values()) {
|
|
if (
|
|
other.candidateGeneration === candidate.candidateGeneration &&
|
|
candidateHasHigherPriority(other, candidate)
|
|
) {
|
|
this.clearCandidate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this.leader && this.leaderIsFresh(this.leader)) {
|
|
if (this.leader.generation > candidate.candidateGeneration) {
|
|
this.clearCandidate();
|
|
return;
|
|
}
|
|
if (
|
|
this.leader.generation === candidate.candidateGeneration &&
|
|
leaderHasHigherPriority(this.leader, leaderStateForCandidate(candidate, this.now()))
|
|
) {
|
|
this.clearCandidate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.becomeLeader(candidate.candidateGeneration);
|
|
}
|
|
|
|
private becomeLeader(generation: number) {
|
|
this.clearCandidate();
|
|
this.closeSource();
|
|
this.isLeader = true;
|
|
this.generation = generation;
|
|
this.pruneStaleCandidates();
|
|
this.leader = {
|
|
leaderId: this.tabId,
|
|
generation,
|
|
visibility: this.currentVisibility(),
|
|
lastSeen: this.now(),
|
|
};
|
|
|
|
const source = this.sourceFactory(queryKeys.system.attachUrl());
|
|
this.source = source;
|
|
source.onmessage = (message) => {
|
|
this.handleLeaderEventSourceMessage(message.data);
|
|
};
|
|
|
|
const announced = this.post({
|
|
type: "leader-changed",
|
|
version: MESSAGE_VERSION,
|
|
tabId: this.tabId,
|
|
sentAt: this.now(),
|
|
leaderId: this.tabId,
|
|
generation,
|
|
visibility: this.currentVisibility(),
|
|
});
|
|
if (!announced) return;
|
|
this.startHeartbeat();
|
|
this.resyncAll();
|
|
}
|
|
|
|
private handleLeaderEventSourceMessage(data: string) {
|
|
if (!this.isLeader) return;
|
|
|
|
let payload: EventPayload;
|
|
try {
|
|
payload = JSON.parse(data) as EventPayload;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (!this.recentEvents.remember(eventDedupeKey(payload), this.now())) return;
|
|
this.dispatchPayload(payload);
|
|
this.post({
|
|
type: "event",
|
|
version: MESSAGE_VERSION,
|
|
tabId: this.tabId,
|
|
sentAt: this.now(),
|
|
leaderId: this.tabId,
|
|
generation: this.generation,
|
|
payload,
|
|
});
|
|
}
|
|
|
|
private dispatchPayload(payload: EventPayload) {
|
|
for (const subscription of this.subscriptions.values()) {
|
|
const invalidation = subscription.resolveInvalidation(payload);
|
|
this.queueInvalidations(subscription, invalidation.keys, {
|
|
immediate: invalidation.immediate,
|
|
});
|
|
}
|
|
}
|
|
|
|
private queueInvalidations(
|
|
subscription: LocalSubscription,
|
|
keys: KeyOrMatcher[],
|
|
{ immediate = false }: { immediate?: boolean } = {},
|
|
) {
|
|
if (keys.length === 0) return;
|
|
for (const key of keys) {
|
|
subscription.pendingKeys.set(sseKeyDedupeId(key), key);
|
|
}
|
|
|
|
if (immediate || subscription.debounceMs <= 0) {
|
|
this.flushInvalidations(subscription);
|
|
return;
|
|
}
|
|
|
|
if (subscription.debounceTimer) {
|
|
clearTimeout(subscription.debounceTimer);
|
|
}
|
|
subscription.debounceTimer = setTimeout(() => {
|
|
subscription.debounceTimer = null;
|
|
this.flushInvalidations(subscription);
|
|
}, subscription.debounceMs);
|
|
}
|
|
|
|
private flushInvalidations(subscription: LocalSubscription) {
|
|
if (subscription.pendingKeys.size === 0) return;
|
|
const keys = [...subscription.pendingKeys.values()];
|
|
subscription.pendingKeys.clear();
|
|
|
|
for (const mutator of subscription.mutators.keys()) {
|
|
for (const key of keys) {
|
|
void mutator(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private resyncAll() {
|
|
for (const subscription of this.subscriptions.values()) {
|
|
this.queueInvalidations(subscription, subscription.resyncKeys(), { immediate: true });
|
|
}
|
|
}
|
|
|
|
private handleVisibilityChange() {
|
|
if (this.isLeader) {
|
|
this.sendHeartbeat();
|
|
}
|
|
|
|
if (this.currentVisibility() === "visible") {
|
|
this.resyncAll();
|
|
if (this.leader?.visibility === "hidden") {
|
|
this.enterCandidacy("hidden-leader", this.leader);
|
|
}
|
|
}
|
|
}
|
|
|
|
private handlePagehide() {
|
|
this.releaseLeadership({ broadcast: true, resync: false });
|
|
this.clearCandidate();
|
|
this.clearNoLeaderTimer();
|
|
}
|
|
|
|
private startHeartbeat() {
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
}
|
|
if (!this.sendHeartbeat()) return;
|
|
this.heartbeatTimer = setInterval(() => {
|
|
this.sendHeartbeat();
|
|
}, this.timing.heartbeatMs);
|
|
}
|
|
|
|
private sendHeartbeat(): boolean {
|
|
if (!this.isLeader) return false;
|
|
const visibility = this.currentVisibility();
|
|
this.leader = {
|
|
leaderId: this.tabId,
|
|
generation: this.generation,
|
|
visibility,
|
|
lastSeen: this.now(),
|
|
};
|
|
return this.post({
|
|
type: "heartbeat",
|
|
version: MESSAGE_VERSION,
|
|
tabId: this.tabId,
|
|
sentAt: this.now(),
|
|
leaderId: this.tabId,
|
|
generation: this.generation,
|
|
visibility,
|
|
});
|
|
}
|
|
|
|
private releaseLeadership({
|
|
broadcast,
|
|
resync,
|
|
}: {
|
|
broadcast: boolean;
|
|
resync: boolean;
|
|
}) {
|
|
if (!this.isLeader && !this.source) return;
|
|
|
|
const generation = this.generation;
|
|
this.closeSource();
|
|
this.isLeader = false;
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
this.leader = null;
|
|
|
|
if (broadcast) {
|
|
const released = this.post({
|
|
type: "release",
|
|
version: MESSAGE_VERSION,
|
|
tabId: this.tabId,
|
|
sentAt: this.now(),
|
|
leaderId: this.tabId,
|
|
generation,
|
|
});
|
|
if (!released) return;
|
|
this.post({
|
|
type: "resync",
|
|
version: MESSAGE_VERSION,
|
|
tabId: this.tabId,
|
|
sentAt: this.now(),
|
|
leaderId: this.tabId,
|
|
generation,
|
|
reason: "release",
|
|
});
|
|
}
|
|
if (resync) this.resyncAll();
|
|
}
|
|
|
|
private closeSource() {
|
|
if (!this.source) return;
|
|
this.source.close();
|
|
this.source = null;
|
|
}
|
|
|
|
private clearCandidate() {
|
|
if (this.candidateTimer) {
|
|
clearTimeout(this.candidateTimer);
|
|
this.candidateTimer = null;
|
|
}
|
|
this.ownCandidate = null;
|
|
}
|
|
|
|
private pruneStaleCandidates() {
|
|
for (const [key, candidate] of this.candidates) {
|
|
if (candidate.candidateGeneration < this.generation) {
|
|
this.candidates.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private scheduleNoLeaderCandidacy() {
|
|
if (
|
|
this.noLeaderTimer ||
|
|
this.ownCandidate ||
|
|
this.isLeader ||
|
|
this.leader ||
|
|
this.subscriptions.size === 0 ||
|
|
this.fallbackMode
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.noLeaderTimer = setTimeout(() => {
|
|
this.noLeaderTimer = null;
|
|
if (!this.leader && !this.isLeader) {
|
|
this.enterCandidacy("no-leader");
|
|
}
|
|
}, this.timing.electionJitterMs);
|
|
}
|
|
|
|
private clearNoLeaderTimer() {
|
|
if (!this.noLeaderTimer) return;
|
|
clearTimeout(this.noLeaderTimer);
|
|
this.noLeaderTimer = null;
|
|
}
|
|
|
|
private shutdownTimersAndChannel() {
|
|
this.clearNoLeaderTimer();
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
if (this.leaderCheckTimer) {
|
|
clearInterval(this.leaderCheckTimer);
|
|
this.leaderCheckTimer = null;
|
|
}
|
|
this.removeVisibilityListener?.();
|
|
this.removeVisibilityListener = null;
|
|
this.removePagehideListener?.();
|
|
this.removePagehideListener = null;
|
|
if (this.channel) {
|
|
this.channel.close();
|
|
this.channel = null;
|
|
}
|
|
}
|
|
|
|
private post(message: CrossTabSseMessage): boolean {
|
|
if (!this.channel) {
|
|
this.degradeToFallback();
|
|
return false;
|
|
}
|
|
try {
|
|
this.channel.postMessage(message);
|
|
return true;
|
|
} catch {
|
|
this.degradeToFallback();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private degradeToFallback() {
|
|
if (this.degradingToFallback || this.fallbackMode) return;
|
|
this.degradingToFallback = true;
|
|
this.releaseLeadership({ broadcast: false, resync: false });
|
|
this.clearCandidate();
|
|
this.shutdownTimersAndChannel();
|
|
this.initialized = false;
|
|
this.fallbackMode = true;
|
|
this.coordinationUnavailable = true;
|
|
for (const subscription of this.subscriptions.values()) {
|
|
this.startFallbacksFor(subscription);
|
|
}
|
|
this.degradingToFallback = false;
|
|
}
|
|
|
|
private startFallbacksFor(subscription: LocalSubscription) {
|
|
for (const fallback of subscription.fallbacks.values()) {
|
|
if (!fallback.cleanup) {
|
|
fallback.cleanup = fallback.subscribe();
|
|
}
|
|
}
|
|
}
|
|
|
|
private closeFallbacks() {
|
|
for (const subscription of this.subscriptions.values()) {
|
|
for (const fallback of subscription.fallbacks.values()) {
|
|
fallback.cleanup?.();
|
|
fallback.cleanup = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
private clearSubscriptionTimers() {
|
|
for (const subscription of this.subscriptions.values()) {
|
|
this.clearSubscriptionTimer(subscription);
|
|
}
|
|
}
|
|
|
|
private clearSubscriptionTimer(subscription: LocalSubscription) {
|
|
if (!subscription.debounceTimer) return;
|
|
clearTimeout(subscription.debounceTimer);
|
|
subscription.debounceTimer = null;
|
|
}
|
|
|
|
private resetIdleState() {
|
|
this.leader = null;
|
|
this.initialized = false;
|
|
this.coordinationUnavailable = false;
|
|
this.fallbackMode = false;
|
|
}
|
|
|
|
private shouldAcceptLeader(incoming: LeaderState): boolean {
|
|
if (!this.leader) return true;
|
|
if (incoming.generation > this.leader.generation) return true;
|
|
if (incoming.generation < this.leader.generation) return false;
|
|
if (incoming.leaderId === this.leader.leaderId) return true;
|
|
return leaderHasHigherPriority(incoming, this.leader);
|
|
}
|
|
|
|
private isCurrentLeader(leaderId: string, generation: number): boolean {
|
|
return Boolean(
|
|
this.leader &&
|
|
this.leader.leaderId === leaderId &&
|
|
this.leader.generation === generation,
|
|
);
|
|
}
|
|
|
|
private currentVisibility(): TabVisibility {
|
|
return this.getVisibility() === "hidden" ? "hidden" : "visible";
|
|
}
|
|
|
|
private leaderIsFresh(leader: LeaderState): boolean {
|
|
return this.now() - leader.lastSeen <= this.timing.leaderStaleMs;
|
|
}
|
|
}
|
|
|
|
const defaultCoordinator = new CrossTabSseCoordinator();
|
|
|
|
export function createCrossTabSseCoordinator(options: CrossTabSseCoordinatorOptions = {}) {
|
|
return new CrossTabSseCoordinator(options);
|
|
}
|
|
|
|
export function subscribeToCrossTabSse<TPayload extends EventPayload>({
|
|
coordinator = defaultCoordinator,
|
|
...options
|
|
}: SubscribeToCrossTabSseOptions<TPayload>): () => void {
|
|
return coordinator.subscribe(options);
|
|
}
|
|
|
|
function createBrowserBroadcastChannel(name: string): BroadcastChannelLike {
|
|
if (typeof BroadcastChannel === "undefined") {
|
|
throw new Error("BroadcastChannel is unavailable");
|
|
}
|
|
return new BroadcastChannel(name);
|
|
}
|
|
|
|
function createTabId(): string {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
function getBrowserVisibility(): TabVisibility {
|
|
if (typeof document === "undefined") return "visible";
|
|
return document.visibilityState === "visible" ? "visible" : "hidden";
|
|
}
|
|
|
|
function addBrowserVisibilityChangeListener(handler: () => void): () => void {
|
|
if (typeof document === "undefined") return () => {};
|
|
document.addEventListener("visibilitychange", handler);
|
|
return () => document.removeEventListener("visibilitychange", handler);
|
|
}
|
|
|
|
function addBrowserPagehideListener(handler: () => void): () => void {
|
|
if (typeof window === "undefined") return () => {};
|
|
window.addEventListener("pagehide", handler);
|
|
return () => window.removeEventListener("pagehide", handler);
|
|
}
|
|
|
|
function parseMessage(data: unknown): CrossTabSseMessage | undefined {
|
|
if (!isRecord(data)) return undefined;
|
|
if (data.version !== MESSAGE_VERSION) return undefined;
|
|
const type = getString(data, "type");
|
|
const tabId = getString(data, "tabId");
|
|
const sentAt = getNumber(data, "sentAt");
|
|
if (!type || !tabId || sentAt === undefined) return undefined;
|
|
const base = { type, version: MESSAGE_VERSION, tabId, sentAt };
|
|
|
|
switch (type) {
|
|
case "hello":
|
|
return { ...base, type: "hello" };
|
|
case "heartbeat": {
|
|
const triple = parseLeaderTriple(data);
|
|
return triple && { ...base, type: "heartbeat", ...triple };
|
|
}
|
|
case "candidate":
|
|
return parseCandidate(data, base);
|
|
case "leader-changed": {
|
|
const triple = parseLeaderTriple(data);
|
|
return triple && { ...base, type: "leader-changed", ...triple };
|
|
}
|
|
case "release": {
|
|
const pair = parseLeaderPair(data);
|
|
return pair && { ...base, type: "release", ...pair };
|
|
}
|
|
case "resync":
|
|
return parseResync(data, base);
|
|
case "event":
|
|
return parseEvent(data, base);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
interface ParsedBase {
|
|
version: typeof MESSAGE_VERSION;
|
|
tabId: string;
|
|
sentAt: number;
|
|
}
|
|
|
|
function parseLeaderPair(data: unknown): { leaderId: string; generation: number } | undefined {
|
|
const leaderId = getString(data, "leaderId");
|
|
const generation = getNumber(data, "generation");
|
|
if (!leaderId || generation === undefined) return undefined;
|
|
return { leaderId, generation };
|
|
}
|
|
|
|
function parseLeaderTriple(
|
|
data: unknown,
|
|
): { leaderId: string; generation: number; visibility: TabVisibility } | undefined {
|
|
const pair = parseLeaderPair(data);
|
|
const visibility = getString(data, "visibility");
|
|
if (!pair || !isVisibility(visibility)) return undefined;
|
|
return { ...pair, visibility };
|
|
}
|
|
|
|
function parseCandidate(data: UnknownRecord, base: ParsedBase): CandidateMessage | undefined {
|
|
const candidateId = getString(data, "candidateId");
|
|
const candidateGeneration = getNumber(data, "candidateGeneration");
|
|
const visibility = getString(data, "visibility");
|
|
const observedGeneration = getNumber(data, "observedGeneration");
|
|
const reason = getString(data, "reason");
|
|
const observedLeaderRaw = data.observedLeaderId;
|
|
const observedLeaderId =
|
|
typeof observedLeaderRaw === "string" ? observedLeaderRaw : observedLeaderRaw === null ? null : undefined;
|
|
if (
|
|
!candidateId ||
|
|
candidateGeneration === undefined ||
|
|
!isVisibility(visibility) ||
|
|
observedLeaderId === undefined ||
|
|
observedGeneration === undefined ||
|
|
!isCandidateReason(reason)
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...base,
|
|
type: "candidate",
|
|
candidateId,
|
|
candidateGeneration,
|
|
visibility,
|
|
observedLeaderId,
|
|
observedGeneration,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function parseResync(data: UnknownRecord, base: ParsedBase): ResyncMessage | undefined {
|
|
const generation = getNumber(data, "generation");
|
|
const reason = getString(data, "reason");
|
|
const leaderRaw = data.leaderId;
|
|
const leaderId = typeof leaderRaw === "string" ? leaderRaw : leaderRaw === null ? null : undefined;
|
|
if (generation === undefined || !isCandidateReason(reason) || leaderId === undefined) {
|
|
return undefined;
|
|
}
|
|
return { ...base, type: "resync", leaderId, generation, reason };
|
|
}
|
|
|
|
function parseEvent(data: UnknownRecord, base: ParsedBase): EventMessage | undefined {
|
|
const pair = parseLeaderPair(data);
|
|
if (!pair || !isRecord(data.payload)) return undefined;
|
|
return { ...base, type: "event", ...pair, payload: data.payload };
|
|
}
|
|
|
|
function isVisibility(value: unknown): value is TabVisibility {
|
|
return value === "visible" || value === "hidden";
|
|
}
|
|
|
|
function isCandidateReason(value: unknown): value is CandidateReason {
|
|
return (
|
|
value === "hidden-leader" ||
|
|
value === "stale-leader" ||
|
|
value === "release" ||
|
|
value === "no-leader"
|
|
);
|
|
}
|
|
|
|
function candidateHasHigherPriority(candidate: CandidateMessage, other: CandidateMessage): boolean {
|
|
if (candidate.visibility !== other.visibility) return candidate.visibility === "visible";
|
|
return candidate.candidateId < other.candidateId;
|
|
}
|
|
|
|
function leaderHasHigherPriority(candidate: LeaderState, other: LeaderState): boolean {
|
|
if (candidate.visibility !== other.visibility) return candidate.visibility === "visible";
|
|
return candidate.leaderId < other.leaderId;
|
|
}
|
|
|
|
function leaderStateForCandidate(candidate: CandidateMessage, lastSeen: number): LeaderState {
|
|
return {
|
|
leaderId: candidate.candidateId,
|
|
generation: candidate.candidateGeneration,
|
|
visibility: candidate.visibility,
|
|
lastSeen,
|
|
};
|
|
}
|
|
|
|
function candidateKey(candidate: CandidateMessage): string {
|
|
return `${candidate.candidateGeneration}:${candidate.candidateId}`;
|
|
}
|
|
|
|
export function eventDedupeKey(payload: EventPayload): string | undefined {
|
|
if (typeof payload.id === "string" && payload.id.length > 0) {
|
|
return payload.id;
|
|
}
|
|
|
|
const runId = typeof payload.run_id === "string" ? payload.run_id : undefined;
|
|
const seq = typeof payload.seq === "number" ? payload.seq : undefined;
|
|
const event = typeof payload.event === "string" ? payload.event : undefined;
|
|
if (runId && seq != null && event) {
|
|
return `${runId}:${seq}:${event}`;
|
|
}
|
|
return undefined;
|
|
}
|