import './utils/disposable'; import { type PushConfig } from './PushManager'; import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventMap, Middleware } from './types'; /** * SharedWebSocket — shares ONE WebSocket connection across browser tabs. * * @typeParam TEvents - Event map for type-safe subscriptions. * * @example * // Typed events * type Events = { * 'chat.message': { text: string; userId: string }; * 'order.created': { id: string; total: number }; * }; * const ws = new SharedWebSocket(url); * ws.on('chat.message', (msg) => msg.text); // ← msg: { text, userId } */ export declare class SharedWebSocket implements Disposable { private readonly url; private readonly options; private bus; private coordinator; private socket; private subs; private syncStore; private tabId; private cleanups; /** Removes all DOM (document/window) listeners at once on dispose. */ private readonly domListeners; private disposed; private readonly proto; private readonly log; /** Outgoing frame building + middleware + socket write. */ private readonly framePipeline; /** Incoming frame transform: middleware + extract + per-event deserialize. */ private readonly incoming; /** At-least-once buffer + replay for follower-originated dispatches. */ private readonly outbox; private serializers; /** * Channel/topic bookkeeping + cross-tab subscription replay. Holds the full * set used for incoming-event routing and leader-handover replay; auth-scoped * subscriptions are tracked separately by AuthManager for auto-leave on deauth. */ private readonly subscriptions; /** Runtime auth: login/logout, token refresh, re-auth on connect, revocation. */ private readonly auth; /** Routes events to render/native notifications by target tab. */ private readonly pushManager; /** Listeners for every raw incoming frame (post-deserialize, post-middleware). */ private rawFrameListeners; /** * Unsubscribe for the leader-only `ws:request` responder. Tracked separately * from `cleanups` so it can be torn down on EACH leadership loss — otherwise * a demoted tab keeps answering requests with a null socket, and every * re-promotion stacks another responder (duplicate server sends). */ private requestResponderCleanup; constructor(url: string, options?: SharedWebSocketOptions); get connected(): boolean; get tabRole(): TabRole; /** Whether the user is authenticated via runtime auth. */ get isAuthenticated(): boolean; /** Whether this tab is currently visible/focused. */ get isActive(): boolean; /** Start leader election and connect. */ connect(): Promise; /** Called when WebSocket connection opens (broadcast to all tabs). */ onConnect(fn: () => void): Unsubscribe; /** Called when WebSocket connection closes (broadcast to all tabs). */ onDisconnect(fn: () => void): Unsubscribe; /** Called when WebSocket starts reconnecting (broadcast to all tabs). */ onReconnecting(fn: () => void): Unsubscribe; /** * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`. * Use this to show a "Reconnect" UI affordance (snackbar, banner, modal) * so the user can call `ws.reconnect()` to try again. * * @example * ws.onReconnectFailed(() => { * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } }); * }); */ onReconnectFailed(fn: () => void): Unsubscribe; /** * Manually trigger a reconnect. Resets the retry counter and attempts a * fresh connection. Safe to call from any tab — the leader actually owns * the socket, followers route the request via BroadcastChannel. * * Use after `onReconnectFailed` fires to let the user retry. * * @example * snackbar.action('Reconnect', () => ws.reconnect()); */ reconnect(): void; /** Called when this tab becomes leader or loses leadership. */ onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe; /** Called on WebSocket or network error (broadcast to all tabs). */ onError(fn: (error: unknown) => void): Unsubscribe; /** Called when this tab becomes visible/focused. */ onActive(fn: () => void): Unsubscribe; /** Called when this tab goes to background/hidden. */ onInactive(fn: () => void): Unsubscribe; /** Called on any visibility change. */ onVisibilityChange(fn: (isActive: boolean) => void): Unsubscribe; /** * Authenticate on an existing connection. Sends auth event to server, * syncs auth state across all tabs. Use for login after guest connection. * * @example * const token = await loginApi(email, password); * ws.authenticate(token); * * @example * // React — via useSocketAuth hook * const { authenticate } = useSocketAuth(); * authenticate(token); */ authenticate(token: string): void; /** * Deauthenticate — notifies server, auto-leaves all auth-required channels * and topics, syncs state across tabs. Connection stays open for public events. * * @example * ws.deauthenticate(); // connection stays open, auth subscriptions cleaned up */ deauthenticate(): void; /** * Called when auth state changes (authenticate, deauthenticate, or server revocation). * * @example * ws.onAuthChange((authenticated) => { * if (!authenticated) router.push('/login'); * }); */ onAuthChange(fn: (authenticated: boolean) => void): Unsubscribe; /** * Add middleware to transform messages before send or after receive. * Return null from middleware to drop the message. * * @example * // Add timestamp to every outgoing message * ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() })); * * @example * // Decrypt incoming messages * ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) })); * * @example * // Drop messages from blocked users * ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg); */ use(direction: 'outgoing' | 'incoming', fn: Middleware): this; /** * Register a custom serializer for a specific event. * The data is transformed before outgoing middleware and global serialize. * * @example * // Binary for file uploads, JSON for everything else * ws.serializer('file.upload', (data) => new Blob([data as ArrayBuffer])); * * @example * // Protobuf for specific event * ws.serializer('trading.order', (data) => OrderProto.encode(data).finish()); */ serializer(event: string, fn: (data: unknown) => unknown): this; /** * Register a custom deserializer for a specific event. * The data is transformed after global deserialize and before incoming middleware. * * @example * ws.deserializer('file.download', (data) => new Uint8Array(data as ArrayBuffer)); * * @example * // Protobuf for specific event * ws.deserializer('trading.tick', (data) => TickProto.decode(data as Uint8Array)); */ deserializer(event: string, fn: (data: unknown) => unknown): this; /** * Subscribe to server events (works in ALL tabs). Type-safe with EventMap. * * The handler receives `(data, raw)`: * - `data` is extracted via `dataField` (default `'data'`) * - `raw` is the full deserialized envelope, useful for protocols with extra * top-level fields like `id`, `kind`, `channel`, `type`, etc. * * @example * ws.on('msg', (data, raw) => { * raw.id; // top-level metadata * raw.kind; // discriminator * }); */ on(event: K, handler: EventHandler): Unsubscribe; on(event: string, handler: EventHandler): Unsubscribe; once(event: K, handler: EventHandler): Unsubscribe; once(event: string, handler: EventHandler): Unsubscribe; off(event: string, handler?: EventHandler): void; /** Async generator for consuming events. Type-safe with EventMap. */ stream(event: K, signal?: AbortSignal): AsyncGenerator; stream(event: string, signal?: AbortSignal): AsyncGenerator; /** * Send message to server (auto-routed through leader). Type-safe with EventMap. * * The optional third argument `extras` adds top-level fields to the wire envelope. * Use it for protocols that need extra envelope keys like `type`, `channel`, etc. * * @example * // Default shape: { event, data } * ws.send('chat.message', { text: 'Hello' }); * // → { event: 'chat.message', data: { text: 'Hello' } } * * @example * // Pusher/Reverb-style envelope * ws.send('group.member_ready', * { member_id: 'abc', ready: true }, * { type: 'event', channel: 'public.group.xxx' }, * ); * // → { * // type: 'event', * // channel: 'public.group.xxx', * // event: 'group.member_ready', * // data: { member_id: 'abc', ready: true }, * // } */ send(event: K, data: TEvents[K], extras?: Record): void; send(event: string, data: unknown, extras?: Record): void; private assertExtrasReserved; /** Request/response through server via leader. */ request(event: string, data: unknown, timeout?: 5000): Promise; /** * Leader-side request/response over the live socket. Sends the event, then * resolves with the first matching response frame (matched by event name or * a `requestId` field), or rejects on timeout. Used by both the local * `request()` fast-path and the `ws:request` responder for followers. */ private performRequest; /** Sync state across tabs (no server roundtrip). */ sync(key: string, value: T): void; getSync(key: string): T | undefined; onSync(key: string, fn: (value: T) => void): Unsubscribe; /** * Subscribe to a private/scoped channel. Returns a channel handle with * scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe. * * @example * const chat = ws.channel('chat:room_123'); * chat.on('message', (msg) => render(msg)); * chat.send('message', { text: 'Hello' }); * chat.leave(); // sends leave + unsubscribes * * @example * // Private notifications for tenant * const notifications = ws.channel(`tenant:${tenantId}:notifications`); * notifications.on('alert', (alert) => showToast(alert)); */ channel(name: string, options?: { auth?: boolean; }): Channel; /** * Subscribe to a server-side topic. Server will start sending events for this topic. * Sends topicSubscribe event (default: "$topic:subscribe"). * * @example * ws.subscribe('notifications:orders'); * ws.subscribe('notifications:payments'); * ws.subscribe(`user:${userId}:mentions`); */ subscribe(topic: string, options?: { auth?: boolean; }): void; /** * Unsubscribe from a server-side topic. * Sends topicUnsubscribe event (default: "$topic:unsubscribe"). */ unsubscribe(topic: string): void; /** * Subscribe to an event and show notifications. * * **target** controls which tab(s) display the notification: * - `'active'` — only the currently visible tab (default for render) * - `'leader'` — only the leader tab (default for browser Notification) * - `'all'` — every tab (for critical alerts) * * @example * // Custom render — sonner toast on active tab only * ws.push('notification', { * render: (n) => toast(n.title), * target: 'active', // default for render * }); * * @example * // Critical alert — show in ALL tabs * ws.push('payment.failed', { * render: (n) => toast.error('Payment failed!'), * target: 'all', * }); * * @example * // Browser Notification — only from leader * ws.push('order.created', { * title: (order) => `New Order #${order.id}`, * target: 'leader', // default for browser Notification * }); * * @example * // Both render + native with different targets * ws.push('order.created', { * render: (order) => toast(`Order #${order.id}`), // active tab * title: (order) => `New Order #${order.id}`, // leader → native * }); */ push(event: string, config: PushConfig): Unsubscribe; disconnect(): void; /** * Subscribe to every raw incoming frame (post-deserialize). Used by * `Channel.ready`'s ack matcher. Internal — not part of the public API. */ private onRawFrame; /** * Route a structured frame: the leader transmits directly via the frame * pipeline; followers hand it to the outbox, which buffers (event kinds) and * forwards over the bus for the leader to write. */ private dispatch; private createSocket; private handleBecomeLeader; /** * Re-establish all server-side state on the freshly connected leader socket: * 1. auth-login (so server accepts subsequent joins on auth channels) * 2. channel-join for the union of channels held by ALL surviving tabs * 3. topic-subscribe for the union of topics held by ALL surviving tabs * * The union covers leader handover: when a follower with handlers is * promoted, no tab's subscriptions get silently dropped. Frames are sent * in FIFO order over the single WebSocket, so auth precedes the joins * that depend on it. */ /** * Orchestrate post-connect recovery: replay subscriptions first (so the * server is ready to route events for any channels we still care about), * then drain follower-pending dispatches that didn't reach the previous * leader's socket. */ private onConnected; private handleLoseLeadership; [Symbol.dispose](): void; }