@gwakko/shared-websocket 0.13.0 → 0.14.5

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
@@ -266,6 +267,25 @@ Incoming: WebSocket.onmessage
266
267
  | **[Server Guide](docs/server-guide.md)** | Node.js, Go, PHP examples + system events |
267
268
  | **[Types](docs/types.md)** | All exported types with import examples |
268
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
+
269
289
  ## Browser Support
270
290
 
271
291
  | API | Chrome | Firefox | Safari | Edge |
@@ -274,6 +294,23 @@ Incoming: WebSocket.onmessage
274
294
  | Web Worker | ✅ | ✅ | ✅ | ✅ |
275
295
  | AsyncGenerator | 63+ | 57+ | 12+ | 79+ |
276
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
+
277
314
  ## License
278
315
 
279
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>;
@@ -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;
@@ -154,7 +175,20 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
154
175
  * ws.deserializer('trading.tick', (data) => TickProto.decode(data as Uint8Array));
155
176
  */
156
177
  deserializer(event: string, fn: (data: unknown) => unknown): this;
157
- /** 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
+ */
158
192
  on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
159
193
  on(event: string, handler: EventHandler<unknown>): Unsubscribe;
160
194
  once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
@@ -163,9 +197,33 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
163
197
  /** Async generator for consuming events. Type-safe with EventMap. */
164
198
  stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;
165
199
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
166
- /** Send message to server (auto-routed through leader). Type-safe with EventMap. */
167
- send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
168
- 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;
169
227
  /** Request/response through server via leader. */
170
228
  request<T>(event: string, data: unknown, timeout?: number): Promise<T>;
171
229
  /** Sync state across tabs (no server roundtrip). */
@@ -265,9 +323,87 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
265
323
  onClick?: (data: T) => void;
266
324
  }): Unsubscribe;
267
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;
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;
268
355
  private createSocket;
269
356
  private handleBecomeLeader;
270
- private reAuthenticateOnReconnect;
357
+ /**
358
+ * Re-establish all server-side state on the freshly connected leader socket:
359
+ * 1. auth-login (so server accepts subsequent joins on auth channels)
360
+ * 2. channel-join for the union of channels held by ALL surviving tabs
361
+ * 3. topic-subscribe for the union of topics held by ALL surviving tabs
362
+ *
363
+ * The union covers leader handover: when a follower with handlers is
364
+ * promoted, no tab's subscriptions get silently dropped. Frames are sent
365
+ * in FIFO order over the single WebSocket, so auth precedes the joins
366
+ * that depend on it.
367
+ */
368
+ /**
369
+ * Orchestrate post-connect recovery: replay subscriptions first (so the
370
+ * server is ready to route events for any channels we still care about),
371
+ * then drain follower-pending dispatches that didn't reach the previous
372
+ * leader's socket.
373
+ */
374
+ private onConnected;
375
+ private resubscribeOnConnect;
376
+ /**
377
+ * Replay buffered follower dispatches over the freshly connected socket.
378
+ * Gathers from all tabs (including this one), de-dups by id, transmits,
379
+ * then signals each originator to drop its local entry. Drops own-tab
380
+ * entries after transmission since `bus.publish` doesn't echo to self.
381
+ */
382
+ private replayPendingDispatches;
383
+ /**
384
+ * Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`
385
+ * — broadcasts a one-shot request, collects for a short window, dedups
386
+ * by id (so multiple tabs holding the same id don't double-replay).
387
+ */
388
+ private gatherPendingDispatches;
389
+ /**
390
+ * Best-effort cross-tab gather. Broadcasts a request and collects responses
391
+ * for a short window. Times out gracefully — late responses are dropped.
392
+ * The leader's own subs are seeded into the result to avoid relying on
393
+ * BroadcastChannel echo to self.
394
+ */
395
+ private gatherSubscriptions;
271
396
  private handleLoseLeadership;
397
+ /**
398
+ * Start a leader-only periodic refresh of the auth token. The callback
399
+ * is `options.refresh` (preferred) or `options.auth` (fallback). When
400
+ * the timer fires and the connection is currently authenticated, the
401
+ * returned token is fed back through `authenticate()` so subscribers
402
+ * stay synced and the leader's socket re-issues auth-login.
403
+ *
404
+ * Idempotent — calling start while already running is a no-op.
405
+ */
406
+ private startRefreshTimer;
407
+ private stopRefreshTimer;
272
408
  [Symbol.dispose](): void;
273
409
  }
@@ -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,6 +38,7 @@ 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;
@@ -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.
@@ -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
  *