@gwakko/shared-websocket 0.12.3 → 0.14.3

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.
package/README.md CHANGED
@@ -16,6 +16,7 @@ Share **one** WebSocket connection across all browser tabs. Leader election via
16
16
  - [Processing Pipeline](#processing-pipeline)
17
17
  - [Options](#options)
18
18
  - [Documentation](#documentation)
19
+ - [Server Compatibility](#server-compatibility)
19
20
  - [Browser Support](#browser-support)
20
21
 
21
22
  ## How It Works
@@ -210,7 +211,8 @@ usePush('notification', {
210
211
  | **Custom Serialization** | `serialize`/`deserialize` — JSON, MessagePack, Protobuf |
211
212
  | **Per-Event Serializers** | `ws.serializer(event, fn)` — binary for specific events |
212
213
  | **Runtime Auth** | `authenticate(token)` / `deauthenticate()` on existing connection |
213
- | **Lifecycle Hooks** | onConnect, onDisconnect, onActive, onInactive, onLeaderChange, onAuthChange |
214
+ | **Lifecycle Hooks** | onConnect, onDisconnect, onReconnecting, onReconnectFailed, onActive, onInactive, onLeaderChange, onAuthChange |
215
+ | **Manual Reconnect** | `ws.reconnect()` resets retry counter — pair with `onReconnectFailed` for a "Reconnect" snackbar |
214
216
  | **Debug/Logger** | `debug: true` + injectable logger (pino, Sentry) |
215
217
  | **Event Protocol** | Configurable field names (Socket.IO, Phoenix, Laravel Echo) |
216
218
  | **Auth** | URL param (`auth` callback / `authToken`) + runtime `authenticate()`/`deauthenticate()` |
@@ -265,6 +267,25 @@ Incoming: WebSocket.onmessage
265
267
  | **[Server Guide](docs/server-guide.md)** | Node.js, Go, PHP examples + system events |
266
268
  | **[Types](docs/types.md)** | All exported types with import examples |
267
269
 
270
+ ## Server Compatibility
271
+
272
+ | Server | Status | Configuration |
273
+ |---|---|---|
274
+ | **Pusher / Soketi / Reverb** | ✅ Default + small overrides | `events: { channelJoin: 'pusher:subscribe', channelLeave: 'pusher:unsubscribe' }` |
275
+ | **Custom 2-key `{ event, data }` server** | ✅ Default | none |
276
+ | **Custom flat-fields server** (`{ type, channel, event, data }`) | ✅ via `frameBuilder` | [sample](docs/configuration.md#flat-fields-server-eg-custom-go-or-rust-ws) |
277
+ | **Phoenix Channels** (Elixir) | ⚠️ Structural sample provided — verify against your phoenix client version | [sample](docs/configuration.md#phoenix-channels) |
278
+ | **ActionCable** (Rails) | ⚠️ Subscribe/event sample; auth typically via session cookie | [sample](docs/configuration.md#actioncable-rails) |
279
+ | **Centrifugo / GraphQL-over-WS / proprietary binary** | ⚠️ Use `frameBuilder` + custom `serialize`/`deserialize` | Hand-rolled — see [Custom Serialization](docs/configuration.md#custom-serialization) |
280
+
281
+ The two-key default `{ [eventField]: <event>, [dataField]: <data> }`
282
+ covers the common case. Anything else — extra top-level fields,
283
+ array-form frames, custom control-frame discriminators — is handled
284
+ by the `frameBuilder` hook (`events.frameBuilder` in
285
+ `SharedWebSocketOptions`). Subscribe-acks are handled by
286
+ `channelAckMatcher` so `await channel.ready` rejects on authz failures
287
+ instead of silently never receiving events.
288
+
268
289
  ## Browser Support
269
290
 
270
291
  | API | Chrome | Firefox | Safari | Edge |
@@ -273,6 +294,23 @@ Incoming: WebSocket.onmessage
273
294
  | Web Worker | ✅ | ✅ | ✅ | ✅ |
274
295
  | AsyncGenerator | 63+ | 57+ | 12+ | 79+ |
275
296
 
297
+ **`BroadcastChannel` is required and has no fallback.** The library
298
+ constructs one synchronously in `new SharedWebSocket(...)`, so an
299
+ unsupported environment throws `ReferenceError: BroadcastChannel is
300
+ not defined`. Practical implications:
301
+
302
+ - **iOS Safari** — fully works on 15.4+ (March 2022). Older iOS will
303
+ throw; gate construction behind a feature check if you ship to those
304
+ versions.
305
+ - **Some Android webviews / older WKWebView** — same caveat.
306
+ - **Node / SSR** — no `BroadcastChannel`. Construct the socket inside
307
+ `useEffect` (React) / `onMounted` (Vue), or behind a `typeof window
308
+ !== 'undefined'` guard. Don't instantiate in module scope on the
309
+ server.
310
+ - **Tests (jsdom)** — recent jsdom (>= 22) implements
311
+ `BroadcastChannel`. Older jsdom or `happy-dom` may need a polyfill
312
+ or a stub.
313
+
276
314
  ## License
277
315
 
278
316
  MIT
@@ -6,6 +6,8 @@ interface SharedSocketOptions {
6
6
  reconnectMaxDelay?: number;
7
7
  /** Max reconnect attempts before giving up (default: Infinity). */
8
8
  reconnectMaxRetries?: number;
9
+ /** Close codes that mean "auth failed — stop reconnect." Default: [1008]. */
10
+ authFailureCloseCodes?: number[];
9
11
  heartbeatInterval?: number;
10
12
  sendBuffer?: number;
11
13
  auth?: () => string | Promise<string>;
@@ -37,7 +39,14 @@ export declare class SharedSocket implements Disposable {
37
39
  send(data: unknown): void;
38
40
  onMessage(fn: EventHandler): Unsubscribe;
39
41
  onStateChange(fn: (state: SocketState) => void): Unsubscribe;
40
- private reconnect;
42
+ /**
43
+ * Manually trigger a reconnect. Resets the retry counter and clears any
44
+ * scheduled backoff so the next attempt happens immediately. Use after
45
+ * `state === 'failed'` to let the user retry, or any time to force a
46
+ * fresh connection.
47
+ */
48
+ reconnect(): void;
49
+ private scheduleReconnect;
41
50
  private flushBuffer;
42
51
  private startHeartbeat;
43
52
  private stopHeartbeat;
@@ -34,6 +34,27 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
34
34
  private _isAuthenticated;
35
35
  private authChannels;
36
36
  private authTopics;
37
+ /**
38
+ * Refcount of active channel subscriptions per name. Used to route
39
+ * incoming events back to channel handlers via `${name}<RS>${event}`
40
+ * keys without colliding when names/events contain `:`, and as the
41
+ * source for cross-tab subscription replay on leader change.
42
+ */
43
+ private channelRefs;
44
+ /** All topic subscriptions (auth and non-auth). Replayed on leader change. */
45
+ private topics;
46
+ /** Listeners for every raw incoming frame (post-deserialize, post-middleware). */
47
+ private rawFrameListeners;
48
+ /**
49
+ * Local outbound buffer of follower-originated dispatches awaiting flush
50
+ * confirmation from the leader. Drained when the leader broadcasts
51
+ * `ws:dispatch-flushed` for the entry's id; replayed by the next leader
52
+ * after gathering across surviving tabs. Insertion order preserved
53
+ * (Map) so we drop oldest on overflow.
54
+ */
55
+ private pendingOutbound;
56
+ /** Periodic refresh timer — leader only. Recreated on each leader handover. */
57
+ private refreshTimer;
37
58
  constructor(url: string, options?: SharedWebSocketOptions<TEvents>);
38
59
  get connected(): boolean;
39
60
  get tabRole(): TabRole;
@@ -49,6 +70,28 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
49
70
  onDisconnect(fn: () => void): Unsubscribe;
50
71
  /** Called when WebSocket starts reconnecting (broadcast to all tabs). */
51
72
  onReconnecting(fn: () => void): Unsubscribe;
73
+ /**
74
+ * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
75
+ * Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
76
+ * so the user can call `ws.reconnect()` to try again.
77
+ *
78
+ * @example
79
+ * ws.onReconnectFailed(() => {
80
+ * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
81
+ * });
82
+ */
83
+ onReconnectFailed(fn: () => void): Unsubscribe;
84
+ /**
85
+ * Manually trigger a reconnect. Resets the retry counter and attempts a
86
+ * fresh connection. Safe to call from any tab — the leader actually owns
87
+ * the socket, followers route the request via BroadcastChannel.
88
+ *
89
+ * Use after `onReconnectFailed` fires to let the user retry.
90
+ *
91
+ * @example
92
+ * snackbar.action('Reconnect', () => ws.reconnect());
93
+ */
94
+ reconnect(): void;
52
95
  /** Called when this tab becomes leader or loses leadership. */
53
96
  onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe;
54
97
  /** Called on WebSocket or network error (broadcast to all tabs). */
@@ -132,7 +175,20 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
132
175
  * ws.deserializer('trading.tick', (data) => TickProto.decode(data as Uint8Array));
133
176
  */
134
177
  deserializer(event: string, fn: (data: unknown) => unknown): this;
135
- /** Subscribe to server events (works in ALL tabs). Type-safe with EventMap. */
178
+ /**
179
+ * Subscribe to server events (works in ALL tabs). Type-safe with EventMap.
180
+ *
181
+ * The handler receives `(data, raw)`:
182
+ * - `data` is extracted via `dataField` (default `'data'`)
183
+ * - `raw` is the full deserialized envelope, useful for protocols with extra
184
+ * top-level fields like `id`, `kind`, `channel`, `type`, etc.
185
+ *
186
+ * @example
187
+ * ws.on('msg', (data, raw) => {
188
+ * raw.id; // top-level metadata
189
+ * raw.kind; // discriminator
190
+ * });
191
+ */
136
192
  on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
137
193
  on(event: string, handler: EventHandler<unknown>): Unsubscribe;
138
194
  once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
@@ -141,9 +197,33 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
141
197
  /** Async generator for consuming events. Type-safe with EventMap. */
142
198
  stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;
143
199
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
144
- /** Send message to server (auto-routed through leader). Type-safe with EventMap. */
145
- send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
146
- send(event: string, data: unknown): void;
200
+ /**
201
+ * Send message to server (auto-routed through leader). Type-safe with EventMap.
202
+ *
203
+ * The optional third argument `extras` adds top-level fields to the wire envelope.
204
+ * Use it for protocols that need extra envelope keys like `type`, `channel`, etc.
205
+ *
206
+ * @example
207
+ * // Default shape: { event, data }
208
+ * ws.send('chat.message', { text: 'Hello' });
209
+ * // → { event: 'chat.message', data: { text: 'Hello' } }
210
+ *
211
+ * @example
212
+ * // Pusher/Reverb-style envelope
213
+ * ws.send('group.member_ready',
214
+ * { member_id: 'abc', ready: true },
215
+ * { type: 'event', channel: 'public.group.xxx' },
216
+ * );
217
+ * // → {
218
+ * // type: 'event',
219
+ * // channel: 'public.group.xxx',
220
+ * // event: 'group.member_ready',
221
+ * // data: { member_id: 'abc', ready: true },
222
+ * // }
223
+ */
224
+ send<K extends string & keyof TEvents>(event: K, data: TEvents[K], extras?: Record<string, unknown>): void;
225
+ send(event: string, data: unknown, extras?: Record<string, unknown>): void;
226
+ private assertExtrasReserved;
147
227
  /** Request/response through server via leader. */
148
228
  request<T>(event: string, data: unknown, timeout?: number): Promise<T>;
149
229
  /** Sync state across tabs (no server roundtrip). */
@@ -243,9 +323,78 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
243
323
  onClick?: (data: T) => void;
244
324
  }): Unsubscribe;
245
325
  disconnect(): void;
326
+ /**
327
+ * Build the wire frame for a given kind. Honors custom `frameBuilder`.
328
+ * Return-value contract:
329
+ * - any concrete value → use as the frame
330
+ * - `null` → drop the frame (intentional filter)
331
+ * - `undefined` → fall back to the default builder for this kind
332
+ */
333
+ private buildFrame;
334
+ /**
335
+ * Subscribe to every raw incoming frame (post-deserialize). Used by
336
+ * `Channel.ready`'s ack matcher. Internal — not part of the public API.
337
+ */
338
+ private onRawFrame;
339
+ /** Legacy two-key builder — preserved as the default for back-compat. */
340
+ private defaultFrameBuilder;
341
+ /** Route a structured frame: leader transmits, followers forward via bus. */
342
+ private dispatch;
343
+ private enqueuePending;
344
+ /** Build, run middleware, and write to the socket. Leader-only. */
345
+ private transmit;
246
346
  private createSocket;
247
347
  private handleBecomeLeader;
248
- private reAuthenticateOnReconnect;
348
+ /**
349
+ * Re-establish all server-side state on the freshly connected leader socket:
350
+ * 1. auth-login (so server accepts subsequent joins on auth channels)
351
+ * 2. channel-join for the union of channels held by ALL surviving tabs
352
+ * 3. topic-subscribe for the union of topics held by ALL surviving tabs
353
+ *
354
+ * The union covers leader handover: when a follower with handlers is
355
+ * promoted, no tab's subscriptions get silently dropped. Frames are sent
356
+ * in FIFO order over the single WebSocket, so auth precedes the joins
357
+ * that depend on it.
358
+ */
359
+ /**
360
+ * Orchestrate post-connect recovery: replay subscriptions first (so the
361
+ * server is ready to route events for any channels we still care about),
362
+ * then drain follower-pending dispatches that didn't reach the previous
363
+ * leader's socket.
364
+ */
365
+ private onConnected;
366
+ private resubscribeOnConnect;
367
+ /**
368
+ * Replay buffered follower dispatches over the freshly connected socket.
369
+ * Gathers from all tabs (including this one), de-dups by id, transmits,
370
+ * then signals each originator to drop its local entry. Drops own-tab
371
+ * entries after transmission since `bus.publish` doesn't echo to self.
372
+ */
373
+ private replayPendingDispatches;
374
+ /**
375
+ * Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`
376
+ * — broadcasts a one-shot request, collects for a short window, dedups
377
+ * by id (so multiple tabs holding the same id don't double-replay).
378
+ */
379
+ private gatherPendingDispatches;
380
+ /**
381
+ * Best-effort cross-tab gather. Broadcasts a request and collects responses
382
+ * for a short window. Times out gracefully — late responses are dropped.
383
+ * The leader's own subs are seeded into the result to avoid relying on
384
+ * BroadcastChannel echo to self.
385
+ */
386
+ private gatherSubscriptions;
249
387
  private handleLoseLeadership;
388
+ /**
389
+ * Start a leader-only periodic refresh of the auth token. The callback
390
+ * is `options.refresh` (preferred) or `options.auth` (fallback). When
391
+ * the timer fires and the connection is currently authenticated, the
392
+ * returned token is fed back through `authenticate()` so subscribers
393
+ * stay synced and the leader's socket re-issues auth-login.
394
+ *
395
+ * Idempotent — calling start while already running is a no-op.
396
+ */
397
+ private startRefreshTimer;
398
+ private stopRefreshTimer;
250
399
  [Symbol.dispose](): void;
251
400
  }
@@ -6,7 +6,7 @@ export declare class SubscriptionManager implements Disposable {
6
6
  on(event: string, handler: EventHandler): Unsubscribe;
7
7
  once(event: string, handler: EventHandler): Unsubscribe;
8
8
  off(event: string, handler?: EventHandler): void;
9
- emit(event: string, data: unknown): void;
9
+ emit(event: string, data: unknown, raw?: unknown): void;
10
10
  getLastMessage(event: string): unknown | undefined;
11
11
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
12
12
  offAll(): void;
@@ -28,6 +28,7 @@ export declare class WorkerSocket implements Disposable {
28
28
  reconnect?: boolean;
29
29
  reconnectMaxDelay?: number;
30
30
  reconnectMaxRetries?: number;
31
+ authFailureCloseCodes?: number[];
31
32
  heartbeatInterval?: number;
32
33
  sendBuffer?: number;
33
34
  workerUrl?: string | URL;
@@ -37,9 +38,12 @@ export declare class WorkerSocket implements Disposable {
37
38
  pingPayload?: unknown;
38
39
  });
39
40
  get state(): SocketState;
41
+ private setState;
40
42
  connect(): Promise<void>;
41
43
  private buildUrl;
42
44
  send(data: unknown): void;
45
+ /** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
46
+ reconnect(): void;
43
47
  disconnect(): void;
44
48
  onMessage(fn: EventHandler): Unsubscribe;
45
49
  onStateChange(fn: (state: SocketState) => void): Unsubscribe;
@@ -91,7 +91,7 @@ export declare function useSocketAuth(): {
91
91
  * setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50
92
92
  * });
93
93
  */
94
- export declare function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined;
94
+ export declare function useSocketEvent<T>(event: string, callback?: (data: T, raw?: unknown) => void): T | undefined;
95
95
  /**
96
96
  * Accumulate WebSocket events into an array.
97
97
  * - Without callback: returns accumulated array (reactive state).
@@ -115,7 +115,7 @@ export declare function useSocketEvent<T>(event: string, callback?: (data: T) =>
115
115
  * if (entry.level === 'error') setErrors(prev => [...prev, entry]);
116
116
  * });
117
117
  */
118
- export declare function useSocketStream<T>(event: string, callback?: (data: T) => void): T[];
118
+ export declare function useSocketStream<T>(event: string, callback?: (data: T, raw?: unknown) => void): T[];
119
119
  /**
120
120
  * Two-way state sync across browser tabs.
121
121
  * - Without callback: returns [value, setter] (like useState but synced).
@@ -152,7 +152,7 @@ export declare function useSocketSync<T>(key: string, initialValue: T, callback?
152
152
  * }
153
153
  * });
154
154
  */
155
- export declare function useSocketCallback<T>(event: string, callback: (data: T) => void): void;
155
+ export declare function useSocketCallback<T>(event: string, callback: (data: T, raw?: unknown) => void): void;
156
156
  /**
157
157
  * Reactive connection status.
158
158
  * Uses useEffectEvent to avoid re-creating interval on state change.
@@ -178,6 +178,29 @@ export declare function useSocketStatus(): {
178
178
  * });
179
179
  */
180
180
  export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): void;
181
+ /**
182
+ * Reactive reconnect state with a manual `reconnect` action. Use this to
183
+ * power a "Reconnect" snackbar/banner after auto-reconnect gives up.
184
+ *
185
+ * `hasFailed` is `true` after `reconnectMaxRetries` are exhausted. It resets
186
+ * to `false` once the connection succeeds again or the user calls `reconnect()`.
187
+ *
188
+ * @example
189
+ * function ConnectionBanner() {
190
+ * const { hasFailed, reconnect } = useSocketReconnect();
191
+ * if (!hasFailed) return null;
192
+ * return (
193
+ * <div className="snackbar">
194
+ * Connection lost.
195
+ * <button onClick={reconnect}>Reconnect</button>
196
+ * </div>
197
+ * );
198
+ * }
199
+ */
200
+ export declare function useSocketReconnect(): {
201
+ hasFailed: boolean;
202
+ reconnect: () => void;
203
+ };
181
204
  /**
182
205
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
183
206
  *
@@ -59,7 +59,7 @@ export declare function useSocketAuth(): {
59
59
  * analytics.track('order_received', order);
60
60
  * });
61
61
  */
62
- export declare function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined>;
62
+ export declare function useSocketEvent<T>(event: string, callback?: (data: T, raw?: unknown) => void): Ref<T | undefined>;
63
63
  /**
64
64
  * Accumulate WebSocket events.
65
65
  * - Without callback: returns reactive array.
@@ -83,7 +83,7 @@ export declare function useSocketEvent<T>(event: string, callback?: (data: T) =>
83
83
  * if (entry.level === 'error') errors.value = [...errors.value, entry];
84
84
  * });
85
85
  */
86
- export declare function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]>;
86
+ export declare function useSocketStream<T>(event: string, callback?: (data: T, raw?: unknown) => void): Ref<T[]>;
87
87
  /**
88
88
  * Two-way state sync across browser tabs.
89
89
  * - Without callback: reactive ref synced across tabs.
@@ -110,7 +110,7 @@ export declare function useSocketSync<T>(key: string, initialValue: T, callback?
110
110
  * showToast(n.title);
111
111
  * });
112
112
  */
113
- export declare function useSocketCallback<T>(event: string, callback: (data: T) => void): void;
113
+ export declare function useSocketCallback<T>(event: string, callback: (data: T, raw?: unknown) => void): void;
114
114
  /**
115
115
  * Reactive connection status.
116
116
  *
@@ -135,6 +135,29 @@ export declare function useSocketStatus(): {
135
135
  * });
136
136
  */
137
137
  export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): void;
138
+ /**
139
+ * Reactive reconnect state with a manual `reconnect` action. Use this to
140
+ * power a "Reconnect" snackbar/banner after auto-reconnect gives up.
141
+ *
142
+ * `hasFailed` flips to `true` once `reconnectMaxRetries` are exhausted, and
143
+ * back to `false` once the connection succeeds or the user calls `reconnect()`.
144
+ *
145
+ * @example
146
+ * <script setup>
147
+ * const { hasFailed, reconnect } = useSocketReconnect();
148
+ * </script>
149
+ *
150
+ * <template>
151
+ * <div v-if="hasFailed" class="snackbar">
152
+ * Connection lost.
153
+ * <button @click="reconnect">Reconnect</button>
154
+ * </div>
155
+ * </template>
156
+ */
157
+ export declare function useSocketReconnect(): {
158
+ hasFailed: Ref<boolean>;
159
+ reconnect: () => void;
160
+ };
138
161
  /**
139
162
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
140
163
  *