@gwakko/shared-websocket 0.14.3 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -343,6 +343,15 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
343
343
  private enqueuePending;
344
344
  /** Build, run middleware, and write to the socket. Leader-only. */
345
345
  private transmit;
346
+ /**
347
+ * Human-readable headline for log lines — picks the most relevant field
348
+ * out of the structured payload so log scanners aren't reading objects:
349
+ * - event → event name
350
+ * - subscribe → channel
351
+ * - topic-* → topic
352
+ * - auth-* → '(redacted)' / ''
353
+ */
354
+ private frameLabel;
346
355
  private createSocket;
347
356
  private handleBecomeLeader;
348
357
  /**
@@ -21,7 +21,9 @@ var MessageBus = (_class = class {
21
21
  __init2() {this.pendingRequests = /* @__PURE__ */ new Map()}
22
22
  subscribe(topic, fn) {
23
23
  const wrapper = (msg) => {
24
- if (msg.source !== this.tabId) fn(msg.data);
24
+ if (msg.type !== "publish" || msg.source !== this.tabId) {
25
+ fn(msg.data);
26
+ }
25
27
  };
26
28
  this.addListener(topic, wrapper);
27
29
  return () => this.removeListener(topic, wrapper);
@@ -1425,7 +1427,9 @@ var SharedWebSocket = (_class6 = class {
1425
1427
  return;
1426
1428
  }
1427
1429
  const id = generateId();
1428
- this.enqueuePending(id, kind, payload);
1430
+ if (kind === "event") {
1431
+ this.enqueuePending(id, kind, payload);
1432
+ }
1429
1433
  this.bus.publish("ws:dispatch", { id, kind, payload });
1430
1434
  }
1431
1435
  enqueuePending(id, kind, payload) {
@@ -1442,19 +1446,47 @@ var SharedWebSocket = (_class6 = class {
1442
1446
  if (!this.socket) return;
1443
1447
  let frame = this.buildFrame(kind, payload);
1444
1448
  if (frame === null) {
1445
- this.log.debug("[SharedWS] \u2717 frameBuilder dropped frame", kind);
1449
+ this.log.debug("[SharedWS] \u2717 frameBuilder dropped frame", kind, this.frameLabel(kind, payload));
1446
1450
  return;
1447
1451
  }
1448
1452
  for (const mw of this.outgoingMiddleware) {
1449
1453
  frame = mw(frame);
1450
1454
  if (frame === null) {
1451
- this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", kind);
1455
+ this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", kind, this.frameLabel(kind, payload));
1452
1456
  return;
1453
1457
  }
1454
1458
  }
1455
- this.log.debug("[SharedWS] \u2192 send", kind, payload);
1459
+ if (kind === "auth-login") {
1460
+ this.log.debug("[SharedWS] \u2192 send", kind, "(token redacted)");
1461
+ } else {
1462
+ this.log.debug("[SharedWS] \u2192 send", kind, this.frameLabel(kind, payload), { payload, frame });
1463
+ }
1456
1464
  this.socket.send(frame);
1457
1465
  }
1466
+ /**
1467
+ * Human-readable headline for log lines — picks the most relevant field
1468
+ * out of the structured payload so log scanners aren't reading objects:
1469
+ * - event → event name
1470
+ * - subscribe → channel
1471
+ * - topic-* → topic
1472
+ * - auth-* → '(redacted)' / ''
1473
+ */
1474
+ frameLabel(kind, p) {
1475
+ switch (kind) {
1476
+ case "event":
1477
+ return _nullishCoalesce(p.event, () => ( "?"));
1478
+ case "subscribe":
1479
+ case "unsubscribe":
1480
+ return _nullishCoalesce(p.channel, () => ( "?"));
1481
+ case "topic-subscribe":
1482
+ case "topic-unsubscribe":
1483
+ return _nullishCoalesce(p.topic, () => ( "?"));
1484
+ case "auth-login":
1485
+ return "(redacted)";
1486
+ case "auth-logout":
1487
+ return "";
1488
+ }
1489
+ }
1458
1490
  createSocket() {
1459
1491
  const socketOptions = {
1460
1492
  protocols: this.options.protocols,
@@ -1493,7 +1525,7 @@ var SharedWebSocket = (_class6 = class {
1493
1525
  for (const mw of this.incomingMiddleware) {
1494
1526
  data = mw(data);
1495
1527
  if (data === null) {
1496
- this.log.debug("[SharedWS] \u2717 incoming dropped by middleware");
1528
+ this.log.debug("[SharedWS] \u2717 incoming dropped by middleware", { raw });
1497
1529
  return;
1498
1530
  }
1499
1531
  }
@@ -1504,7 +1536,7 @@ var SharedWebSocket = (_class6 = class {
1504
1536
  if (eventDeserializer) {
1505
1537
  payload = eventDeserializer(payload);
1506
1538
  }
1507
- this.log.debug("[SharedWS] \u2190 recv", event, payload);
1539
+ this.log.debug("[SharedWS] \u2190 recv", event, { data: payload, raw: data });
1508
1540
  this.bus.broadcast("ws:message", { event, data: payload, raw: data });
1509
1541
  });
1510
1542
  this.socket.onStateChange((state) => {
@@ -1734,4 +1766,4 @@ var SharedWebSocket = (_class6 = class {
1734
1766
 
1735
1767
 
1736
1768
  exports.MessageBus = MessageBus; exports.TabCoordinator = TabCoordinator; exports.SharedSocket = SharedSocket; exports.WorkerSocket = WorkerSocket; exports.SubscriptionManager = SubscriptionManager; exports.SharedWebSocket = SharedWebSocket;
1737
- //# sourceMappingURL=chunk-YZLE4TZB.cjs.map
1769
+ //# sourceMappingURL=chunk-MTKKFO6U.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/chunk-MTKKFO6U.cjs","../src/utils/id.ts","../src/MessageBus.ts","../src/TabCoordinator.ts","../src/utils/backoff.ts","../src/SharedSocket.ts","../src/WorkerSocket.ts","../src/SubscriptionManager.ts","../src/SharedWebSocket.ts"],"names":[],"mappings":"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","file":"/Users/gwakko/Projects/shared-websocket/dist/chunk-MTKKFO6U.cjs","sourcesContent":[null,"export function generateId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;\n}\n","import './utils/disposable';\nimport { generateId } from './utils/id';\nimport type { BusMessage, Unsubscribe } from './types';\n\ntype Listener = (msg: BusMessage) => void;\n\nexport class MessageBus implements Disposable {\n private channel: BroadcastChannel;\n private listeners = new Map<string, Set<Listener>>();\n private pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();\n\n constructor(\n channelName: string,\n private readonly tabId: string,\n ) {\n this.channel = new BroadcastChannel(channelName);\n this.channel.onmessage = (ev: MessageEvent<BusMessage>) => {\n this.handleMessage(ev.data);\n };\n }\n\n subscribe<T>(topic: string, fn: (data: T) => void): Unsubscribe {\n const wrapper: Listener = (msg) => {\n // `broadcast` is \"fan out to all tabs INCLUDING me\" — `broadcast()`\n // explicitly self-delivers via handleMessage so the leader's own\n // handlers fire for events it originated (e.g. ws:message after the\n // leader receives a server frame). `publish` is fire-and-forget to\n // OTHER tabs, so skip self-originated publish messages.\n if (msg.type !== 'publish' || msg.source !== this.tabId) {\n fn(msg.data as T);\n }\n };\n this.addListener(topic, wrapper);\n return () => this.removeListener(topic, wrapper);\n }\n\n publish<T>(topic: string, data: T): void {\n this.postMessage({ topic, type: 'publish', data });\n }\n\n broadcast<T>(topic: string, data: T): void {\n const msg = this.createMessage(topic, 'broadcast', data);\n this.channel.postMessage(msg);\n // Also deliver to self\n this.handleMessage(msg);\n }\n\n async request<T, R>(topic: string, data: T, timeout = 5000): Promise<R> {\n const msg = this.createMessage(topic, 'request', data);\n return new Promise<R>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pendingRequests.delete(msg.id);\n reject(new Error(`MessageBus.request: timeout for topic \"${topic}\"`));\n }, timeout);\n this.pendingRequests.set(msg.id, { resolve: resolve as (v: unknown) => void, reject, timer });\n this.channel.postMessage(msg);\n });\n }\n\n respond<T, R>(topic: string, fn: (data: T) => R | Promise<R>): Unsubscribe {\n const wrapper: Listener = async (msg) => {\n if (msg.type !== 'request' || msg.source === this.tabId) return;\n const result = await fn(msg.data as T);\n this.postMessage({ topic, type: 'response', data: { requestId: msg.id, result } });\n };\n this.addListener(topic, wrapper);\n return () => this.removeListener(topic, wrapper);\n }\n\n private handleMessage(msg: BusMessage): void {\n // Handle response to pending request\n if (msg.type === 'response') {\n const payload = msg.data as { requestId: string; result: unknown };\n const pending = this.pendingRequests.get(payload.requestId);\n if (pending) {\n clearTimeout(pending.timer);\n this.pendingRequests.delete(payload.requestId);\n pending.resolve(payload.result);\n return;\n }\n }\n\n const listeners = this.listeners.get(msg.topic);\n if (listeners) {\n for (const fn of listeners) fn(msg);\n }\n }\n\n private postMessage(partial: Pick<BusMessage, 'topic' | 'type' | 'data'>): void {\n this.channel.postMessage(this.createMessage(partial.topic, partial.type, partial.data));\n }\n\n private createMessage(topic: string, type: BusMessage['type'], data: unknown): BusMessage {\n return { id: generateId(), source: this.tabId, topic, type, data, timestamp: Date.now() };\n }\n\n private addListener(topic: string, fn: Listener): void {\n let set = this.listeners.get(topic);\n if (!set) {\n set = new Set();\n this.listeners.set(topic, set);\n }\n set.add(fn);\n }\n\n private removeListener(topic: string, fn: Listener): void {\n this.listeners.get(topic)?.delete(fn);\n }\n\n [Symbol.dispose](): void {\n for (const pending of this.pendingRequests.values()) {\n clearTimeout(pending.timer);\n pending.reject(new Error('MessageBus disposed'));\n }\n this.pendingRequests.clear();\n this.listeners.clear();\n this.channel.close();\n }\n}\n","import './utils/disposable';\nimport { MessageBus } from './MessageBus';\nimport type { Unsubscribe } from './types';\n\ninterface CoordinatorOptions {\n electionTimeout?: number; // ms to wait for rejection (default 200)\n heartbeatInterval?: number; // ms between heartbeats (default 2000)\n leaderTimeout?: number; // ms without heartbeat to trigger election (default 5000)\n}\n\nexport class TabCoordinator implements Disposable {\n private _isLeader = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private leaderCheckTimer: ReturnType<typeof setInterval> | null = null;\n private lastHeartbeat = 0;\n private disposed = false;\n\n private onBecomeLeaderFns = new Set<() => void>();\n private onLoseLeadershipFns = new Set<() => void>();\n private cleanups: Unsubscribe[] = [];\n\n private readonly electionTimeout: number;\n private readonly heartbeatInterval: number;\n private readonly leaderTimeout: number;\n\n constructor(\n private readonly bus: MessageBus,\n private readonly tabId: string,\n options: CoordinatorOptions = {},\n ) {\n this.electionTimeout = options.electionTimeout ?? 200;\n this.heartbeatInterval = options.heartbeatInterval ?? 2000;\n this.leaderTimeout = options.leaderTimeout ?? 5000;\n\n // Listen for election requests — reject if we are leader\n this.cleanups.push(\n this.bus.subscribe<{ tabId: string }>('coord:election', () => {\n if (this._isLeader) {\n this.bus.publish('coord:reject', { tabId: this.tabId });\n }\n }),\n );\n\n // Listen for heartbeats\n this.cleanups.push(\n this.bus.subscribe<{ tabId: string }>('coord:heartbeat', () => {\n this.lastHeartbeat = Date.now();\n }),\n );\n\n // Listen for abdication\n this.cleanups.push(\n this.bus.subscribe('coord:abdicate', () => {\n if (!this._isLeader && !this.disposed) {\n this.elect();\n }\n }),\n );\n }\n\n get isLeader(): boolean {\n return this._isLeader;\n }\n\n async elect(): Promise<void> {\n if (this.disposed) return;\n\n return new Promise<void>((resolve) => {\n let rejected = false;\n\n const unsub = this.bus.subscribe('coord:reject', () => {\n rejected = true;\n unsub();\n // We are follower — start monitoring leader heartbeat\n this.startLeaderCheck();\n resolve();\n });\n\n this.bus.publish('coord:election', { tabId: this.tabId });\n\n setTimeout(() => {\n unsub();\n if (!rejected && !this.disposed) {\n this.becomeLeader();\n }\n resolve();\n }, this.electionTimeout);\n });\n }\n\n abdicate(): void {\n if (!this._isLeader) return;\n this._isLeader = false;\n this.stopHeartbeat();\n this.bus.publish('coord:abdicate', { tabId: this.tabId });\n for (const fn of this.onLoseLeadershipFns) fn();\n }\n\n onBecomeLeader(fn: () => void): Unsubscribe {\n this.onBecomeLeaderFns.add(fn);\n return () => this.onBecomeLeaderFns.delete(fn);\n }\n\n onLoseLeadership(fn: () => void): Unsubscribe {\n this.onLoseLeadershipFns.add(fn);\n return () => this.onLoseLeadershipFns.delete(fn);\n }\n\n private becomeLeader(): void {\n this._isLeader = true;\n this.stopLeaderCheck();\n this.startHeartbeat();\n for (const fn of this.onBecomeLeaderFns) fn();\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.bus.publish('coord:heartbeat', { tabId: this.tabId });\n }, this.heartbeatInterval);\n // Send immediately\n this.bus.publish('coord:heartbeat', { tabId: this.tabId });\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private startLeaderCheck(): void {\n this.stopLeaderCheck();\n this.lastHeartbeat = Date.now();\n this.leaderCheckTimer = setInterval(() => {\n if (Date.now() - this.lastHeartbeat > this.leaderTimeout && !this.disposed) {\n this.stopLeaderCheck();\n this.elect();\n }\n }, 1000);\n }\n\n private stopLeaderCheck(): void {\n if (this.leaderCheckTimer) {\n clearInterval(this.leaderCheckTimer);\n this.leaderCheckTimer = null;\n }\n }\n\n [Symbol.dispose](): void {\n this.disposed = true;\n if (this._isLeader) {\n this.abdicate();\n }\n this.stopHeartbeat();\n this.stopLeaderCheck();\n for (const unsub of this.cleanups) unsub();\n this.cleanups = [];\n this.onBecomeLeaderFns.clear();\n this.onLoseLeadershipFns.clear();\n }\n}\n","/** Exponential backoff generator with jitter. */\nexport function* backoff(base = 1000, max = 30_000): Generator<number> {\n let delay = base;\n while (true) {\n const jitter = delay * 0.25 * (Math.random() * 2 - 1);\n yield Math.min(delay + jitter, max);\n delay = Math.min(delay * 2, max);\n }\n}\n","import './utils/disposable';\nimport { backoff } from './utils/backoff';\nimport type { SocketState, Unsubscribe, EventHandler } from './types';\n\ninterface SharedSocketOptions {\n protocols?: string[];\n reconnect?: boolean;\n reconnectMaxDelay?: number;\n /** Max reconnect attempts before giving up (default: Infinity). */\n reconnectMaxRetries?: number;\n /** Close codes that mean \"auth failed — stop reconnect.\" Default: [1008]. */\n authFailureCloseCodes?: number[];\n heartbeatInterval?: number;\n sendBuffer?: number;\n auth?: () => string | Promise<string>;\n authToken?: string;\n authParam?: string;\n /** Heartbeat payload (default: { type: \"ping\" }). */\n pingPayload?: unknown;\n /** Custom serializer (default: JSON.stringify). */\n serialize?: (data: unknown) => string | ArrayBuffer | Blob;\n /** Custom deserializer (default: JSON.parse). */\n deserialize?: (raw: string | ArrayBuffer) => unknown;\n}\n\nexport class SharedSocket implements Disposable {\n private ws: WebSocket | null = null;\n private _state: SocketState = 'closed';\n private buffer: unknown[] = [];\n private disposed = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n private onMessageFns = new Set<EventHandler>();\n private onStateChangeFns = new Set<(state: SocketState) => void>();\n\n private reconnectAttempts = 0;\n\n private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam' | 'pingPayload' | 'serialize' | 'deserialize' | 'authFailureCloseCodes'>> & {\n authFailureCloseCodes: ReadonlySet<number>;\n auth?: () => string | Promise<string>;\n authToken?: string;\n authParam: string;\n pingPayload: unknown;\n serialize: (data: unknown) => string | ArrayBuffer | Blob;\n deserialize: (raw: string | ArrayBuffer) => unknown;\n };\n\n constructor(\n private url: string,\n options: SharedSocketOptions = {},\n ) {\n this.opts = {\n protocols: options.protocols ?? [],\n reconnect: options.reconnect ?? true,\n reconnectMaxDelay: options.reconnectMaxDelay ?? 30_000,\n reconnectMaxRetries: options.reconnectMaxRetries ?? Infinity,\n authFailureCloseCodes: new Set(options.authFailureCloseCodes ?? [1008]),\n heartbeatInterval: options.heartbeatInterval ?? 30_000,\n sendBuffer: options.sendBuffer ?? 100,\n auth: options.auth,\n authToken: options.authToken,\n authParam: options.authParam ?? 'token',\n pingPayload: options.pingPayload ?? { type: 'ping' },\n serialize: options.serialize ?? ((data: unknown) => JSON.stringify(data)),\n deserialize: options.deserialize ?? ((raw: string | ArrayBuffer) => {\n if (typeof raw === 'string') return JSON.parse(raw);\n // ArrayBuffer → decode as UTF-8 then parse\n return JSON.parse(new TextDecoder().decode(raw));\n }),\n };\n }\n\n get state(): SocketState {\n return this._state;\n }\n\n async connect(): Promise<void> {\n if (this.disposed) return;\n\n this.setState('connecting');\n\n let connectUrl: string;\n try {\n connectUrl = await this.buildUrl();\n } catch {\n // auth() threw or returned no token — pause reconnect until user\n // provides fresh creds via ws.authenticate(token) or ws.reconnect().\n this.setState('failed');\n return;\n }\n this.ws = new WebSocket(connectUrl, this.opts.protocols);\n\n this.ws.onopen = () => {\n this.reconnectAttempts = 0;\n this.setState('connected');\n this.flushBuffer();\n this.startHeartbeat();\n };\n\n this.ws.onmessage = (ev: MessageEvent) => {\n let data: unknown;\n try {\n data = this.opts.deserialize(ev.data as string | ArrayBuffer);\n } catch {\n data = ev.data;\n }\n for (const fn of this.onMessageFns) fn(data);\n };\n\n this.ws.onclose = (ev) => {\n this.stopHeartbeat();\n if (this.opts.authFailureCloseCodes.has(ev.code)) {\n // Auth-failure close code — don't burn retries with stale creds.\n // User must call ws.authenticate(freshToken) or ws.reconnect() to resume.\n this.setState('failed');\n return;\n }\n if (!this.disposed && this.opts.reconnect) {\n this.scheduleReconnect();\n } else {\n this.setState('closed');\n }\n };\n\n this.ws.onerror = () => {\n // onclose will fire after onerror\n };\n }\n\n disconnect(): void {\n this.disposed = true;\n this.stopHeartbeat();\n this.clearReconnect();\n\n if (this.ws) {\n this.ws.onclose = null;\n this.ws.onmessage = null;\n this.ws.onerror = null;\n if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {\n this.ws.close(1000, 'client disconnect');\n }\n this.ws = null;\n }\n\n this.setState('closed');\n }\n\n send(data: unknown): void {\n if (this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(this.opts.serialize(data));\n } else if (this._state === 'reconnecting' || this._state === 'connecting') {\n if (this.buffer.length < this.opts.sendBuffer) {\n this.buffer.push(data);\n }\n }\n }\n\n onMessage(fn: EventHandler): Unsubscribe {\n this.onMessageFns.add(fn);\n return () => this.onMessageFns.delete(fn);\n }\n\n onStateChange(fn: (state: SocketState) => void): Unsubscribe {\n this.onStateChangeFns.add(fn);\n return () => this.onStateChangeFns.delete(fn);\n }\n\n /**\n * Manually trigger a reconnect. Resets the retry counter and clears any\n * scheduled backoff so the next attempt happens immediately. Use after\n * `state === 'failed'` to let the user retry, or any time to force a\n * fresh connection.\n */\n reconnect(): void {\n if (this.disposed) return;\n this.clearReconnect();\n this.reconnectAttempts = 0;\n\n if (this.ws) {\n this.ws.onclose = null;\n this.ws.onmessage = null;\n this.ws.onerror = null;\n if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {\n this.ws.close(1000, 'manual reconnect');\n }\n this.ws = null;\n }\n\n void this.connect();\n }\n\n private scheduleReconnect(): void {\n this.reconnectAttempts++;\n\n if (this.reconnectAttempts > this.opts.reconnectMaxRetries) {\n this.setState('failed');\n return;\n }\n\n this.setState('reconnecting');\n const gen = backoff(1000, this.opts.reconnectMaxDelay);\n\n const attempt = () => {\n if (this.disposed) return;\n const delay = gen.next().value;\n this.reconnectTimer = setTimeout(() => {\n if (!this.disposed) this.connect();\n }, delay);\n };\n\n attempt();\n }\n\n private flushBuffer(): void {\n const pending = this.buffer.splice(0);\n for (const item of pending) {\n this.send(item);\n }\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(this.opts.serialize(this.opts.pingPayload));\n }\n }, this.opts.heartbeatInterval);\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private clearReconnect(): void {\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n }\n\n private async buildUrl(): Promise<string> {\n // Resolve token: callback > static > none\n let token: string | undefined;\n if (this.opts.auth) {\n // If the auth callback throws, let it propagate — connect() catches and\n // pauses reconnect until the user supplies fresh creds.\n token = await this.opts.auth();\n if (!token) {\n // Configured auth callback returned no token. Treat as a fatal auth\n // condition (don't silently connect without credentials).\n throw new Error('SharedSocket: auth() returned no token');\n }\n } else if (this.opts.authToken) {\n token = this.opts.authToken;\n }\n\n if (!token) return this.url;\n\n // WebSocket URLs (ws://, wss://) are not fully supported by URL API.\n // Convert to http(s) for parsing, then back to ws(s).\n const httpUrl = this.url.replace(/^ws(s?):\\/\\//, 'http$1://');\n const parsed = new URL(httpUrl);\n parsed.searchParams.set(this.opts.authParam, token);\n\n return parsed.toString().replace(/^http(s?):\\/\\//, 'ws$1://');\n }\n\n private setState(state: SocketState): void {\n this._state = state;\n for (const fn of this.onStateChangeFns) fn(state);\n }\n\n [Symbol.dispose](): void {\n this.disconnect();\n this.onMessageFns.clear();\n this.onStateChangeFns.clear();\n this.buffer = [];\n }\n}\n","import './utils/disposable';\nimport type { SocketState, Unsubscribe, EventHandler } from './types';\n\n/**\n * WorkerSocket — WebSocket running inside a Web Worker.\n *\n * Same interface as SharedSocket, but WebSocket lives off main thread.\n * Benefits: heartbeat timers and JSON parsing don't block UI rendering.\n *\n * Use when:\n * - High message rate (50+ msgs/sec)\n * - Heavy JSON payloads\n * - UI does complex rendering that could block main thread\n *\n * Don't use when:\n * - Low message rate (simple chat, notifications)\n * - Bundle size matters (adds worker file)\n * - Debugging (Worker DevTools is less convenient)\n */\nexport class WorkerSocket implements Disposable {\n private worker: Worker | null = null;\n private _state: SocketState = 'closed';\n\n private onMessageFns = new Set<EventHandler>();\n private onStateChangeFns = new Set<(state: SocketState) => void>();\n\n constructor(\n private url: string,\n private options: {\n protocols?: string[];\n reconnect?: boolean;\n reconnectMaxDelay?: number;\n reconnectMaxRetries?: number;\n authFailureCloseCodes?: number[];\n heartbeatInterval?: number;\n sendBuffer?: number;\n workerUrl?: string | URL;\n auth?: () => string | Promise<string>;\n authToken?: string;\n authParam?: string;\n pingPayload?: unknown;\n } = {},\n ) {}\n\n get state(): SocketState {\n return this._state;\n }\n\n private setState(s: SocketState): void {\n this._state = s;\n for (const fn of this.onStateChangeFns) fn(s);\n }\n\n async connect(): Promise<void> {\n // Resolve auth token before sending to worker (functions can't cross worker boundary)\n let authToken: string | undefined;\n if (this.options.auth) {\n try {\n authToken = await this.options.auth();\n } catch {\n // auth() threw — pause reconnect until user provides fresh creds.\n this.setState('failed');\n return;\n }\n if (!authToken) {\n // Configured auth callback returned nothing — same fail-closed behavior.\n this.setState('failed');\n return;\n }\n } else if (this.options.authToken) {\n authToken = this.options.authToken;\n }\n\n // Build URL with auth token\n const connectUrl = authToken ? this.buildUrl(authToken) : this.url;\n\n // Create worker from inline blob if no workerUrl provided\n const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();\n\n this.worker = new Worker(workerUrl, { type: 'module' });\n\n this.worker.onmessage = (ev: MessageEvent) => {\n const msg = ev.data;\n\n switch (msg.type) {\n case 'state':\n this._state = msg.state;\n for (const fn of this.onStateChangeFns) fn(msg.state);\n break;\n\n case 'message':\n for (const fn of this.onMessageFns) fn(msg.data);\n break;\n\n case 'open':\n // State already set via 'state' message\n break;\n\n case 'close':\n break;\n\n case 'error':\n console.error('WorkerSocket error:', msg.message);\n break;\n }\n };\n\n this.worker.postMessage({\n type: 'connect',\n url: connectUrl,\n protocols: this.options.protocols ?? [],\n reconnect: this.options.reconnect ?? true,\n reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,\n reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,\n authFailureCloseCodes: this.options.authFailureCloseCodes ?? [1008],\n heartbeatInterval: this.options.heartbeatInterval ?? 30_000,\n bufferSize: this.options.sendBuffer ?? 100,\n pingPayload: this.options.pingPayload,\n });\n }\n\n private buildUrl(token: string): string {\n const param = this.options.authParam ?? 'token';\n const httpUrl = this.url.replace(/^ws(s?):\\/\\//, 'http$1://');\n const parsed = new URL(httpUrl);\n parsed.searchParams.set(param, token);\n return parsed.toString().replace(/^http(s?):\\/\\//, 'ws$1://');\n }\n\n send(data: unknown): void {\n this.worker?.postMessage({ type: 'send', data });\n }\n\n /** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */\n reconnect(): void {\n this.worker?.postMessage({ type: 'reconnect' });\n }\n\n disconnect(): void {\n this.worker?.postMessage({ type: 'disconnect' });\n setTimeout(() => {\n this.worker?.terminate();\n this.worker = null;\n }, 100);\n this._state = 'closed';\n }\n\n onMessage(fn: EventHandler): Unsubscribe {\n this.onMessageFns.add(fn);\n return () => this.onMessageFns.delete(fn);\n }\n\n onStateChange(fn: (state: SocketState) => void): Unsubscribe {\n this.onStateChangeFns.add(fn);\n return () => this.onStateChangeFns.delete(fn);\n }\n\n private createWorkerBlob(): URL {\n // Inline the worker code as a blob URL\n // In production, use a bundler (Vite, webpack) to handle worker imports\n const code = `\n let ws = null, state = 'closed', buffer = [], disposed = false;\n let heartbeatTimer = null, reconnectTimer = null;\n let url = '', protocols = [], shouldReconnect = true;\n let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;\n let authFailCodes = new Set([1008]);\n let delay = 1000, attempts = 0, pingPayload = '{\"type\":\"ping\"}';\n\n function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }\n function connect() {\n if (disposed) return;\n setState('connecting');\n ws = new WebSocket(url, protocols);\n ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };\n ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };\n ws.onclose = (e) => {\n stopHB();\n self.postMessage({ type: 'close', code: e.code, reason: e.reason });\n if (authFailCodes.has(e.code)) { setState('failed'); return; }\n if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed');\n };\n ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };\n }\n function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }\n function flush() { const p = buffer.splice(0); p.forEach(send); }\n function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(pingPayload); }, hbInterval); }\n function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }\n function reconnect() {\n attempts++;\n if (attempts > maxRetries) { setState('failed'); return; }\n setState('reconnecting');\n const j = delay * 0.25 * (Math.random() * 2 - 1);\n reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));\n delay = Math.min(delay * 2, maxDelay);\n }\n function manualReconnect() {\n if (disposed) return;\n if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }\n attempts = 0; delay = 1000;\n if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }\n connect();\n }\n self.onmessage = (e) => {\n const c = e.data;\n if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; if (c.authFailureCloseCodes) authFailCodes = new Set(c.authFailureCloseCodes); hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }\n if (c.type === 'send') send(c.data);\n if (c.type === 'reconnect') manualReconnect();\n if (c.type === 'disconnect') { disposed = true; stopHB(); if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) { ws.onclose = null; if (ws.readyState < 2) ws.close(1000); ws = null; } buffer = []; setState('closed'); }\n };\n `;\n const blob = new Blob([code], { type: 'application/javascript' });\n return new URL(URL.createObjectURL(blob));\n }\n\n [Symbol.dispose](): void {\n this.disconnect();\n this.onMessageFns.clear();\n this.onStateChangeFns.clear();\n }\n}\n","import './utils/disposable';\nimport type { EventHandler, Unsubscribe } from './types';\n\nexport class SubscriptionManager implements Disposable {\n private handlers = new Map<string, Set<EventHandler>>();\n private lastMessages = new Map<string, unknown>();\n\n on(event: string, handler: EventHandler): Unsubscribe {\n let set = this.handlers.get(event);\n if (!set) {\n set = new Set();\n this.handlers.set(event, set);\n }\n set.add(handler);\n return () => set!.delete(handler);\n }\n\n once(event: string, handler: EventHandler): Unsubscribe {\n const wrapper: EventHandler = (data, raw) => {\n unsub();\n handler(data, raw);\n };\n const unsub = this.on(event, wrapper);\n return unsub;\n }\n\n off(event: string, handler?: EventHandler): void {\n if (handler) {\n this.handlers.get(event)?.delete(handler);\n } else {\n this.handlers.delete(event);\n }\n }\n\n emit(event: string, data: unknown, raw?: unknown): void {\n this.lastMessages.set(event, data);\n const set = this.handlers.get(event);\n if (set) {\n for (const fn of set) fn(data, raw);\n }\n }\n\n getLastMessage(event: string): unknown | undefined {\n return this.lastMessages.get(event);\n }\n\n async *stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n const queue: unknown[] = [];\n let resolve: (() => void) | null = null;\n let done = false;\n\n const unsub = this.on(event, (data) => {\n queue.push(data);\n resolve?.();\n });\n\n const onAbort = () => {\n done = true;\n resolve?.();\n };\n signal?.addEventListener('abort', onAbort);\n\n try {\n while (!done) {\n if (queue.length > 0) {\n yield queue.shift()!;\n } else {\n await new Promise<void>((r) => { resolve = r; });\n resolve = null;\n }\n }\n } finally {\n unsub();\n signal?.removeEventListener('abort', onAbort);\n }\n }\n\n offAll(): void {\n this.handlers.clear();\n this.lastMessages.clear();\n }\n\n [Symbol.dispose](): void {\n this.offAll();\n }\n}\n","import './utils/disposable';\nimport { generateId } from './utils/id';\nimport { MessageBus } from './MessageBus';\nimport { TabCoordinator } from './TabCoordinator';\nimport { SharedSocket } from './SharedSocket';\nimport { WorkerSocket } from './WorkerSocket';\nimport { SubscriptionManager } from './SubscriptionManager';\nimport type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventProtocol, EventMap, Logger, Middleware, FrameKind, FramePayload, ChannelAckResult } from './types';\n\nconst DEFAULT_PROTOCOL: EventProtocol = {\n eventField: 'event',\n dataField: 'data',\n channelJoin: '$channel:join',\n channelLeave: '$channel:leave',\n ping: { type: 'ping' },\n defaultEvent: 'message',\n topicSubscribe: '$topic:subscribe',\n topicUnsubscribe: '$topic:unsubscribe',\n authLogin: '$auth:login',\n authLogout: '$auth:logout',\n authRevoked: '$auth:revoked',\n};\n\nconst NOOP_LOGGER: Logger = {\n debug() {},\n info() {},\n warn() {},\n error() {},\n};\n\n/**\n * Internal separator for channel-scoped subscription keys. ASCII RECORD\n * SEPARATOR (U+001E) — chosen because it cannot collide with characters\n * users put in channel or event names. Wire format keeps `:` for server\n * compatibility; this is storage-only.\n */\nconst CHANNEL_KEY_SEP = '\\u001e';\n\n/** Common interface for both SharedSocket and WorkerSocket. */\ninterface SocketAdapter {\n readonly state: string;\n connect(): void | Promise<void>;\n send(data: unknown): void;\n reconnect(): void;\n disconnect(): void;\n onMessage(fn: EventHandler): Unsubscribe;\n onStateChange(fn: (state: string) => void): Unsubscribe;\n [Symbol.dispose](): void;\n}\n\n/**\n * SharedWebSocket — shares ONE WebSocket connection across browser tabs.\n *\n * @typeParam TEvents - Event map for type-safe subscriptions.\n *\n * @example\n * // Typed events\n * type Events = {\n * 'chat.message': { text: string; userId: string };\n * 'order.created': { id: string; total: number };\n * };\n * const ws = new SharedWebSocket<Events>(url);\n * ws.on('chat.message', (msg) => msg.text); // ← msg: { text, userId }\n */\nexport class SharedWebSocket<TEvents extends EventMap = EventMap> implements Disposable {\n private bus: MessageBus;\n private coordinator: TabCoordinator;\n private socket: SocketAdapter | null = null;\n private subs = new SubscriptionManager();\n private syncStore = new Map<string, unknown>();\n private tabId: string;\n private cleanups: Unsubscribe[] = [];\n private disposed = false;\n private readonly proto: EventProtocol;\n private readonly log: Logger;\n private outgoingMiddleware: Middleware[] = [];\n private incomingMiddleware: Middleware[] = [];\n private serializers = new Map<string, (data: unknown) => unknown>();\n private deserializers = new Map<string, (data: unknown) => unknown>();\n private _isAuthenticated = false;\n private authChannels = new Map<string, Channel>();\n private authTopics = new Set<string>();\n /**\n * Refcount of active channel subscriptions per name. Used to route\n * incoming events back to channel handlers via `${name}<RS>${event}`\n * keys without colliding when names/events contain `:`, and as the\n * source for cross-tab subscription replay on leader change.\n */\n private channelRefs = new Map<string, number>();\n /** All topic subscriptions (auth and non-auth). Replayed on leader change. */\n private topics = new Set<string>();\n /** Listeners for every raw incoming frame (post-deserialize, post-middleware). */\n private rawFrameListeners = new Set<(raw: unknown) => void>();\n /**\n * Local outbound buffer of follower-originated dispatches awaiting flush\n * confirmation from the leader. Drained when the leader broadcasts\n * `ws:dispatch-flushed` for the entry's id; replayed by the next leader\n * after gathering across surviving tabs. Insertion order preserved\n * (Map) so we drop oldest on overflow.\n */\n private pendingOutbound = new Map<string, { id: string; kind: FrameKind; payload: FramePayload; enqueuedAt: number }>();\n /** Periodic refresh timer — leader only. Recreated on each leader handover. */\n private refreshTimer: ReturnType<typeof setInterval> | null = null;\n\n constructor(\n private readonly url: string,\n private readonly options: SharedWebSocketOptions<TEvents> = {} as SharedWebSocketOptions<TEvents>,\n ) {\n this.proto = { ...DEFAULT_PROTOCOL, ...options.events };\n this.log = options.debug ? (options.logger ?? console) : NOOP_LOGGER;\n this.tabId = generateId();\n this.log.debug('[SharedWS] init', { tabId: this.tabId, url });\n this.bus = new MessageBus('shared-ws', this.tabId);\n this.coordinator = new TabCoordinator(this.bus, this.tabId, {\n electionTimeout: options.electionTimeout,\n heartbeatInterval: options.leaderHeartbeat,\n leaderTimeout: options.leaderTimeout,\n });\n\n // When ANY tab receives a WS message via bus → emit to local subscribers\n this.cleanups.push(\n this.bus.subscribe<{ event: string; data: unknown; raw?: unknown }>('ws:message', (msg) => {\n // Bare emit — fires any handler registered with the literal event name\n this.subs.emit(msg.event, msg.data, msg.raw);\n\n // Channel-scoped emit — for each registered channel whose name is a\n // prefix of the incoming event (separated by ':'), also fire handlers\n // stored under `${name}<RS>${rest}`. This lets `Channel.on('msg', h)`\n // receive a wire event like 'chat:room:42:msg' without colon parsing.\n for (const channelName of this.channelRefs.keys()) {\n const prefix = channelName + ':';\n if (msg.event.length > prefix.length && msg.event.startsWith(prefix)) {\n const subEvent = msg.event.slice(prefix.length);\n this.subs.emit(`${channelName}${CHANNEL_KEY_SEP}${subEvent}`, msg.data, msg.raw);\n }\n }\n\n // Raw-frame fanout — pending Channel.ready ack matchers listen here.\n if (this.rawFrameListeners.size > 0) {\n for (const fn of this.rawFrameListeners) {\n try { fn(msg.raw); } catch { /* matcher errors don't break dispatch */ }\n }\n }\n }),\n );\n\n // Leader listens for dispatch requests from followers — re-enters\n // transmit() so frameBuilder + outgoing middleware run on the tab that\n // actually owns the socket.\n this.cleanups.push(\n this.bus.subscribe<{ kind: FrameKind; payload: FramePayload; id?: string }>('ws:dispatch', (msg) => {\n if (this.coordinator.isLeader && this.socket) {\n this.transmit(msg.kind, msg.payload);\n // Tell the originator to drop the entry from its pending buffer.\n // Always flush — even when transmit was a no-op (middleware drop,\n // frameBuilder returned null) — there's no point retrying a\n // permanently-dropped frame.\n if (msg.id) this.bus.publish('ws:dispatch-flushed', { id: msg.id });\n }\n }),\n );\n\n // Originator tabs drop their entry once the leader confirms it processed\n // the dispatch (or, on leader change, the new leader confirms replay).\n this.cleanups.push(\n this.bus.subscribe<{ id: string }>('ws:dispatch-flushed', (msg) => {\n this.pendingOutbound.delete(msg.id);\n }),\n );\n\n // New-leader gather request — every tab announces its still-pending\n // dispatches so the new leader can replay them on the fresh socket.\n this.cleanups.push(\n this.bus.subscribe<{ replyId: string }>('ws:gather-pending', (req) => {\n if (this.pendingOutbound.size === 0) return;\n this.bus.publish(`ws:pending:${req.replyId}`, {\n entries: [...this.pendingOutbound.values()],\n });\n }),\n );\n\n // Leader listens for reconnect requests from followers\n this.cleanups.push(\n this.bus.subscribe<void>('ws:reconnect', () => {\n if (this.coordinator.isLeader && this.socket) {\n this.log.info('[SharedWS] manual reconnect requested by follower');\n this.socket.reconnect();\n }\n }),\n );\n\n // Conditional resume — only reconnect if the leader's socket gave up\n // (e.g. auth-failure close code). Sent by authenticate() from followers\n // so they can recover with fresh creds without disrupting healthy tabs.\n this.cleanups.push(\n this.bus.subscribe<void>('ws:authenticate-resume', () => {\n if (this.coordinator.isLeader && this.socket?.state === 'failed') {\n this.log.info('[SharedWS] resume requested after auth — reconnecting failed socket');\n this.socket.reconnect();\n }\n }),\n );\n\n // Each tab announces its channels/topics on request. Used on leader\n // promotion or reconnect to rebuild the server-side subscription set.\n this.cleanups.push(\n this.bus.subscribe<{ replyId: string }>('ws:gather-subs', (req) => {\n this.bus.publish(`ws:subs:${req.replyId}`, {\n channels: [...this.channelRefs.keys()],\n topics: [...this.topics],\n });\n }),\n );\n\n // Sync across tabs\n this.cleanups.push(\n this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {\n this.syncStore.set(msg.key, msg.value);\n this.subs.emit(`sync:${msg.key}`, msg.value);\n }),\n );\n\n // Leader lifecycle\n this.coordinator.onBecomeLeader(() => {\n this.handleBecomeLeader();\n this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: true });\n });\n this.coordinator.onLoseLeadership(() => {\n this.handleLoseLeadership();\n this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: false });\n });\n\n // Lifecycle events from bus (all tabs receive)\n this.cleanups.push(\n this.bus.subscribe<{ type: string; isLeader?: boolean; error?: unknown; authenticated?: boolean }>('ws:lifecycle', (msg) => {\n switch (msg.type) {\n case 'connect':\n this.subs.emit('$lifecycle:connect', undefined);\n break;\n case 'disconnect':\n this.subs.emit('$lifecycle:disconnect', undefined);\n break;\n case 'reconnecting':\n this.subs.emit('$lifecycle:reconnecting', undefined);\n break;\n case 'reconnectFailed':\n this.subs.emit('$lifecycle:reconnectFailed', undefined);\n break;\n case 'leader':\n this.subs.emit('$lifecycle:leader', msg.isLeader);\n break;\n case 'error':\n this.subs.emit('$lifecycle:error', msg.error);\n break;\n case 'auth': {\n this._isAuthenticated = !!msg.authenticated;\n if (!msg.authenticated) {\n this.authChannels.clear();\n this.authTopics.clear();\n }\n this.subs.emit('$lifecycle:auth', msg.authenticated);\n break;\n }\n }\n }),\n );\n\n // Track tab visibility\n if (typeof document !== 'undefined') {\n const onVisibilityChange = () => {\n const active = !document.hidden;\n this.subs.emit('$lifecycle:active', active);\n this.log.debug('[SharedWS]', active ? '👁 tab active' : '👁 tab hidden');\n };\n document.addEventListener('visibilitychange', onVisibilityChange);\n this.cleanups.push(() => document.removeEventListener('visibilitychange', onVisibilityChange));\n }\n\n // Handle server-initiated auth revocation\n this.cleanups.push(\n this.subs.on(this.proto.authRevoked, () => {\n if (this.coordinator.isLeader) {\n for (const [, ch] of this.authChannels) ch.leave();\n for (const topic of this.authTopics) this.unsubscribe(topic);\n }\n this.authChannels.clear();\n this.authTopics.clear();\n this._isAuthenticated = false;\n this.syncStore.delete('$auth:token');\n this.subs.emit('$lifecycle:auth', false);\n this.log.warn('[SharedWS] auth revoked by server');\n }),\n );\n\n // Cleanup on tab close\n if (typeof window !== 'undefined') {\n const onBeforeUnload = () => this[Symbol.dispose]();\n window.addEventListener('beforeunload', onBeforeUnload);\n this.cleanups.push(() => window.removeEventListener('beforeunload', onBeforeUnload));\n }\n }\n\n get connected(): boolean {\n return this.socket?.state === 'connected' || !this.coordinator.isLeader;\n }\n\n get tabRole(): TabRole {\n return this.coordinator.isLeader ? 'leader' : 'follower';\n }\n\n /** Whether the user is authenticated via runtime auth. */\n get isAuthenticated(): boolean {\n return this._isAuthenticated;\n }\n\n /** Whether this tab is currently visible/focused. */\n get isActive(): boolean {\n return typeof document !== 'undefined' ? !document.hidden : true;\n }\n\n /** Start leader election and connect. */\n async connect(): Promise<void> {\n await this.coordinator.elect();\n }\n\n // ─── Lifecycle Hooks ─────────────────────────────────\n\n /** Called when WebSocket connection opens (broadcast to all tabs). */\n onConnect(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:connect', fn);\n }\n\n /** Called when WebSocket connection closes (broadcast to all tabs). */\n onDisconnect(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:disconnect', fn);\n }\n\n /** Called when WebSocket starts reconnecting (broadcast to all tabs). */\n onReconnecting(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:reconnecting', fn);\n }\n\n /**\n * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.\n * Use this to show a \"Reconnect\" UI affordance (snackbar, banner, modal)\n * so the user can call `ws.reconnect()` to try again.\n *\n * @example\n * ws.onReconnectFailed(() => {\n * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });\n * });\n */\n onReconnectFailed(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:reconnectFailed', fn);\n }\n\n /**\n * Manually trigger a reconnect. Resets the retry counter and attempts a\n * fresh connection. Safe to call from any tab — the leader actually owns\n * the socket, followers route the request via BroadcastChannel.\n *\n * Use after `onReconnectFailed` fires to let the user retry.\n *\n * @example\n * snackbar.action('Reconnect', () => ws.reconnect());\n */\n reconnect(): void {\n if (this.coordinator.isLeader && this.socket) {\n this.socket.reconnect();\n } else {\n this.bus.publish('ws:reconnect', undefined);\n }\n }\n\n /** Called when this tab becomes leader or loses leadership. */\n onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {\n return this.subs.on('$lifecycle:leader', fn as EventHandler);\n }\n\n /** Called on WebSocket or network error (broadcast to all tabs). */\n onError(fn: (error: unknown) => void): Unsubscribe {\n return this.subs.on('$lifecycle:error', fn as EventHandler);\n }\n\n /** Called when this tab becomes visible/focused. */\n onActive(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:active', ((isActive: unknown) => {\n if (isActive === true) fn();\n }) as EventHandler);\n }\n\n /** Called when this tab goes to background/hidden. */\n onInactive(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:active', ((isActive: unknown) => {\n if (isActive === false) fn();\n }) as EventHandler);\n }\n\n /** Called on any visibility change. */\n onVisibilityChange(fn: (isActive: boolean) => void): Unsubscribe {\n return this.subs.on('$lifecycle:active', fn as EventHandler);\n }\n\n // ─── Authentication ──────────────────────────────────\n\n /**\n * Authenticate on an existing connection. Sends auth event to server,\n * syncs auth state across all tabs. Use for login after guest connection.\n *\n * @example\n * const token = await loginApi(email, password);\n * ws.authenticate(token);\n *\n * @example\n * // React — via useSocketAuth hook\n * const { authenticate } = useSocketAuth();\n * authenticate(token);\n */\n authenticate(token: string): void {\n this._isAuthenticated = true;\n this.syncStore.set('$auth:token', token);\n this.bus.broadcast('ws:sync', { key: '$auth:token', value: token });\n this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: true });\n this.log.info('[SharedWS] authenticated');\n\n // If the leader's socket gave up (e.g. auth-failure close code), the new\n // creds should restart the connection. resubscribeOnConnect resends\n // the auth-login frame from syncStore once we're connected again.\n if (this.coordinator.isLeader && this.socket && this.socket.state === 'failed') {\n this.reconnect();\n return;\n }\n\n if (!this.coordinator.isLeader) {\n // Followers can't see leader state — hint to leader to reconnect IFF failed.\n this.bus.publish('ws:authenticate-resume', undefined);\n }\n\n this.dispatch('auth-login', { data: token });\n }\n\n /**\n * Deauthenticate — notifies server, auto-leaves all auth-required channels\n * and topics, syncs state across tabs. Connection stays open for public events.\n *\n * @example\n * ws.deauthenticate(); // connection stays open, auth subscriptions cleaned up\n */\n deauthenticate(): void {\n // Leave auth channels and unsubscribe auth topics\n for (const [, ch] of this.authChannels) ch.leave();\n this.authChannels.clear();\n for (const topic of this.authTopics) this.unsubscribe(topic);\n this.authTopics.clear();\n\n this._isAuthenticated = false;\n this.dispatch('auth-logout', {});\n this.syncStore.delete('$auth:token');\n this.bus.broadcast('ws:sync', { key: '$auth:token', value: undefined });\n this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: false });\n this.log.info('[SharedWS] deauthenticated');\n }\n\n /**\n * Called when auth state changes (authenticate, deauthenticate, or server revocation).\n *\n * @example\n * ws.onAuthChange((authenticated) => {\n * if (!authenticated) router.push('/login');\n * });\n */\n onAuthChange(fn: (authenticated: boolean) => void): Unsubscribe {\n return this.subs.on('$lifecycle:auth', fn as EventHandler);\n }\n\n // ─── Middleware ───────────────────────────────────────\n\n /**\n * Add middleware to transform messages before send or after receive.\n * Return null from middleware to drop the message.\n *\n * @example\n * // Add timestamp to every outgoing message\n * ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));\n *\n * @example\n * // Decrypt incoming messages\n * ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));\n *\n * @example\n * // Drop messages from blocked users\n * ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);\n */\n use(direction: 'outgoing' | 'incoming', fn: Middleware): this {\n if (direction === 'outgoing') {\n this.outgoingMiddleware.push(fn);\n } else {\n this.incomingMiddleware.push(fn);\n }\n return this;\n }\n\n // ─── Per-Event Serialization ─────────────────────────\n\n /**\n * Register a custom serializer for a specific event.\n * The data is transformed before outgoing middleware and global serialize.\n *\n * @example\n * // Binary for file uploads, JSON for everything else\n * ws.serializer('file.upload', (data) => new Blob([data as ArrayBuffer]));\n *\n * @example\n * // Protobuf for specific event\n * ws.serializer('trading.order', (data) => OrderProto.encode(data).finish());\n */\n serializer(event: string, fn: (data: unknown) => unknown): this {\n this.serializers.set(event, fn);\n return this;\n }\n\n /**\n * Register a custom deserializer for a specific event.\n * The data is transformed after global deserialize and before incoming middleware.\n *\n * @example\n * ws.deserializer('file.download', (data) => new Uint8Array(data as ArrayBuffer));\n *\n * @example\n * // Protobuf for specific event\n * ws.deserializer('trading.tick', (data) => TickProto.decode(data as Uint8Array));\n */\n deserializer(event: string, fn: (data: unknown) => unknown): this {\n this.deserializers.set(event, fn);\n return this;\n }\n\n // ─── Event Subscription ──────────────────────────────\n\n /**\n * Subscribe to server events (works in ALL tabs). Type-safe with EventMap.\n *\n * The handler receives `(data, raw)`:\n * - `data` is extracted via `dataField` (default `'data'`)\n * - `raw` is the full deserialized envelope, useful for protocols with extra\n * top-level fields like `id`, `kind`, `channel`, `type`, etc.\n *\n * @example\n * ws.on('msg', (data, raw) => {\n * raw.id; // top-level metadata\n * raw.kind; // discriminator\n * });\n */\n on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;\n on(event: string, handler: EventHandler<unknown>): Unsubscribe;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n on(event: string, handler: (data: any, raw?: unknown) => void): Unsubscribe {\n return this.subs.on(event, handler);\n }\n\n once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;\n once(event: string, handler: EventHandler<unknown>): Unsubscribe;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n once(event: string, handler: (data: any, raw?: unknown) => void): Unsubscribe {\n return this.subs.once(event, handler);\n }\n\n off(event: string, handler?: EventHandler): void {\n this.subs.off(event, handler);\n }\n\n /** Async generator for consuming events. Type-safe with EventMap. */\n stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n return this.subs.stream(event, signal);\n }\n\n /**\n * Send message to server (auto-routed through leader). Type-safe with EventMap.\n *\n * The optional third argument `extras` adds top-level fields to the wire envelope.\n * Use it for protocols that need extra envelope keys like `type`, `channel`, etc.\n *\n * @example\n * // Default shape: { event, data }\n * ws.send('chat.message', { text: 'Hello' });\n * // → { event: 'chat.message', data: { text: 'Hello' } }\n *\n * @example\n * // Pusher/Reverb-style envelope\n * ws.send('group.member_ready',\n * { member_id: 'abc', ready: true },\n * { type: 'event', channel: 'public.group.xxx' },\n * );\n * // → {\n * // type: 'event',\n * // channel: 'public.group.xxx',\n * // event: 'group.member_ready',\n * // data: { member_id: 'abc', ready: true },\n * // }\n */\n send<K extends string & keyof TEvents>(event: K, data: TEvents[K], extras?: Record<string, unknown>): void;\n send(event: string, data: unknown, extras?: Record<string, unknown>): void;\n send(event: string, data: unknown, extras?: Record<string, unknown>): void {\n this.assertExtrasReserved(extras);\n\n // Per-event serializer transforms data before the frame is built\n const eventSerializer = this.serializers.get(event);\n const serializedData = eventSerializer ? eventSerializer(data) : data;\n\n this.dispatch('event', { event, data: serializedData, extras });\n }\n\n private assertExtrasReserved(extras: Record<string, unknown> | undefined): void {\n if (!extras) return;\n if (this.proto.eventField in extras) {\n throw new Error(\n `SharedWebSocket.send: extras cannot contain reserved key \"${this.proto.eventField}\" (eventField). ` +\n `Pass the event name as the first argument instead.`,\n );\n }\n if (this.proto.dataField in extras) {\n throw new Error(\n `SharedWebSocket.send: extras cannot contain reserved key \"${this.proto.dataField}\" (dataField). ` +\n `Pass the payload as the second argument instead.`,\n );\n }\n }\n\n /** Request/response through server via leader. */\n async request<T>(event: string, data: unknown, timeout = 5000): Promise<T> {\n return this.bus.request('ws:request', { event, data }, timeout);\n }\n\n /** Sync state across tabs (no server roundtrip). */\n sync<T>(key: string, value: T): void {\n this.syncStore.set(key, value);\n this.bus.broadcast('ws:sync', { key, value });\n }\n\n getSync<T>(key: string): T | undefined {\n return this.syncStore.get(key) as T | undefined;\n }\n\n onSync<T>(key: string, fn: (value: T) => void): Unsubscribe {\n return this.subs.on(`sync:${key}`, fn as EventHandler);\n }\n\n /**\n * Subscribe to a private/scoped channel. Returns a channel handle with\n * scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe.\n *\n * @example\n * const chat = ws.channel('chat:room_123');\n * chat.on('message', (msg) => render(msg));\n * chat.send('message', { text: 'Hello' });\n * chat.leave(); // sends leave + unsubscribes\n *\n * @example\n * // Private notifications for tenant\n * const notifications = ws.channel(`tenant:${tenantId}:notifications`);\n * notifications.on('alert', (alert) => showToast(alert));\n */\n channel(name: string, options?: { auth?: boolean }): Channel {\n // Set up the ack matcher BEFORE dispatching so we don't miss a fast\n // server response. With no matcher configured, ready resolves\n // synchronously on the next microtask after dispatch.\n const matcher = this.proto.channelAckMatcher;\n const ackTimeout = this.proto.channelAckTimeout ?? 5000;\n let cancelReady: ((reason: Error) => void) | undefined;\n\n const ready = matcher\n ? new Promise<void>((resolve, reject) => {\n let settled = false;\n const settle = (fn: () => void) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n unsubAck();\n fn();\n };\n const unsubAck = this.onRawFrame((frame) => {\n let result: ChannelAckResult;\n try {\n result = matcher(frame, name);\n } catch {\n // matcher exceptions are treated as a hard reject\n result = 'reject';\n }\n if (result === 'ok') settle(() => resolve());\n else if (result === 'reject') settle(() => reject(new Error(`SharedWebSocket: subscribe rejected for channel \"${name}\"`)));\n });\n const timer = setTimeout(\n () => settle(() => reject(new Error(`SharedWebSocket: subscribe ack timeout for channel \"${name}\"`))),\n ackTimeout,\n );\n cancelReady = (err: Error) => settle(() => reject(err));\n })\n : Promise.resolve();\n\n // Avoid noisy unhandled-rejection warnings if the user never awaits ready.\n if (matcher) ready.catch(() => {});\n\n // Notify server about channel subscription\n this.dispatch('subscribe', { channel: name });\n\n // Track this channel for incoming-event prefix routing\n this.channelRefs.set(name, (this.channelRefs.get(name) ?? 0) + 1);\n\n const self = this;\n const unsubs: Unsubscribe[] = [];\n const isAuth = options?.auth ?? false;\n let left = false;\n const key = (event: string) => `${name}${CHANNEL_KEY_SEP}${event}`;\n\n const ch: Channel = {\n name,\n ready,\n on(event: string, handler: EventHandler): Unsubscribe {\n const unsub = self.subs.on(key(event), handler);\n unsubs.push(unsub);\n return unsub;\n },\n once(event: string, handler: EventHandler): Unsubscribe {\n const unsub = self.subs.once(key(event), handler);\n unsubs.push(unsub);\n return unsub;\n },\n send(event: string, data: unknown): void {\n // Channel name is passed structurally so a custom frameBuilder can\n // emit it as a top-level wire field (Pusher/Reverb-style). The\n // default builder joins as `${channel}:${event}` for back-compat.\n // Per-event serializers are keyed on the joined name (legacy).\n const joined = `${name}:${event}`;\n const eventSerializer = self.serializers.get(joined) ?? self.serializers.get(event);\n const serializedData = eventSerializer ? eventSerializer(data) : data;\n self.dispatch('event', { event, data: serializedData, channel: name });\n },\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n return self.subs.stream(key(event), signal);\n },\n leave(): void {\n if (left) return;\n left = true;\n cancelReady?.(new Error(`SharedWebSocket: channel \"${name}\" left before ack`));\n self.dispatch('unsubscribe', { channel: name });\n for (const unsub of unsubs) unsub();\n unsubs.length = 0;\n if (isAuth) self.authChannels.delete(name);\n const next = (self.channelRefs.get(name) ?? 1) - 1;\n if (next <= 0) self.channelRefs.delete(name);\n else self.channelRefs.set(name, next);\n },\n };\n\n if (isAuth) {\n this.authChannels.set(name, ch);\n }\n\n return ch;\n }\n\n // ─── Topics ──────────────────────────────────────────\n\n /**\n * Subscribe to a server-side topic. Server will start sending events for this topic.\n * Sends topicSubscribe event (default: \"$topic:subscribe\").\n *\n * @example\n * ws.subscribe('notifications:orders');\n * ws.subscribe('notifications:payments');\n * ws.subscribe(`user:${userId}:mentions`);\n */\n subscribe(topic: string, options?: { auth?: boolean }): void {\n this.dispatch('topic-subscribe', { topic });\n this.topics.add(topic);\n if (options?.auth) {\n this.authTopics.add(topic);\n }\n this.log.debug('[SharedWS] subscribe topic', topic);\n }\n\n /**\n * Unsubscribe from a server-side topic.\n * Sends topicUnsubscribe event (default: \"$topic:unsubscribe\").\n */\n unsubscribe(topic: string): void {\n this.dispatch('topic-unsubscribe', { topic });\n this.topics.delete(topic);\n this.authTopics.delete(topic);\n this.log.debug('[SharedWS] unsubscribe topic', topic);\n }\n\n // ─── Push Notifications ─────────────────────────────\n\n /**\n * Subscribe to an event and show notifications.\n *\n * **target** controls which tab(s) display the notification:\n * - `'active'` — only the currently visible tab (default for render)\n * - `'leader'` — only the leader tab (default for browser Notification)\n * - `'all'` — every tab (for critical alerts)\n *\n * @example\n * // Custom render — sonner toast on active tab only\n * ws.push('notification', {\n * render: (n) => toast(n.title),\n * target: 'active', // default for render\n * });\n *\n * @example\n * // Critical alert — show in ALL tabs\n * ws.push('payment.failed', {\n * render: (n) => toast.error('Payment failed!'),\n * target: 'all',\n * });\n *\n * @example\n * // Browser Notification — only from leader\n * ws.push('order.created', {\n * title: (order) => `New Order #${order.id}`,\n * target: 'leader', // default for browser Notification\n * });\n *\n * @example\n * // Both render + native with different targets\n * ws.push('order.created', {\n * render: (order) => toast(`Order #${order.id}`), // active tab\n * title: (order) => `New Order #${order.id}`, // leader → native\n * });\n */\n push<T = unknown>(\n event: string,\n config: {\n /** Custom render function — you decide how to display. */\n render?: (data: T) => void;\n /** Title for browser Notification API. */\n title?: string | ((data: T) => string);\n /** Body for browser Notification API. */\n body?: string | ((data: T) => string);\n /** Icon URL for browser Notification. */\n icon?: string;\n /** Tag for browser Notification deduplication. */\n tag?: string | ((data: T) => string);\n /**\n * Which tab(s) show the notification:\n * - `'active'` — only the visible/focused tab (default for render)\n * - `'leader'` — only the leader tab (default for browser Notification)\n * - `'all'` — every tab (critical alerts)\n */\n target?: 'active' | 'leader' | 'all';\n /** Called when browser Notification is clicked. */\n onClick?: (data: T) => void;\n },\n ): Unsubscribe {\n const useNativeNotification = !!config.title;\n\n // Default target: 'active' for render, 'leader' for native\n const renderTarget = config.target ?? 'active';\n const nativeTarget = config.target ?? 'leader';\n\n if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'default') {\n Notification.requestPermission();\n }\n\n return this.on(event, ((data: unknown) => {\n const typed = data as T;\n const isVisible = typeof document !== 'undefined' && !document.hidden;\n const isLeader = this.tabRole === 'leader';\n\n // Custom render\n if (config.render) {\n const shouldRender =\n renderTarget === 'all' ||\n (renderTarget === 'active' && isVisible) ||\n (renderTarget === 'leader' && isLeader);\n\n if (shouldRender) {\n config.render(typed);\n this.log.debug('[SharedWS] 🔔 render', event, `(target: ${renderTarget})`);\n }\n }\n\n // Browser Notification API\n if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'granted') {\n const shouldNotify =\n nativeTarget === 'all' ||\n (nativeTarget === 'leader' && isLeader) ||\n (nativeTarget === 'active' && isVisible);\n\n // Native notifications make sense when tab is hidden\n if (shouldNotify && !isVisible) {\n const title = typeof config.title === 'function' ? config.title(typed) : config.title!;\n const body = typeof config.body === 'function' ? config.body(typed) : config.body;\n const tag = typeof config.tag === 'function' ? config.tag(typed) : config.tag;\n\n const notif = new Notification(title, { body, icon: config.icon, tag });\n\n if (config.onClick) {\n const handler = config.onClick;\n notif.onclick = () => {\n handler(typed);\n window.focus();\n };\n }\n\n this.log.debug('[SharedWS] 🔔 native', title, `(target: ${nativeTarget})`);\n }\n }\n }) as EventHandler);\n }\n\n disconnect(): void {\n this[Symbol.dispose]();\n }\n\n // ─── Frame Pipeline ─────────────────────────────────\n //\n // dispatch(kind, payload) is the single entry point for all outgoing\n // frames (events, channel join/leave, topic sub/unsub, auth login/logout).\n // - On the leader, it calls transmit() which builds the frame, runs\n // outgoing middleware, and writes to the socket.\n // - On followers, it forwards { kind, payload } over BroadcastChannel;\n // the leader's bus subscriber re-enters transmit() so middleware\n // runs in exactly one place regardless of which tab originated.\n //\n // The actual wire shape is decided by frameBuilder (custom) or\n // defaultFrameBuilder (legacy two-key { event, data } envelope).\n\n /**\n * Build the wire frame for a given kind. Honors custom `frameBuilder`.\n * Return-value contract:\n * - any concrete value → use as the frame\n * - `null` → drop the frame (intentional filter)\n * - `undefined` → fall back to the default builder for this kind\n */\n private buildFrame(kind: FrameKind, payload: FramePayload): unknown {\n if (this.proto.frameBuilder) {\n const result = this.proto.frameBuilder(kind, payload);\n if (result !== undefined) return result;\n // undefined → fall through to default for this kind\n }\n return this.defaultFrameBuilder(kind, payload);\n }\n\n /**\n * Subscribe to every raw incoming frame (post-deserialize). Used by\n * `Channel.ready`'s ack matcher. Internal — not part of the public API.\n */\n private onRawFrame(fn: (raw: unknown) => void): Unsubscribe {\n this.rawFrameListeners.add(fn);\n return () => { this.rawFrameListeners.delete(fn); };\n }\n\n /** Legacy two-key builder — preserved as the default for back-compat. */\n private defaultFrameBuilder(kind: FrameKind, p: FramePayload): unknown {\n let eventName: string;\n let dataPart: unknown;\n\n switch (kind) {\n case 'event':\n // Channel-scoped events join with `:` for wire compat (Pusher convention).\n eventName = p.channel ? `${p.channel}:${p.event ?? ''}` : (p.event ?? this.proto.defaultEvent);\n dataPart = p.data;\n break;\n case 'subscribe':\n eventName = this.proto.channelJoin;\n dataPart = { channel: p.channel };\n break;\n case 'unsubscribe':\n eventName = this.proto.channelLeave;\n dataPart = { channel: p.channel };\n break;\n case 'topic-subscribe':\n eventName = this.proto.topicSubscribe;\n dataPart = { topic: p.topic };\n break;\n case 'topic-unsubscribe':\n eventName = this.proto.topicUnsubscribe;\n dataPart = { topic: p.topic };\n break;\n case 'auth-login':\n eventName = this.proto.authLogin;\n dataPart = { token: p.data };\n break;\n case 'auth-logout':\n eventName = this.proto.authLogout;\n dataPart = {};\n break;\n }\n\n return {\n ...(p.extras ?? {}),\n [this.proto.eventField]: eventName,\n [this.proto.dataField]: dataPart,\n };\n }\n\n /** Route a structured frame: leader transmits, followers forward via bus. */\n private dispatch(kind: FrameKind, payload: FramePayload): void {\n if (this.coordinator.isLeader && this.socket) {\n this.transmit(kind, payload);\n return;\n }\n // Follower path — route to the leader's socket via the bus.\n const id = generateId();\n // Only `event` dispatches need the local replay buffer. Channel /\n // topic / auth frames are already tracked (channelRefs, topicRefs,\n // the auth token) and re-sent by resubscribeOnConnect() on the next\n // connect — buffering them here too makes onConnected() replay them\n // twice, emitting a duplicate subscribe frame.\n if (kind === 'event') {\n this.enqueuePending(id, kind, payload);\n }\n this.bus.publish('ws:dispatch', { id, kind, payload });\n }\n\n private enqueuePending(id: string, kind: FrameKind, payload: FramePayload): void {\n const max = this.options.outboundBufferSize ?? 100;\n if (max <= 0) return;\n if (this.pendingOutbound.size >= max) {\n // Drop oldest — Map iteration order = insertion order.\n const oldestKey = this.pendingOutbound.keys().next().value;\n if (oldestKey !== undefined) this.pendingOutbound.delete(oldestKey);\n }\n this.pendingOutbound.set(id, { id, kind, payload, enqueuedAt: Date.now() });\n }\n\n /** Build, run middleware, and write to the socket. Leader-only. */\n private transmit(kind: FrameKind, payload: FramePayload): void {\n if (!this.socket) return;\n let frame: unknown = this.buildFrame(kind, payload);\n if (frame === null) {\n this.log.debug('[SharedWS] ✗ frameBuilder dropped frame', kind, this.frameLabel(kind, payload));\n return;\n }\n for (const mw of this.outgoingMiddleware) {\n frame = mw(frame);\n if (frame === null) {\n this.log.debug('[SharedWS] ✗ outgoing dropped by middleware', kind, this.frameLabel(kind, payload));\n return;\n }\n }\n // Auth frames carry a token — never log payload or wire frame.\n if (kind === 'auth-login') {\n this.log.debug('[SharedWS] → send', kind, '(token redacted)');\n } else {\n this.log.debug('[SharedWS] → send', kind, this.frameLabel(kind, payload), { payload, frame });\n }\n this.socket.send(frame);\n }\n\n /**\n * Human-readable headline for log lines — picks the most relevant field\n * out of the structured payload so log scanners aren't reading objects:\n * - event → event name\n * - subscribe → channel\n * - topic-* → topic\n * - auth-* → '(redacted)' / ''\n */\n private frameLabel(kind: FrameKind, p: FramePayload): string {\n switch (kind) {\n case 'event': return p.event ?? '?';\n case 'subscribe':\n case 'unsubscribe': return p.channel ?? '?';\n case 'topic-subscribe':\n case 'topic-unsubscribe': return p.topic ?? '?';\n case 'auth-login': return '(redacted)';\n case 'auth-logout': return '';\n }\n }\n\n private createSocket(): SocketAdapter {\n const socketOptions = {\n protocols: this.options.protocols,\n reconnect: this.options.reconnect,\n reconnectMaxDelay: this.options.reconnectMaxDelay,\n reconnectMaxRetries: this.options.reconnectMaxRetries,\n authFailureCloseCodes: this.options.authFailureCloseCodes,\n heartbeatInterval: this.options.heartbeatInterval,\n sendBuffer: this.options.sendBuffer,\n pingPayload: this.proto.ping,\n };\n\n if (this.options.useWorker) {\n // WebSocket runs in a Web Worker — main thread stays free\n return new WorkerSocket(this.url, {\n ...socketOptions,\n workerUrl: this.options.workerUrl,\n auth: this.options.auth,\n authToken: this.options.authToken,\n authParam: this.options.authParam,\n });\n }\n\n // WebSocket runs in main thread (default)\n return new SharedSocket(this.url, {\n ...socketOptions,\n auth: this.options.auth,\n authToken: this.options.authToken,\n authParam: this.options.authParam,\n serialize: this.options.serialize,\n deserialize: this.options.deserialize,\n });\n }\n\n private handleBecomeLeader(): void {\n this.log.info('[SharedWS] 👑 became leader');\n this.socket = this.createSocket();\n this.startRefreshTimer();\n\n this.socket.onMessage((raw: unknown) => {\n let data: unknown = raw;\n for (const mw of this.incomingMiddleware) {\n data = mw(data);\n if (data === null) {\n this.log.debug('[SharedWS] ✗ incoming dropped by middleware', { raw });\n return;\n }\n }\n\n const msg = data as Record<string, unknown> | null | undefined;\n const event = (msg?.[this.proto.eventField] as string) ?? this.proto.defaultEvent;\n let payload = msg?.[this.proto.dataField] ?? data;\n\n // Per-event deserializer transforms data after global deserialize\n const eventDeserializer = this.deserializers.get(event);\n if (eventDeserializer) {\n payload = eventDeserializer(payload);\n }\n\n this.log.debug('[SharedWS] ← recv', event, { data: payload, raw: data });\n this.bus.broadcast('ws:message', { event, data: payload, raw: data });\n });\n\n this.socket.onStateChange((state: string) => {\n this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : state === 'failed' ? '✗ reconnect failed' : `state: ${state}`);\n switch (state) {\n case 'connected':\n this.bus.broadcast('ws:lifecycle', { type: 'connect' });\n void this.onConnected();\n break;\n case 'closed':\n this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });\n break;\n case 'reconnecting':\n this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });\n break;\n case 'failed':\n this.bus.broadcast('ws:lifecycle', { type: 'reconnectFailed' });\n this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });\n break;\n }\n });\n\n this.cleanups.push(\n this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {\n return new Promise((resolve) => {\n const unsub = this.socket!.onMessage((response: unknown) => {\n const res = response as Record<string, unknown> | undefined;\n if (res?.[this.proto.eventField] === req.event || res?.requestId) {\n unsub();\n resolve(res?.[this.proto.dataField] ?? response);\n }\n });\n this.transmit('event', { event: req.event, data: req.data });\n });\n }),\n );\n\n void this.socket.connect();\n }\n\n /**\n * Re-establish all server-side state on the freshly connected leader socket:\n * 1. auth-login (so server accepts subsequent joins on auth channels)\n * 2. channel-join for the union of channels held by ALL surviving tabs\n * 3. topic-subscribe for the union of topics held by ALL surviving tabs\n *\n * The union covers leader handover: when a follower with handlers is\n * promoted, no tab's subscriptions get silently dropped. Frames are sent\n * in FIFO order over the single WebSocket, so auth precedes the joins\n * that depend on it.\n */\n /**\n * Orchestrate post-connect recovery: replay subscriptions first (so the\n * server is ready to route events for any channels we still care about),\n * then drain follower-pending dispatches that didn't reach the previous\n * leader's socket.\n */\n private async onConnected(): Promise<void> {\n await this.resubscribeOnConnect();\n await this.replayPendingDispatches();\n }\n\n private async resubscribeOnConnect(): Promise<void> {\n if (!this.socket) return;\n const socket = this.socket;\n\n // 1. Re-authenticate first so subsequent auth-channel joins succeed.\n if (this._isAuthenticated) {\n const token = this.syncStore.get('$auth:token') as string | undefined;\n if (token) {\n this.transmit('auth-login', { data: token });\n this.log.debug('[SharedWS] re-authenticated after reconnect');\n }\n }\n\n // 2/3. Gather subscriptions from all surviving tabs (including self).\n const { channels, topics } = await this.gatherSubscriptions();\n if (this.socket !== socket) return; // socket replaced while we were waiting\n\n for (const name of channels) {\n this.transmit('subscribe', { channel: name });\n }\n for (const topic of topics) {\n this.transmit('topic-subscribe', { topic });\n }\n\n if (channels.length || topics.length) {\n this.log.info('[SharedWS] replayed subscriptions', {\n channels: channels.length,\n topics: topics.length,\n });\n }\n }\n\n /**\n * Replay buffered follower dispatches over the freshly connected socket.\n * Gathers from all tabs (including this one), de-dups by id, transmits,\n * then signals each originator to drop its local entry. Drops own-tab\n * entries after transmission since `bus.publish` doesn't echo to self.\n */\n private async replayPendingDispatches(): Promise<void> {\n if (!this.socket) return;\n const socket = this.socket;\n const entries = await this.gatherPendingDispatches();\n if (this.socket !== socket) return; // socket replaced while waiting\n if (entries.length === 0) return;\n\n let sent = 0;\n for (const e of entries) {\n this.transmit(e.kind, e.payload);\n // Remove from own pending (publish doesn't echo to self) and tell\n // any other tab that originated the same id to drop it as well.\n this.pendingOutbound.delete(e.id);\n this.bus.publish('ws:dispatch-flushed', { id: e.id });\n sent++;\n }\n this.log.info('[SharedWS] replayed pending dispatches', { count: sent });\n }\n\n /**\n * Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`\n * — broadcasts a one-shot request, collects for a short window, dedups\n * by id (so multiple tabs holding the same id don't double-replay).\n */\n private gatherPendingDispatches(timeoutMs = 100): Promise<Array<{ id: string; kind: FrameKind; payload: FramePayload }>> {\n const seen = new Map<string, { id: string; kind: FrameKind; payload: FramePayload }>();\n for (const e of this.pendingOutbound.values()) {\n seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });\n }\n const replyId = generateId();\n\n return new Promise((resolve) => {\n const unsub = this.bus.subscribe<{ entries: Array<{ id: string; kind: FrameKind; payload: FramePayload; enqueuedAt: number }> }>(\n `ws:pending:${replyId}`,\n (msg) => {\n for (const e of msg.entries) {\n if (!seen.has(e.id)) seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });\n }\n },\n );\n this.bus.publish('ws:gather-pending', { replyId });\n setTimeout(() => {\n unsub();\n resolve([...seen.values()]);\n }, timeoutMs);\n });\n }\n\n /**\n * Best-effort cross-tab gather. Broadcasts a request and collects responses\n * for a short window. Times out gracefully — late responses are dropped.\n * The leader's own subs are seeded into the result to avoid relying on\n * BroadcastChannel echo to self.\n */\n private gatherSubscriptions(timeoutMs = 150): Promise<{ channels: string[]; topics: string[] }> {\n const channels = new Set<string>(this.channelRefs.keys());\n const topics = new Set<string>(this.topics);\n const replyId = generateId();\n\n return new Promise((resolve) => {\n const unsub = this.bus.subscribe<{ channels: string[]; topics: string[] }>(\n `ws:subs:${replyId}`,\n (msg) => {\n for (const c of msg.channels) channels.add(c);\n for (const t of msg.topics) topics.add(t);\n },\n );\n\n this.bus.publish('ws:gather-subs', { replyId });\n\n setTimeout(() => {\n unsub();\n resolve({ channels: [...channels], topics: [...topics] });\n }, timeoutMs);\n });\n }\n\n private handleLoseLeadership(): void {\n this.stopRefreshTimer();\n if (this.socket) {\n this.socket[Symbol.dispose]();\n this.socket = null;\n }\n }\n\n /**\n * Start a leader-only periodic refresh of the auth token. The callback\n * is `options.refresh` (preferred) or `options.auth` (fallback). When\n * the timer fires and the connection is currently authenticated, the\n * returned token is fed back through `authenticate()` so subscribers\n * stay synced and the leader's socket re-issues auth-login.\n *\n * Idempotent — calling start while already running is a no-op.\n */\n private startRefreshTimer(): void {\n if (this.refreshTimer) return;\n const interval = this.options.refreshTokenInterval;\n const refresh = this.options.refresh ?? this.options.auth;\n if (!interval || interval <= 0 || !refresh) return;\n if (!this.coordinator.isLeader) return;\n\n this.refreshTimer = setInterval(async () => {\n if (!this.coordinator.isLeader || !this._isAuthenticated) return;\n try {\n const token = await refresh();\n if (!token) {\n this.log.warn('[SharedWS] refresh() returned empty token — skipping');\n return;\n }\n this.authenticate(token);\n } catch (err) {\n this.log.warn('[SharedWS] refresh() failed', err);\n }\n }, interval);\n }\n\n private stopRefreshTimer(): void {\n if (this.refreshTimer) {\n clearInterval(this.refreshTimer);\n this.refreshTimer = null;\n }\n }\n\n [Symbol.dispose](): void {\n if (this.disposed) return;\n this.disposed = true;\n this.stopRefreshTimer();\n\n this.coordinator[Symbol.dispose]();\n\n if (this.socket) {\n this.socket[Symbol.dispose]();\n this.socket = null;\n }\n\n for (const unsub of this.cleanups) unsub();\n this.cleanups = [];\n this.subs[Symbol.dispose]();\n this.bus[Symbol.dispose]();\n this.syncStore.clear();\n this.authChannels.clear();\n this.authTopics.clear();\n this.channelRefs.clear();\n this.topics.clear();\n this.rawFrameListeners.clear();\n this.pendingOutbound.clear();\n }\n}\n"]}
@@ -21,7 +21,9 @@ var MessageBus = class {
21
21
  pendingRequests = /* @__PURE__ */ new Map();
22
22
  subscribe(topic, fn) {
23
23
  const wrapper = (msg) => {
24
- if (msg.source !== this.tabId) fn(msg.data);
24
+ if (msg.type !== "publish" || msg.source !== this.tabId) {
25
+ fn(msg.data);
26
+ }
25
27
  };
26
28
  this.addListener(topic, wrapper);
27
29
  return () => this.removeListener(topic, wrapper);
@@ -1425,7 +1427,9 @@ var SharedWebSocket = class {
1425
1427
  return;
1426
1428
  }
1427
1429
  const id = generateId();
1428
- this.enqueuePending(id, kind, payload);
1430
+ if (kind === "event") {
1431
+ this.enqueuePending(id, kind, payload);
1432
+ }
1429
1433
  this.bus.publish("ws:dispatch", { id, kind, payload });
1430
1434
  }
1431
1435
  enqueuePending(id, kind, payload) {
@@ -1442,19 +1446,47 @@ var SharedWebSocket = class {
1442
1446
  if (!this.socket) return;
1443
1447
  let frame = this.buildFrame(kind, payload);
1444
1448
  if (frame === null) {
1445
- this.log.debug("[SharedWS] \u2717 frameBuilder dropped frame", kind);
1449
+ this.log.debug("[SharedWS] \u2717 frameBuilder dropped frame", kind, this.frameLabel(kind, payload));
1446
1450
  return;
1447
1451
  }
1448
1452
  for (const mw of this.outgoingMiddleware) {
1449
1453
  frame = mw(frame);
1450
1454
  if (frame === null) {
1451
- this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", kind);
1455
+ this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", kind, this.frameLabel(kind, payload));
1452
1456
  return;
1453
1457
  }
1454
1458
  }
1455
- this.log.debug("[SharedWS] \u2192 send", kind, payload);
1459
+ if (kind === "auth-login") {
1460
+ this.log.debug("[SharedWS] \u2192 send", kind, "(token redacted)");
1461
+ } else {
1462
+ this.log.debug("[SharedWS] \u2192 send", kind, this.frameLabel(kind, payload), { payload, frame });
1463
+ }
1456
1464
  this.socket.send(frame);
1457
1465
  }
1466
+ /**
1467
+ * Human-readable headline for log lines — picks the most relevant field
1468
+ * out of the structured payload so log scanners aren't reading objects:
1469
+ * - event → event name
1470
+ * - subscribe → channel
1471
+ * - topic-* → topic
1472
+ * - auth-* → '(redacted)' / ''
1473
+ */
1474
+ frameLabel(kind, p) {
1475
+ switch (kind) {
1476
+ case "event":
1477
+ return p.event ?? "?";
1478
+ case "subscribe":
1479
+ case "unsubscribe":
1480
+ return p.channel ?? "?";
1481
+ case "topic-subscribe":
1482
+ case "topic-unsubscribe":
1483
+ return p.topic ?? "?";
1484
+ case "auth-login":
1485
+ return "(redacted)";
1486
+ case "auth-logout":
1487
+ return "";
1488
+ }
1489
+ }
1458
1490
  createSocket() {
1459
1491
  const socketOptions = {
1460
1492
  protocols: this.options.protocols,
@@ -1493,7 +1525,7 @@ var SharedWebSocket = class {
1493
1525
  for (const mw of this.incomingMiddleware) {
1494
1526
  data = mw(data);
1495
1527
  if (data === null) {
1496
- this.log.debug("[SharedWS] \u2717 incoming dropped by middleware");
1528
+ this.log.debug("[SharedWS] \u2717 incoming dropped by middleware", { raw });
1497
1529
  return;
1498
1530
  }
1499
1531
  }
@@ -1504,7 +1536,7 @@ var SharedWebSocket = class {
1504
1536
  if (eventDeserializer) {
1505
1537
  payload = eventDeserializer(payload);
1506
1538
  }
1507
- this.log.debug("[SharedWS] \u2190 recv", event, payload);
1539
+ this.log.debug("[SharedWS] \u2190 recv", event, { data: payload, raw: data });
1508
1540
  this.bus.broadcast("ws:message", { event, data: payload, raw: data });
1509
1541
  });
1510
1542
  this.socket.onStateChange((state) => {
@@ -1734,4 +1766,4 @@ export {
1734
1766
  SubscriptionManager,
1735
1767
  SharedWebSocket
1736
1768
  };
1737
- //# sourceMappingURL=chunk-OQMJRH6C.js.map
1769
+ //# sourceMappingURL=chunk-W35A73NR.js.map