@gwakko/shared-websocket 0.12.2 → 0.13.0
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 +2 -1
- package/dist/SharedSocket.d.ts +8 -1
- package/dist/SharedWebSocket.d.ts +22 -0
- package/dist/TabSync.d.ts +14 -2
- package/dist/WorkerSocket.d.ts +9 -1
- package/dist/adapters/react.d.ts +23 -0
- package/dist/adapters/sync-react.d.ts +3 -1
- package/dist/adapters/sync-vue.d.ts +3 -1
- package/dist/adapters/vue.d.ts +23 -0
- package/dist/{chunk-RM27CYKT.js → chunk-7WBM2C7H.js} +15 -2
- package/dist/chunk-7WBM2C7H.js.map +1 -0
- package/dist/{chunk-FZIIMO67.js → chunk-IK4HLA3K.js} +119 -14
- package/dist/chunk-IK4HLA3K.js.map +1 -0
- package/dist/{chunk-ET3YHQ7V.cjs → chunk-RJKAFACH.cjs} +16 -3
- package/dist/chunk-RJKAFACH.cjs.map +1 -0
- package/dist/{chunk-ADGLL3J2.cjs → chunk-RKVYLJTQ.cjs} +133 -28
- package/dist/chunk-RKVYLJTQ.cjs.map +1 -0
- package/dist/index.cjs +4 -4
- package/dist/index.js +2 -2
- package/dist/react.cjs +31 -13
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +24 -6
- package/dist/react.js.map +1 -1
- package/dist/sync-react.cjs +3 -3
- package/dist/sync-react.cjs.map +1 -1
- package/dist/sync-react.js +3 -3
- package/dist/sync-react.js.map +1 -1
- package/dist/sync-vue.cjs +3 -3
- package/dist/sync-vue.cjs.map +1 -1
- package/dist/sync-vue.js +3 -3
- package/dist/sync-vue.js.map +1 -1
- package/dist/sync.cjs +2 -2
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/vue.cjs +26 -4
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +24 -2
- package/dist/vue.js.map +1 -1
- package/dist/worker/socket.worker.d.ts +6 -2
- package/package.json +1 -1
- package/src/SharedSocket.ts +27 -3
- package/src/SharedWebSocket.ts +56 -3
- package/src/TabSync.ts +23 -2
- package/src/WorkerSocket.ts +54 -7
- package/src/adapters/react.ts +49 -5
- package/src/adapters/sync-react.ts +4 -2
- package/src/adapters/sync-vue.ts +2 -2
- package/src/adapters/vue.ts +48 -1
- package/src/sync.ts +1 -1
- package/src/types.ts +3 -1
- package/src/worker/socket.worker.ts +37 -2
- package/dist/chunk-ADGLL3J2.cjs.map +0 -1
- package/dist/chunk-ET3YHQ7V.cjs.map +0 -1
- package/dist/chunk-FZIIMO67.js.map +0 -1
- package/dist/chunk-RM27CYKT.js.map +0 -1
package/src/SharedWebSocket.ts
CHANGED
|
@@ -31,8 +31,9 @@ const NOOP_LOGGER: Logger = {
|
|
|
31
31
|
/** Common interface for both SharedSocket and WorkerSocket. */
|
|
32
32
|
interface SocketAdapter {
|
|
33
33
|
readonly state: string;
|
|
34
|
-
connect(): void
|
|
34
|
+
connect(): void | Promise<void>;
|
|
35
35
|
send(data: unknown): void;
|
|
36
|
+
reconnect(): void;
|
|
36
37
|
disconnect(): void;
|
|
37
38
|
onMessage(fn: EventHandler): Unsubscribe;
|
|
38
39
|
onStateChange(fn: (state: string) => void): Unsubscribe;
|
|
@@ -103,6 +104,16 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
103
104
|
}),
|
|
104
105
|
);
|
|
105
106
|
|
|
107
|
+
// Leader listens for reconnect requests from followers
|
|
108
|
+
this.cleanups.push(
|
|
109
|
+
this.bus.subscribe<void>('ws:reconnect', () => {
|
|
110
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
111
|
+
this.log.info('[SharedWS] manual reconnect requested by follower');
|
|
112
|
+
this.socket.reconnect();
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
|
|
106
117
|
// Sync across tabs
|
|
107
118
|
this.cleanups.push(
|
|
108
119
|
this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {
|
|
@@ -134,6 +145,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
134
145
|
case 'reconnecting':
|
|
135
146
|
this.subs.emit('$lifecycle:reconnecting', undefined);
|
|
136
147
|
break;
|
|
148
|
+
case 'reconnectFailed':
|
|
149
|
+
this.subs.emit('$lifecycle:reconnectFailed', undefined);
|
|
150
|
+
break;
|
|
137
151
|
case 'leader':
|
|
138
152
|
this.subs.emit('$lifecycle:leader', msg.isLeader);
|
|
139
153
|
break;
|
|
@@ -228,6 +242,38 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
228
242
|
return this.subs.on('$lifecycle:reconnecting', fn);
|
|
229
243
|
}
|
|
230
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
|
|
247
|
+
* Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
|
|
248
|
+
* so the user can call `ws.reconnect()` to try again.
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ws.onReconnectFailed(() => {
|
|
252
|
+
* showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
|
|
253
|
+
* });
|
|
254
|
+
*/
|
|
255
|
+
onReconnectFailed(fn: () => void): Unsubscribe {
|
|
256
|
+
return this.subs.on('$lifecycle:reconnectFailed', fn);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Manually trigger a reconnect. Resets the retry counter and attempts a
|
|
261
|
+
* fresh connection. Safe to call from any tab — the leader actually owns
|
|
262
|
+
* the socket, followers route the request via BroadcastChannel.
|
|
263
|
+
*
|
|
264
|
+
* Use after `onReconnectFailed` fires to let the user retry.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* snackbar.action('Reconnect', () => ws.reconnect());
|
|
268
|
+
*/
|
|
269
|
+
reconnect(): void {
|
|
270
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
271
|
+
this.socket.reconnect();
|
|
272
|
+
} else {
|
|
273
|
+
this.bus.publish('ws:reconnect', undefined);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
231
277
|
/** Called when this tab becomes leader or loses leadership. */
|
|
232
278
|
onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {
|
|
233
279
|
return this.subs.on('$lifecycle:leader', fn as EventHandler);
|
|
@@ -675,6 +721,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
675
721
|
return new WorkerSocket(this.url, {
|
|
676
722
|
...socketOptions,
|
|
677
723
|
workerUrl: this.options.workerUrl,
|
|
724
|
+
auth: this.options.auth,
|
|
725
|
+
authToken: this.options.authToken,
|
|
726
|
+
authParam: this.options.authParam,
|
|
678
727
|
});
|
|
679
728
|
}
|
|
680
729
|
|
|
@@ -718,7 +767,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
718
767
|
});
|
|
719
768
|
|
|
720
769
|
this.socket.onStateChange((state: string) => {
|
|
721
|
-
this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : `state: ${state}`);
|
|
770
|
+
this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : state === 'failed' ? '✗ reconnect failed' : `state: ${state}`);
|
|
722
771
|
switch (state) {
|
|
723
772
|
case 'connected':
|
|
724
773
|
this.bus.broadcast('ws:lifecycle', { type: 'connect' });
|
|
@@ -730,6 +779,10 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
730
779
|
case 'reconnecting':
|
|
731
780
|
this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });
|
|
732
781
|
break;
|
|
782
|
+
case 'failed':
|
|
783
|
+
this.bus.broadcast('ws:lifecycle', { type: 'reconnectFailed' });
|
|
784
|
+
this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
|
|
785
|
+
break;
|
|
733
786
|
}
|
|
734
787
|
});
|
|
735
788
|
|
|
@@ -748,7 +801,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
748
801
|
}),
|
|
749
802
|
);
|
|
750
803
|
|
|
751
|
-
this.socket.connect();
|
|
804
|
+
void this.socket.connect();
|
|
752
805
|
}
|
|
753
806
|
|
|
754
807
|
private reAuthenticateOnReconnect(): void {
|
package/src/TabSync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import './utils/disposable';
|
|
2
|
-
import type { Unsubscribe } from './types';
|
|
2
|
+
import type { Unsubscribe, Logger } from './types';
|
|
3
3
|
|
|
4
4
|
interface SyncMessage {
|
|
5
5
|
key: string;
|
|
@@ -7,6 +7,15 @@ interface SyncMessage {
|
|
|
7
7
|
deleted?: boolean;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
interface TabSyncOptions {
|
|
11
|
+
/** Enable debug logging (default: false). */
|
|
12
|
+
debug?: boolean;
|
|
13
|
+
/** Custom logger (default: console). */
|
|
14
|
+
logger?: Logger;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Cross-tab state synchronization via BroadcastChannel.
|
|
12
21
|
* No WebSocket needed — works standalone for sharing state between browser tabs.
|
|
@@ -15,21 +24,30 @@ interface SyncMessage {
|
|
|
15
24
|
* const sync = new TabSync('my-app');
|
|
16
25
|
* sync.set('theme', 'dark');
|
|
17
26
|
* sync.on('theme', (theme) => applyTheme(theme));
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // With debug logging
|
|
30
|
+
* const sync = new TabSync('my-app', { debug: true });
|
|
18
31
|
*/
|
|
19
32
|
export class TabSync implements Disposable {
|
|
20
33
|
private store = new Map<string, unknown>();
|
|
21
34
|
private listeners = new Map<string, Set<(value: unknown) => void>>();
|
|
22
35
|
private bc: BroadcastChannel;
|
|
23
36
|
private disposed = false;
|
|
37
|
+
private readonly log: Logger;
|
|
24
38
|
|
|
25
|
-
constructor(channel = 'tab-sync') {
|
|
39
|
+
constructor(channel = 'tab-sync', options?: TabSyncOptions) {
|
|
40
|
+
this.log = options?.debug ? (options.logger ?? console) : NOOP_LOGGER;
|
|
41
|
+
this.log.debug('[TabSync] init', { channel });
|
|
26
42
|
this.bc = new BroadcastChannel(channel);
|
|
27
43
|
this.bc.onmessage = (ev: MessageEvent<SyncMessage>) => {
|
|
28
44
|
const { key, value, deleted } = ev.data;
|
|
29
45
|
if (deleted) {
|
|
30
46
|
this.store.delete(key);
|
|
47
|
+
this.log.debug('[TabSync] ← remote delete', key);
|
|
31
48
|
} else {
|
|
32
49
|
this.store.set(key, value);
|
|
50
|
+
this.log.debug('[TabSync] ← remote set', key, value);
|
|
33
51
|
}
|
|
34
52
|
this.emit(key, value);
|
|
35
53
|
};
|
|
@@ -39,6 +57,7 @@ export class TabSync implements Disposable {
|
|
|
39
57
|
set<T>(key: string, value: T): void {
|
|
40
58
|
this.store.set(key, value);
|
|
41
59
|
this.bc.postMessage({ key, value } satisfies SyncMessage);
|
|
60
|
+
this.log.debug('[TabSync] → set', key, value);
|
|
42
61
|
this.emit(key, value);
|
|
43
62
|
}
|
|
44
63
|
|
|
@@ -51,6 +70,7 @@ export class TabSync implements Disposable {
|
|
|
51
70
|
delete(key: string): void {
|
|
52
71
|
this.store.delete(key);
|
|
53
72
|
this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);
|
|
73
|
+
this.log.debug('[TabSync] → delete', key);
|
|
54
74
|
this.emit(key, undefined);
|
|
55
75
|
}
|
|
56
76
|
|
|
@@ -98,6 +118,7 @@ export class TabSync implements Disposable {
|
|
|
98
118
|
/** Clear all keys and notify listeners. */
|
|
99
119
|
clear(): void {
|
|
100
120
|
const keys = [...this.store.keys()];
|
|
121
|
+
this.log.debug('[TabSync] → clear', keys);
|
|
101
122
|
this.store.clear();
|
|
102
123
|
for (const key of keys) {
|
|
103
124
|
this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);
|
package/src/WorkerSocket.ts
CHANGED
|
@@ -30,9 +30,14 @@ export class WorkerSocket implements Disposable {
|
|
|
30
30
|
protocols?: string[];
|
|
31
31
|
reconnect?: boolean;
|
|
32
32
|
reconnectMaxDelay?: number;
|
|
33
|
+
reconnectMaxRetries?: number;
|
|
33
34
|
heartbeatInterval?: number;
|
|
34
35
|
sendBuffer?: number;
|
|
35
36
|
workerUrl?: string | URL;
|
|
37
|
+
auth?: () => string | Promise<string>;
|
|
38
|
+
authToken?: string;
|
|
39
|
+
authParam?: string;
|
|
40
|
+
pingPayload?: unknown;
|
|
36
41
|
} = {},
|
|
37
42
|
) {}
|
|
38
43
|
|
|
@@ -40,7 +45,18 @@ export class WorkerSocket implements Disposable {
|
|
|
40
45
|
return this._state;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
connect(): void {
|
|
48
|
+
async connect(): Promise<void> {
|
|
49
|
+
// Resolve auth token before sending to worker (functions can't cross worker boundary)
|
|
50
|
+
let authToken: string | undefined;
|
|
51
|
+
if (this.options.auth) {
|
|
52
|
+
authToken = await this.options.auth();
|
|
53
|
+
} else if (this.options.authToken) {
|
|
54
|
+
authToken = this.options.authToken;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build URL with auth token
|
|
58
|
+
const connectUrl = authToken ? this.buildUrl(authToken) : this.url;
|
|
59
|
+
|
|
44
60
|
// Create worker from inline blob if no workerUrl provided
|
|
45
61
|
const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();
|
|
46
62
|
|
|
@@ -74,19 +90,34 @@ export class WorkerSocket implements Disposable {
|
|
|
74
90
|
|
|
75
91
|
this.worker.postMessage({
|
|
76
92
|
type: 'connect',
|
|
77
|
-
url:
|
|
93
|
+
url: connectUrl,
|
|
78
94
|
protocols: this.options.protocols ?? [],
|
|
79
95
|
reconnect: this.options.reconnect ?? true,
|
|
80
96
|
reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,
|
|
97
|
+
reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
|
|
81
98
|
heartbeatInterval: this.options.heartbeatInterval ?? 30_000,
|
|
82
99
|
bufferSize: this.options.sendBuffer ?? 100,
|
|
100
|
+
pingPayload: this.options.pingPayload,
|
|
83
101
|
});
|
|
84
102
|
}
|
|
85
103
|
|
|
104
|
+
private buildUrl(token: string): string {
|
|
105
|
+
const param = this.options.authParam ?? 'token';
|
|
106
|
+
const httpUrl = this.url.replace(/^ws(s?):\/\//, 'http$1://');
|
|
107
|
+
const parsed = new URL(httpUrl);
|
|
108
|
+
parsed.searchParams.set(param, token);
|
|
109
|
+
return parsed.toString().replace(/^http(s?):\/\//, 'ws$1://');
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
send(data: unknown): void {
|
|
87
113
|
this.worker?.postMessage({ type: 'send', data });
|
|
88
114
|
}
|
|
89
115
|
|
|
116
|
+
/** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
|
|
117
|
+
reconnect(): void {
|
|
118
|
+
this.worker?.postMessage({ type: 'reconnect' });
|
|
119
|
+
}
|
|
120
|
+
|
|
90
121
|
disconnect(): void {
|
|
91
122
|
this.worker?.postMessage({ type: 'disconnect' });
|
|
92
123
|
setTimeout(() => {
|
|
@@ -113,27 +144,43 @@ export class WorkerSocket implements Disposable {
|
|
|
113
144
|
let ws = null, state = 'closed', buffer = [], disposed = false;
|
|
114
145
|
let heartbeatTimer = null, reconnectTimer = null;
|
|
115
146
|
let url = '', protocols = [], shouldReconnect = true;
|
|
116
|
-
let maxDelay = 30000,
|
|
147
|
+
let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
|
|
148
|
+
let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
|
|
117
149
|
|
|
118
150
|
function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
|
|
119
151
|
function connect() {
|
|
120
152
|
if (disposed) return;
|
|
121
153
|
setState('connecting');
|
|
122
154
|
ws = new WebSocket(url, protocols);
|
|
123
|
-
ws.onopen = () => {
|
|
155
|
+
ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
|
|
124
156
|
ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
|
|
125
157
|
ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
|
|
126
158
|
ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
|
|
127
159
|
}
|
|
128
160
|
function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
|
|
129
161
|
function flush() { const p = buffer.splice(0); p.forEach(send); }
|
|
130
|
-
function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(
|
|
162
|
+
function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(pingPayload); }, hbInterval); }
|
|
131
163
|
function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
|
|
132
|
-
function reconnect() {
|
|
164
|
+
function reconnect() {
|
|
165
|
+
attempts++;
|
|
166
|
+
if (attempts > maxRetries) { setState('failed'); return; }
|
|
167
|
+
setState('reconnecting');
|
|
168
|
+
const j = delay * 0.25 * (Math.random() * 2 - 1);
|
|
169
|
+
reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));
|
|
170
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
171
|
+
}
|
|
172
|
+
function manualReconnect() {
|
|
173
|
+
if (disposed) return;
|
|
174
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
175
|
+
attempts = 0; delay = 1000;
|
|
176
|
+
if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }
|
|
177
|
+
connect();
|
|
178
|
+
}
|
|
133
179
|
self.onmessage = (e) => {
|
|
134
180
|
const c = e.data;
|
|
135
|
-
if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; connect(); }
|
|
181
|
+
if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
|
|
136
182
|
if (c.type === 'send') send(c.data);
|
|
183
|
+
if (c.type === 'reconnect') manualReconnect();
|
|
137
184
|
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'); }
|
|
138
185
|
};
|
|
139
186
|
`;
|
package/src/adapters/react.ts
CHANGED
|
@@ -48,13 +48,10 @@ export interface SharedWebSocketProviderProps {
|
|
|
48
48
|
* }
|
|
49
49
|
*/
|
|
50
50
|
export function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {
|
|
51
|
-
const [socket] = useState(() =>
|
|
52
|
-
const ws = new SharedWebSocket(url, options);
|
|
53
|
-
ws.connect();
|
|
54
|
-
return ws;
|
|
55
|
-
});
|
|
51
|
+
const [socket] = useState(() => new SharedWebSocket(url, options));
|
|
56
52
|
|
|
57
53
|
useEffect(() => {
|
|
54
|
+
socket.connect();
|
|
58
55
|
return () => {
|
|
59
56
|
socket[Symbol.dispose]();
|
|
60
57
|
};
|
|
@@ -341,6 +338,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
341
338
|
const onConnect = useEffectEvent(() => handlers.onConnect?.());
|
|
342
339
|
const onDisconnect = useEffectEvent(() => handlers.onDisconnect?.());
|
|
343
340
|
const onReconnecting = useEffectEvent(() => handlers.onReconnecting?.());
|
|
341
|
+
const onReconnectFailed = useEffectEvent(() => handlers.onReconnectFailed?.());
|
|
344
342
|
const onLeaderChange = useEffectEvent((isLeader: boolean) => handlers.onLeaderChange?.(isLeader));
|
|
345
343
|
const onError = useEffectEvent((error: unknown) => handlers.onError?.(error));
|
|
346
344
|
const onActive = useEffectEvent(() => handlers.onActive?.());
|
|
@@ -353,6 +351,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
353
351
|
socket.onConnect(onConnect),
|
|
354
352
|
socket.onDisconnect(onDisconnect),
|
|
355
353
|
socket.onReconnecting(onReconnecting),
|
|
354
|
+
socket.onReconnectFailed(onReconnectFailed),
|
|
356
355
|
socket.onLeaderChange(onLeaderChange),
|
|
357
356
|
socket.onError(onError),
|
|
358
357
|
socket.onActive(onActive),
|
|
@@ -364,6 +363,51 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
364
363
|
}, [socket]);
|
|
365
364
|
}
|
|
366
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Reactive reconnect state with a manual `reconnect` action. Use this to
|
|
368
|
+
* power a "Reconnect" snackbar/banner after auto-reconnect gives up.
|
|
369
|
+
*
|
|
370
|
+
* `hasFailed` is `true` after `reconnectMaxRetries` are exhausted. It resets
|
|
371
|
+
* to `false` once the connection succeeds again or the user calls `reconnect()`.
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* function ConnectionBanner() {
|
|
375
|
+
* const { hasFailed, reconnect } = useSocketReconnect();
|
|
376
|
+
* if (!hasFailed) return null;
|
|
377
|
+
* return (
|
|
378
|
+
* <div className="snackbar">
|
|
379
|
+
* Connection lost.
|
|
380
|
+
* <button onClick={reconnect}>Reconnect</button>
|
|
381
|
+
* </div>
|
|
382
|
+
* );
|
|
383
|
+
* }
|
|
384
|
+
*/
|
|
385
|
+
export function useSocketReconnect(): {
|
|
386
|
+
hasFailed: boolean;
|
|
387
|
+
reconnect: () => void;
|
|
388
|
+
} {
|
|
389
|
+
const socket = useSharedWebSocket();
|
|
390
|
+
const [hasFailed, setHasFailed] = useState(false);
|
|
391
|
+
|
|
392
|
+
const onFailed = useEffectEvent(() => setHasFailed(true));
|
|
393
|
+
const onConnected = useEffectEvent(() => setHasFailed(false));
|
|
394
|
+
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
const unsubs = [
|
|
397
|
+
socket.onReconnectFailed(onFailed),
|
|
398
|
+
socket.onConnect(onConnected),
|
|
399
|
+
];
|
|
400
|
+
return () => unsubs.forEach((u) => u());
|
|
401
|
+
}, [socket]);
|
|
402
|
+
|
|
403
|
+
const reconnect = useEffectEvent(() => {
|
|
404
|
+
setHasFailed(false);
|
|
405
|
+
socket.reconnect();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return { hasFailed, reconnect };
|
|
409
|
+
}
|
|
410
|
+
|
|
367
411
|
/**
|
|
368
412
|
* Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
|
|
369
413
|
*
|
|
@@ -28,11 +28,13 @@ const TabSyncContext = createContext<TabSync | null>(null);
|
|
|
28
28
|
export interface TabSyncProviderProps {
|
|
29
29
|
/** BroadcastChannel name (default: "tab-sync"). */
|
|
30
30
|
channel?: string;
|
|
31
|
+
/** Enable debug logging. */
|
|
32
|
+
debug?: boolean;
|
|
31
33
|
children: ReactNode;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
export function TabSyncProvider({ channel, children }: TabSyncProviderProps) {
|
|
35
|
-
const [sync] = useState(() => new TabSync(channel));
|
|
36
|
+
export function TabSyncProvider({ channel, debug, children }: TabSyncProviderProps) {
|
|
37
|
+
const [sync] = useState(() => new TabSync(channel, { debug }));
|
|
36
38
|
|
|
37
39
|
useEffect(() => {
|
|
38
40
|
return () => sync[Symbol.dispose]();
|
package/src/adapters/sync-vue.ts
CHANGED
|
@@ -21,10 +21,10 @@ export const TabSyncKey: InjectionKey<TabSync> = Symbol('TabSync');
|
|
|
21
21
|
* const app = createApp(App);
|
|
22
22
|
* app.use(createTabSyncPlugin('my-app'));
|
|
23
23
|
*/
|
|
24
|
-
export function createTabSyncPlugin(channel?: string) {
|
|
24
|
+
export function createTabSyncPlugin(channel?: string, options?: { debug?: boolean }) {
|
|
25
25
|
return {
|
|
26
26
|
install(app: App) {
|
|
27
|
-
const sync = new TabSync(channel);
|
|
27
|
+
const sync = new TabSync(channel, options);
|
|
28
28
|
app.provide(TabSyncKey, sync);
|
|
29
29
|
|
|
30
30
|
const originalUnmount = app.unmount.bind(app);
|
package/src/adapters/vue.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function createSharedWebSocketPlugin(url: string, options?: SharedWebSock
|
|
|
29
29
|
return {
|
|
30
30
|
install(app: App) {
|
|
31
31
|
const socket = new SharedWebSocket(url, options);
|
|
32
|
-
socket.connect();
|
|
32
|
+
void socket.connect();
|
|
33
33
|
app.provide(SharedWebSocketKey, socket);
|
|
34
34
|
|
|
35
35
|
const originalUnmount = app.unmount.bind(app);
|
|
@@ -275,6 +275,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
275
275
|
if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));
|
|
276
276
|
if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));
|
|
277
277
|
if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
|
|
278
|
+
if (handlers.onReconnectFailed) unsubs.push(socket.onReconnectFailed(handlers.onReconnectFailed));
|
|
278
279
|
if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
|
|
279
280
|
if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
|
|
280
281
|
if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
|
|
@@ -285,6 +286,52 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
285
286
|
onUnmounted(() => unsubs.forEach((u) => u()));
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Reactive reconnect state with a manual `reconnect` action. Use this to
|
|
291
|
+
* power a "Reconnect" snackbar/banner after auto-reconnect gives up.
|
|
292
|
+
*
|
|
293
|
+
* `hasFailed` flips to `true` once `reconnectMaxRetries` are exhausted, and
|
|
294
|
+
* back to `false` once the connection succeeds or the user calls `reconnect()`.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* <script setup>
|
|
298
|
+
* const { hasFailed, reconnect } = useSocketReconnect();
|
|
299
|
+
* </script>
|
|
300
|
+
*
|
|
301
|
+
* <template>
|
|
302
|
+
* <div v-if="hasFailed" class="snackbar">
|
|
303
|
+
* Connection lost.
|
|
304
|
+
* <button @click="reconnect">Reconnect</button>
|
|
305
|
+
* </div>
|
|
306
|
+
* </template>
|
|
307
|
+
*/
|
|
308
|
+
export function useSocketReconnect(): {
|
|
309
|
+
hasFailed: Ref<boolean>;
|
|
310
|
+
reconnect: () => void;
|
|
311
|
+
} {
|
|
312
|
+
const socket = useSharedWebSocket();
|
|
313
|
+
const hasFailed = ref(false);
|
|
314
|
+
|
|
315
|
+
const unsubs = [
|
|
316
|
+
socket.onReconnectFailed(() => {
|
|
317
|
+
hasFailed.value = true;
|
|
318
|
+
}),
|
|
319
|
+
socket.onConnect(() => {
|
|
320
|
+
hasFailed.value = false;
|
|
321
|
+
}),
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
onUnmounted(() => unsubs.forEach((u) => u()));
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
hasFailed: readonly(hasFailed) as Ref<boolean>,
|
|
328
|
+
reconnect: () => {
|
|
329
|
+
hasFailed.value = false;
|
|
330
|
+
socket.reconnect();
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
288
335
|
/**
|
|
289
336
|
* Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
|
|
290
337
|
*
|
package/src/sync.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { TabSync } from './TabSync';
|
|
2
|
-
export type { Unsubscribe } from './types';
|
|
2
|
+
export type { Unsubscribe, Logger } from './types';
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
|
1
|
+
export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'failed';
|
|
2
2
|
export type TabRole = 'leader' | 'follower';
|
|
3
3
|
export type Unsubscribe = () => void;
|
|
4
4
|
export type EventHandler<T = unknown> = (data: T) => void;
|
|
@@ -136,6 +136,8 @@ export interface SocketLifecycleHandlers {
|
|
|
136
136
|
onConnect?: () => void;
|
|
137
137
|
onDisconnect?: () => void;
|
|
138
138
|
onReconnecting?: () => void;
|
|
139
|
+
/** Called when auto-reconnect gives up after exhausting reconnectMaxRetries. */
|
|
140
|
+
onReconnectFailed?: () => void;
|
|
139
141
|
onLeaderChange?: (isLeader: boolean) => void;
|
|
140
142
|
onError?: (error: unknown) => void;
|
|
141
143
|
/** Called when this tab becomes visible/focused. */
|
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
* { type: 'state', state: SocketState }
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
|
19
|
+
type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'failed';
|
|
20
20
|
|
|
21
21
|
interface WorkerCommand {
|
|
22
|
-
type: 'connect' | 'send' | 'disconnect';
|
|
22
|
+
type: 'connect' | 'send' | 'disconnect' | 'reconnect';
|
|
23
23
|
url?: string;
|
|
24
24
|
protocols?: string[];
|
|
25
25
|
data?: unknown;
|
|
26
26
|
reconnect?: boolean;
|
|
27
27
|
reconnectMaxDelay?: number;
|
|
28
|
+
reconnectMaxRetries?: number;
|
|
28
29
|
heartbeatInterval?: number;
|
|
29
30
|
bufferSize?: number;
|
|
30
31
|
}
|
|
@@ -40,11 +41,13 @@ let currentUrl = '';
|
|
|
40
41
|
let currentProtocols: string[] = [];
|
|
41
42
|
let shouldReconnect = true;
|
|
42
43
|
let maxDelay = 30_000;
|
|
44
|
+
let maxRetries = Infinity;
|
|
43
45
|
let heartbeatInterval = 30_000;
|
|
44
46
|
let maxBuffer = 100;
|
|
45
47
|
|
|
46
48
|
// Backoff state
|
|
47
49
|
let backoffDelay = 1000;
|
|
50
|
+
let reconnectAttempts = 0;
|
|
48
51
|
|
|
49
52
|
function setState(s: SocketState) {
|
|
50
53
|
state = s;
|
|
@@ -57,6 +60,7 @@ function connect(url: string, protocols: string[]) {
|
|
|
57
60
|
currentUrl = url;
|
|
58
61
|
currentProtocols = protocols;
|
|
59
62
|
backoffDelay = 1000;
|
|
63
|
+
reconnectAttempts = 0;
|
|
60
64
|
|
|
61
65
|
doConnect();
|
|
62
66
|
}
|
|
@@ -76,6 +80,7 @@ function doConnect() {
|
|
|
76
80
|
ws.onopen = () => {
|
|
77
81
|
setState('connected');
|
|
78
82
|
backoffDelay = 1000;
|
|
83
|
+
reconnectAttempts = 0;
|
|
79
84
|
self.postMessage({ type: 'open' });
|
|
80
85
|
flushBuffer();
|
|
81
86
|
startHeartbeat();
|
|
@@ -161,6 +166,12 @@ function stopHeartbeat() {
|
|
|
161
166
|
}
|
|
162
167
|
|
|
163
168
|
function scheduleReconnect() {
|
|
169
|
+
reconnectAttempts++;
|
|
170
|
+
if (reconnectAttempts > maxRetries) {
|
|
171
|
+
setState('failed');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
164
175
|
setState('reconnecting');
|
|
165
176
|
clearReconnect();
|
|
166
177
|
|
|
@@ -174,6 +185,25 @@ function scheduleReconnect() {
|
|
|
174
185
|
backoffDelay = Math.min(backoffDelay * 2, maxDelay);
|
|
175
186
|
}
|
|
176
187
|
|
|
188
|
+
function manualReconnect() {
|
|
189
|
+
if (disposed) return;
|
|
190
|
+
clearReconnect();
|
|
191
|
+
reconnectAttempts = 0;
|
|
192
|
+
backoffDelay = 1000;
|
|
193
|
+
|
|
194
|
+
if (ws) {
|
|
195
|
+
ws.onclose = null;
|
|
196
|
+
ws.onmessage = null;
|
|
197
|
+
ws.onerror = null;
|
|
198
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
199
|
+
ws.close(1000, 'manual reconnect');
|
|
200
|
+
}
|
|
201
|
+
ws = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
doConnect();
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
function clearReconnect() {
|
|
178
208
|
if (reconnectTimer) {
|
|
179
209
|
clearTimeout(reconnectTimer);
|
|
@@ -189,6 +219,7 @@ self.onmessage = (ev: MessageEvent<WorkerCommand>) => {
|
|
|
189
219
|
case 'connect':
|
|
190
220
|
if (cmd.reconnect !== undefined) shouldReconnect = cmd.reconnect;
|
|
191
221
|
if (cmd.reconnectMaxDelay) maxDelay = cmd.reconnectMaxDelay;
|
|
222
|
+
if (cmd.reconnectMaxRetries !== undefined) maxRetries = cmd.reconnectMaxRetries;
|
|
192
223
|
if (cmd.heartbeatInterval) heartbeatInterval = cmd.heartbeatInterval;
|
|
193
224
|
if (cmd.bufferSize) maxBuffer = cmd.bufferSize;
|
|
194
225
|
connect(cmd.url!, cmd.protocols ?? []);
|
|
@@ -198,6 +229,10 @@ self.onmessage = (ev: MessageEvent<WorkerCommand>) => {
|
|
|
198
229
|
send(cmd.data);
|
|
199
230
|
break;
|
|
200
231
|
|
|
232
|
+
case 'reconnect':
|
|
233
|
+
manualReconnect();
|
|
234
|
+
break;
|
|
235
|
+
|
|
201
236
|
case 'disconnect':
|
|
202
237
|
disconnect();
|
|
203
238
|
break;
|