@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 +37 -0
- package/dist/SharedSocket.d.ts +2 -0
- package/dist/SharedWebSocket.d.ts +141 -5
- package/dist/SubscriptionManager.d.ts +1 -1
- package/dist/WorkerSocket.d.ts +2 -0
- package/dist/adapters/react.d.ts +3 -3
- package/dist/adapters/vue.d.ts +3 -3
- package/dist/{chunk-RKVYLJTQ.cjs → chunk-HIKH74NQ.cjs} +505 -69
- package/dist/chunk-HIKH74NQ.cjs.map +1 -0
- package/dist/{chunk-IK4HLA3K.js → chunk-N63ZMMWV.js} +496 -60
- package/dist/chunk-N63ZMMWV.js.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.cjs +8 -8
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +7 -7
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +152 -1
- package/dist/vue.cjs +8 -8
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +7 -7
- package/dist/vue.js.map +1 -1
- package/dist/worker/socket.worker.d.ts +2 -0
- package/package.json +1 -1
- package/src/MessageBus.ts +8 -1
- package/src/SharedSocket.ts +28 -3
- package/src/SharedWebSocket.ts +577 -63
- package/src/SubscriptionManager.ts +4 -4
- package/src/WorkerSocket.ts +27 -3
- package/src/adapters/react.ts +9 -9
- package/src/adapters/vue.ts +9 -9
- package/src/index.ts +3 -0
- package/src/types.ts +162 -1
- package/src/worker/socket.worker.ts +7 -0
- package/dist/chunk-IK4HLA3K.js.map +0 -1
- package/dist/chunk-RKVYLJTQ.cjs.map +0 -1
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
|
package/dist/SharedSocket.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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;
|
package/dist/WorkerSocket.d.ts
CHANGED
|
@@ -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;
|
package/dist/adapters/react.d.ts
CHANGED
|
@@ -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.
|
package/dist/adapters/vue.d.ts
CHANGED
|
@@ -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
|
*
|