import { Tab } from './tab.js'; /** * Hub & Spoke Multi-Tab Coordination Utility * * This utility provides a simple way to coordinate multiple browser tabs using a hub-and-spoke * architecture where one tab (or shared worker) acts as the central hub that manages shared * resources like databases and server connections, while other tabs (spokes) communicate with * the hub to access these resources. * * Key features: * - Automatic leadership election using tab-election * - Type-safe RPC between spokes and hub services * - Optional version mismatch detection and handling * - Support for SharedWorker, WebWorker, or in-tab coordination * - Flexible service registration and service stub generation * * @example * ```typescript * // Define event types for your service * interface DatabaseEvents { * 'user-saved': { user: User }; * 'user-deleted': { id: string }; * } * * // Define a service class with phantom property for type inference * class DatabaseService { * readonly namespace = 'db' as const; * readonly __events?: DatabaseEvents; // Phantom property - don't set at runtime * private db: IDBDatabase; * private hub: Hub; * * async init(hub: Hub): Promise { * this.hub = hub; * this.db = await openDB(`app-${hub.name}`); * } * * async getUser(id: string): Promise { * // Database operations... * } * * async saveUser(user: User): Promise { * // Database operations... * this.hub.emit(this.namespace, 'user-saved', { user }); // Type-safe event emission * } * } * * // Hub setup (in shared worker or elected tab) * const hub = new Hub((hub) => { * hub.register(new DatabaseService()); * hub.register(new AuthenticationService()); * }); * hub.onVersionMismatch((oldVersion, newVersion) => { * console.log(`Version updated: ${oldVersion} -> ${newVersion}`); * return 'refresh'; // or 'ignore' * }); * * // Spoke setup (in each tab) * const spoke = new Spoke({ * workerUrl: 'hub.js', * name: 'user-123', * version: '1.0.0' * }); * const db = spoke.getService('db'); * const user = await db.getUser('123'); // Fully typed! * * // Listen for events from the service - fully typed! * const unsubscribe = db.on('user-saved', ({ user }) => { * console.log('User was saved:', user); * }); * * // TypeScript will error on invalid event names or payloads: * // db.on('invalid-event', () => {}); // Error: invalid event name * // db.on('user-saved', ({ wrongProp }) => {}); // Error: wrong payload shape * ``` */ /** * Event listener function type. */ export type EventListener = (payload: T) => void; /** * Unsubscribe function type. */ export type UnsubscribeFunction = () => void; /** * Base service interface that hub services should implement. * * `Events` is a mapping from event names (string keys) to the payload type that will be * delivered to listeners. By default it is an empty map meaning the service does not * emit any strongly-typed events. * * @example * ```typescript * interface UserEvents { * "user-saved": { user: User }; * "user-deleted": { id: string }; * } * * class DatabaseService { * readonly namespace = "db" as const; * readonly __events?: UserEvents; // Phantom property for type inference * * async saveUser(user: User): Promise { * // ... save logic * // Emit via hub.emit(this.namespace, 'user-saved', { user }) * } * } * ``` */ export interface Service = {}> { readonly namespace: string; readonly __events?: Events; /** * Initialize the service. * This is called once when the service is first instantiated in the hub. */ init?(hub: Hub): Promise | void; /** * Close the service. * This is called when the service is no longer needed. */ close?(): void; } type ServiceEvents = T extends Service ? E : never; /** * Service stub type - a proxy for calling methods on a remote Service with type-safe events. */ export type ServiceStub> = AllMethodsAsync> & { on>(eventName: K, listener: EventListener[K]>): UnsubscribeFunction; }; /** * @deprecated Use `ServiceStub` instead. This alias will be removed in a future major version. */ export type Client> = ServiceStub; /** * Configuration options for creating a Hub. */ export interface HubOptions { /** Unique name/namespace for this hub instance (e.g., 'user-123', 'session-abc') */ name?: string; /** Optional version string for version mismatch detection */ version?: string; } /** * Configuration options for creating a Spoke. */ export interface SpokeOptions { /** URL of the worker script that runs the hub, or a Hub instance for an in-tab hub (will still only be one active hub per name/version) */ workerUrl: string | Hub; /** Unique name/namespace to connect to (must match hub name) */ name: string; /** Optional version string for version mismatch detection */ version?: string; /** Whether to use SharedWorker when available */ useSharedWorker?: boolean; /** How long a service call waits for a return before rejecting with `Error('Call timed out')`. Default 30s. */ callTimeout?: number; /** Minimum delay between fruitless worker recoveries (ms). Doubles per fruitless recovery. Default 10s. */ recoveryBackoffMinMs?: number; /** Ceiling for the recovery backoff (ms). Default 5 minutes. */ recoveryBackoffMaxMs?: number; /** Fruitless recoveries (no heartbeat between them) before `onRecoveryFailed` fires. Default 5. */ maxFruitlessRecoveries?: number; } /** * Why a spoke recovered its hub worker: heartbeats stopped ('heartbeat'), * service calls kept timing out while heartbeats continued ('call-stall'), or * the worker fired an 'error' event before ever producing a heartbeat * ('boot-failure' — e.g. its module script failed to fetch or parse). */ export type RecoveryReason = 'heartbeat' | 'call-stall' | 'boot-failure'; export interface RecoveryEvent { reason: RecoveryReason; attempt: number; } /** * Fired once per wedge episode when repeated worker recoveries never produce * a heartbeat — the worker is not coming back on its own (the classic cause: a * deploy purged the old hashed worker script, so every respawn re-fetches a * URL that no longer serves JavaScript). The app should escalate: surface the * failure and, when it cannot lose user data, reload onto fresh assets. * Recovery attempts continue at the backoff ceiling after this fires. */ export interface RecoveryFailedEvent { /** Consecutive recoveries without a single heartbeat in between. */ attempts: number; /** Message from the last worker 'error' event, when one fired (e.g. a failed script fetch). */ lastError?: string; } /** * Function signature for version mismatch handlers. */ export type VersionMismatchHandler = (oldVersion: string, newVersion: string) => void; /** * Utility type to convert all methods to async methods for RPC. */ type AllMethodsAsync = { [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K] extends (...args: any[]) => Promise ? T[K] : T[K] extends (...args: infer P) => infer R ? (...args: P) => Promise : never; }; declare class Leader { hub: Hub; readonly services: Map; constructor(hub: Hub, services: Map); init(hub: Hub): Promise; close(): void; } export interface Hub { addEventListener(type: 'message', listener: (ev: MessageEvent) => any, options?: boolean | AddEventListenerOptions): void; addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; removeEventListener(type: 'message', listener: (ev: MessageEvent) => any, options?: boolean | EventListenerOptions): void; removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; } /** * Hub class - runs in shared worker or elected tab to manage services and coordination. * * The Hub is responsible for: * - Leadership election among tabs/workers * - Service initialization and lifecycle management * - RPC method dispatch from spokes to services * - Version mismatch detection and handling * - Broadcasting updates to connected spokes */ export declare class Hub extends EventTarget { readonly initialize: (hub: Hub) => Promise | void; protected services: Map>; protected tab: Tab; protected leader: Leader | null; protected versionChannel?: BroadcastChannel; protected versionMismatchHandlers: Set; protected _name: string; protected _version: string; protected _isRecovery: boolean; protected _heartbeatInterval?: ReturnType; /** * Create a new Hub instance. * * @example * ```typescript * const hub = new Hub((hub) => { * // Initialize the hub when it becomes the leader * hub.register(new DatabaseService()); * hub.register(new AuthenticationService()); * }); * ``` */ constructor(initialize: (hub: Hub) => Promise | void, name?: string, version?: string); /** * Get the name of the hub. */ get name(): string; /** * Get the version of the hub. */ get version(): string; /** * Whether this hub instance is the elected leader. */ get isLeader(): boolean; /** * Change the options of the hub. * This will change the name and version of the hub and restart the leadership election. * * @param options - The new options for the hub */ setOptions(options: Required): void; /** * Register a service with the hub. * Services will be instantiated only when this hub becomes the leader. * * @param service - Instance of the service * @example * ```typescript * hub.register(databaseService); * hub.register(authenticationService); * ``` */ register(service: T): void; /** * Set up version mismatch detection and handling. * When called, enables cross-version communication to detect when tabs with different * versions are present and allows custom handling of the situation. * * @param handler - Function to call when version mismatch is detected * @example * ```typescript * hub.onVersionMismatch((oldVersion, newVersion) => { * if (hasUnsavedData()) return 'ignore'; * return 'refresh'; * }); * ``` */ onVersionMismatch(handler: VersionMismatchHandler): UnsubscribeFunction; /** * Send a message to all connected spokes. * * @param message - Message to send * @example * ```typescript * hub.send({ type: 'user-updated', userId: '123' }); * ``` */ send(message: any): void; /** * Emit an event to all connected spokes for a service. * Events are scoped to the service namespace, so only clients of this specific service will receive them. * * @param namespace - Namespace of the service to emit the event for * @param eventName - Name of the event to emit * @param payload - Data to send with the event * @example * ```typescript * hub.emit('db', 'user-updated', { userId: '123', changes: {...} }); * ``` */ emit(namespace: string, eventName: string, payload: unknown): void; /** * Get the state of the hub. */ get state(): Record; /** * Updates the state of the hub. * * @param state - State to update * @example * ```typescript * hub.setState({ connected: true }); * ``` */ updateState(state: Record): void; /** * Close the hub and clean up resources. */ close(): void; protected initializeLeadership(): Promise; protected setupVersionDetection(): void; } /** * Spoke class - runs in browser tabs to communicate with the hub. * * The Spoke is responsible for: * - Connecting to the hub (via worker or tab communication) * - Providing type-safe service client proxies * - Handling worker lifecycle (creation, communication) * - Forwarding RPC calls to hub services */ export declare class Spoke { protected tab: Tab; protected worker?: Worker | SharedWorker | Hub; protected stubs: Map>>; protected onStateListeners: Set>>; protected onLeaderChangeListeners: Set>; protected _isLeader: boolean; protected _workerUrl?: string; protected _recoveryAttempt: number; protected _heartbeatTimeout?: ReturnType; protected _lastHeartbeat: number; /** * Consecutive `Call timed out` rejections per `namespace.method`, NOT per * spoke. A hub can wedge on ONE code path while every other path stays * responsive — the observed shape is a hub whose write loop has starved * while its RPC dispatcher still answers cheap reads. A single spoke-wide * counter cannot see that: any successful call anywhere zeroes it, and a * real app is always chattering on something (presence leases, tab pings, * liveness probes), so the wedged path's timeouts never accumulate and the * worker is never respawned. Keying by method lets a starved path reach the * threshold on its own evidence while healthy traffic flows past it. */ protected _consecutiveCallTimeouts: Map; protected onRecoveryListeners: Set>; protected onRecoveryFailedListeners: Set>; /** When the last worker recovery ran — the base the backoff gate measures from. */ protected _lastRecoverAt: number; /** Recoveries since the last heartbeat; >0 means the episode is so far fruitless. */ protected _recoveriesSinceHeartbeat: number; /** Whether any heartbeat has arrived since the current worker spawned. */ protected _heartbeatSinceSpawn: boolean; /** Message from the newest worker 'error' event this episode, for RecoveryFailedEvent. */ protected _lastWorkerError?: string; protected _recoveryFailedFired: boolean; protected _recoveryBackoffMinMs: number; protected _recoveryBackoffMaxMs: number; protected _maxFruitlessRecoveries: number; readonly name: string; readonly version?: string; /** * Create a new Spoke instance. * * @param options - Configuration options for the spoke * @example * ```typescript * const spoke = new Spoke({ * workerUrl: 'hub.js', * name: 'user-123', * version: '1.0.0' * }); * ``` */ constructor(options: SpokeOptions); /** * Attach the per-worker listeners: leadership changes (regular Worker via * postMessage, in-tab Hub via EventTarget — SharedWorker is excluded since * the spoke doesn't own it) and worker 'error' (real workers only). Called * for the initial worker and again for every replacement `_recover` spawns; * each listener ignores events once its worker has been replaced. */ protected _attachWorkerListeners(worker: Worker | SharedWorker | Hub): void; /** * Whether this spoke's worker is the elected leader. * Always false when using a SharedWorker (the spoke doesn't own it). */ get isLeader(): boolean; /** * Get the state of the hub. */ get state(): Record; /** * Listen for leadership changes. * The listener is called with `true` when this spoke's worker becomes the leader, * and `false` when it loses leadership. * * @param listener - Function to call when leadership changes * @returns A function to unsubscribe the listener */ onLeaderChange(listener: EventListener): UnsubscribeFunction; /** * Listen for state changes on the hub. * * @param listener - Function to call when state changes * @example * ```typescript * spoke.onState(state => { * console.log('State changed:', state); * }); * ``` */ onState(listener: EventListener>): UnsubscribeFunction; /** * Listen for hub worker recoveries initiated by this spoke — either because * heartbeats stopped ('heartbeat') or because service calls kept timing out * while heartbeats continued ('call-stall', a wedged hub). Useful for * telemetry: recoveries should be rare, and a recurring one points at a * reproducible hub wedge. * * @param listener - Function to call when this spoke recovers its worker * @returns A function to unsubscribe the listener */ onRecovery(listener: EventListener): UnsubscribeFunction; /** * Listen for the terminal recovery signal: repeated worker recoveries have * produced no heartbeat, so the worker is not coming back on its own (see * {@link RecoveryFailedEvent}). Fires at most once per wedge episode — a * later heartbeat closes the episode and re-arms it. The spoke keeps * retrying at the backoff ceiling after it fires; the app should escalate * (surface the failure, and reload onto fresh assets when that cannot lose * user data). * * @param listener - Function to call when recovery is declared failed * @returns A function to unsubscribe the listener */ onRecoveryFailed(listener: EventListener): UnsubscribeFunction; /** * Get a type-safe stub for calling methods on a hub service. * * @param namespace - The namespace of the service to get (must match service's namespace) * @returns A proxy object with async versions of all service methods * @example * ```typescript * const db = spoke.getService('db'); * const user = await db.getUser('123'); // Fully typed! * await db.saveUser(updatedUser); * ``` */ getService(namespace: T['namespace']): ServiceStub; /** * @deprecated Use `getService()` instead. This method will be removed in a future major version. */ client(namespace: T['namespace']): ServiceStub; /** * Close the spoke and clean up resources. */ close(): void; protected _startHeartbeatMonitoring(): void; /** Randomized 5-10s per spoke instance. Overridable so tests can run fast. */ protected _heartbeatCheckIntervalMs(): number; /** * How much later than scheduled a heartbeat check may fire before the gap it * measures is considered untrustworthy (see _shouldRecoverOnHeartbeatGap). * Loose enough for ordinary main-thread jank; a suspension overshoots it by * orders of magnitude. */ protected _heartbeatLateGraceMs(): number; /** * Whether a heartbeat-gap recovery should fire for the check window that * opened at `scheduledAt`. Only ever true after at least one heartbeat has * been seen (boot failures have their own detection). * * A check timer that fired much later than scheduled means this whole * context was suspended — system sleep, a backgrounded mobile tab, Safari * tab suspension. The hub's clocks were frozen right along with ours, so a * stale `_lastHeartbeat` proves nothing about its health; recovering on it * would respawn a perfectly live hub on every wake. Skip that round and let * a clean window decide: a live hub heartbeats within ~2s of resuming, and a * genuinely dead one is still recovered one interval later. */ protected _shouldRecoverOnHeartbeatGap(scheduledAt: number, timeout: number): boolean; /** * Route a detected hub failure into the right recovery path for the worker * mode: SharedWorker recoveries broadcast so every spoke switches together; * dedicated-Worker recoveries must happen in the spoke that OWNS the hung * leader, so a non-owner broadcasts and the owner acts (see the recover * listener above). */ protected _initiateRecovery(reason: RecoveryReason): void; protected _noteCallTimeout(method: string): void; /** * Delay required before the next respawn: nothing for the first recovery of * an episode, then doubling per fruitless recovery (one that produced no * heartbeat) up to the ceiling. */ protected _recoveryBackoffMs(): number; protected _recover(attempt: number, reason?: RecoveryReason): void; } export {};