@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/README.md
CHANGED
|
@@ -210,7 +210,8 @@ usePush('notification', {
|
|
|
210
210
|
| **Custom Serialization** | `serialize`/`deserialize` — JSON, MessagePack, Protobuf |
|
|
211
211
|
| **Per-Event Serializers** | `ws.serializer(event, fn)` — binary for specific events |
|
|
212
212
|
| **Runtime Auth** | `authenticate(token)` / `deauthenticate()` on existing connection |
|
|
213
|
-
| **Lifecycle Hooks** | onConnect, onDisconnect, onActive, onInactive, onLeaderChange, onAuthChange |
|
|
213
|
+
| **Lifecycle Hooks** | onConnect, onDisconnect, onReconnecting, onReconnectFailed, onActive, onInactive, onLeaderChange, onAuthChange |
|
|
214
|
+
| **Manual Reconnect** | `ws.reconnect()` resets retry counter — pair with `onReconnectFailed` for a "Reconnect" snackbar |
|
|
214
215
|
| **Debug/Logger** | `debug: true` + injectable logger (pino, Sentry) |
|
|
215
216
|
| **Event Protocol** | Configurable field names (Socket.IO, Phoenix, Laravel Echo) |
|
|
216
217
|
| **Auth** | URL param (`auth` callback / `authToken`) + runtime `authenticate()`/`deauthenticate()` |
|
package/dist/SharedSocket.d.ts
CHANGED
|
@@ -37,7 +37,14 @@ export declare class SharedSocket implements Disposable {
|
|
|
37
37
|
send(data: unknown): void;
|
|
38
38
|
onMessage(fn: EventHandler): Unsubscribe;
|
|
39
39
|
onStateChange(fn: (state: SocketState) => void): Unsubscribe;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Manually trigger a reconnect. Resets the retry counter and clears any
|
|
42
|
+
* scheduled backoff so the next attempt happens immediately. Use after
|
|
43
|
+
* `state === 'failed'` to let the user retry, or any time to force a
|
|
44
|
+
* fresh connection.
|
|
45
|
+
*/
|
|
46
|
+
reconnect(): void;
|
|
47
|
+
private scheduleReconnect;
|
|
41
48
|
private flushBuffer;
|
|
42
49
|
private startHeartbeat;
|
|
43
50
|
private stopHeartbeat;
|
|
@@ -49,6 +49,28 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
|
|
|
49
49
|
onDisconnect(fn: () => void): Unsubscribe;
|
|
50
50
|
/** Called when WebSocket starts reconnecting (broadcast to all tabs). */
|
|
51
51
|
onReconnecting(fn: () => void): Unsubscribe;
|
|
52
|
+
/**
|
|
53
|
+
* Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
|
|
54
|
+
* Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
|
|
55
|
+
* so the user can call `ws.reconnect()` to try again.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ws.onReconnectFailed(() => {
|
|
59
|
+
* showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
|
|
60
|
+
* });
|
|
61
|
+
*/
|
|
62
|
+
onReconnectFailed(fn: () => void): Unsubscribe;
|
|
63
|
+
/**
|
|
64
|
+
* Manually trigger a reconnect. Resets the retry counter and attempts a
|
|
65
|
+
* fresh connection. Safe to call from any tab — the leader actually owns
|
|
66
|
+
* the socket, followers route the request via BroadcastChannel.
|
|
67
|
+
*
|
|
68
|
+
* Use after `onReconnectFailed` fires to let the user retry.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* snackbar.action('Reconnect', () => ws.reconnect());
|
|
72
|
+
*/
|
|
73
|
+
reconnect(): void;
|
|
52
74
|
/** Called when this tab becomes leader or loses leadership. */
|
|
53
75
|
onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe;
|
|
54
76
|
/** Called on WebSocket or network error (broadcast to all tabs). */
|
package/dist/TabSync.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import './utils/disposable';
|
|
2
|
-
import type { Unsubscribe } from './types';
|
|
2
|
+
import type { Unsubscribe, Logger } from './types';
|
|
3
|
+
interface TabSyncOptions {
|
|
4
|
+
/** Enable debug logging (default: false). */
|
|
5
|
+
debug?: boolean;
|
|
6
|
+
/** Custom logger (default: console). */
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
}
|
|
3
9
|
/**
|
|
4
10
|
* Cross-tab state synchronization via BroadcastChannel.
|
|
5
11
|
* No WebSocket needed — works standalone for sharing state between browser tabs.
|
|
@@ -8,13 +14,18 @@ import type { Unsubscribe } from './types';
|
|
|
8
14
|
* const sync = new TabSync('my-app');
|
|
9
15
|
* sync.set('theme', 'dark');
|
|
10
16
|
* sync.on('theme', (theme) => applyTheme(theme));
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // With debug logging
|
|
20
|
+
* const sync = new TabSync('my-app', { debug: true });
|
|
11
21
|
*/
|
|
12
22
|
export declare class TabSync implements Disposable {
|
|
13
23
|
private store;
|
|
14
24
|
private listeners;
|
|
15
25
|
private bc;
|
|
16
26
|
private disposed;
|
|
17
|
-
|
|
27
|
+
private readonly log;
|
|
28
|
+
constructor(channel?: string, options?: TabSyncOptions);
|
|
18
29
|
/** Set a value and broadcast to all tabs. Local listeners also fire. */
|
|
19
30
|
set<T>(key: string, value: T): void;
|
|
20
31
|
/** Get current value from local store. */
|
|
@@ -43,3 +54,4 @@ export declare class TabSync implements Disposable {
|
|
|
43
54
|
private emit;
|
|
44
55
|
[Symbol.dispose](): void;
|
|
45
56
|
}
|
|
57
|
+
export {};
|
package/dist/WorkerSocket.d.ts
CHANGED
|
@@ -27,13 +27,21 @@ export declare class WorkerSocket implements Disposable {
|
|
|
27
27
|
protocols?: string[];
|
|
28
28
|
reconnect?: boolean;
|
|
29
29
|
reconnectMaxDelay?: number;
|
|
30
|
+
reconnectMaxRetries?: number;
|
|
30
31
|
heartbeatInterval?: number;
|
|
31
32
|
sendBuffer?: number;
|
|
32
33
|
workerUrl?: string | URL;
|
|
34
|
+
auth?: () => string | Promise<string>;
|
|
35
|
+
authToken?: string;
|
|
36
|
+
authParam?: string;
|
|
37
|
+
pingPayload?: unknown;
|
|
33
38
|
});
|
|
34
39
|
get state(): SocketState;
|
|
35
|
-
connect(): void
|
|
40
|
+
connect(): Promise<void>;
|
|
41
|
+
private buildUrl;
|
|
36
42
|
send(data: unknown): void;
|
|
43
|
+
/** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
|
|
44
|
+
reconnect(): void;
|
|
37
45
|
disconnect(): void;
|
|
38
46
|
onMessage(fn: EventHandler): Unsubscribe;
|
|
39
47
|
onStateChange(fn: (state: SocketState) => void): Unsubscribe;
|
package/dist/adapters/react.d.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -15,9 +15,11 @@ import { TabSync } from '../TabSync';
|
|
|
15
15
|
export interface TabSyncProviderProps {
|
|
16
16
|
/** BroadcastChannel name (default: "tab-sync"). */
|
|
17
17
|
channel?: string;
|
|
18
|
+
/** Enable debug logging. */
|
|
19
|
+
debug?: boolean;
|
|
18
20
|
children: ReactNode;
|
|
19
21
|
}
|
|
20
|
-
export declare function TabSyncProvider({ channel, children }: TabSyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<TabSync | null>>;
|
|
22
|
+
export declare function TabSyncProvider({ channel, debug, children }: TabSyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<TabSync | null>>;
|
|
21
23
|
/**
|
|
22
24
|
* Access the TabSync instance from context.
|
|
23
25
|
*
|
|
@@ -8,7 +8,9 @@ export declare const TabSyncKey: InjectionKey<TabSync>;
|
|
|
8
8
|
* const app = createApp(App);
|
|
9
9
|
* app.use(createTabSyncPlugin('my-app'));
|
|
10
10
|
*/
|
|
11
|
-
export declare function createTabSyncPlugin(channel?: string
|
|
11
|
+
export declare function createTabSyncPlugin(channel?: string, options?: {
|
|
12
|
+
debug?: boolean;
|
|
13
|
+
}): {
|
|
12
14
|
install(app: App): void;
|
|
13
15
|
};
|
|
14
16
|
/**
|
package/dist/adapters/vue.d.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
// src/TabSync.ts
|
|
2
|
+
var NOOP_LOGGER = { debug() {
|
|
3
|
+
}, info() {
|
|
4
|
+
}, warn() {
|
|
5
|
+
}, error() {
|
|
6
|
+
} };
|
|
2
7
|
var TabSync = class {
|
|
3
8
|
store = /* @__PURE__ */ new Map();
|
|
4
9
|
listeners = /* @__PURE__ */ new Map();
|
|
5
10
|
bc;
|
|
6
11
|
disposed = false;
|
|
7
|
-
|
|
12
|
+
log;
|
|
13
|
+
constructor(channel = "tab-sync", options) {
|
|
14
|
+
this.log = options?.debug ? options.logger ?? console : NOOP_LOGGER;
|
|
15
|
+
this.log.debug("[TabSync] init", { channel });
|
|
8
16
|
this.bc = new BroadcastChannel(channel);
|
|
9
17
|
this.bc.onmessage = (ev) => {
|
|
10
18
|
const { key, value, deleted } = ev.data;
|
|
11
19
|
if (deleted) {
|
|
12
20
|
this.store.delete(key);
|
|
21
|
+
this.log.debug("[TabSync] \u2190 remote delete", key);
|
|
13
22
|
} else {
|
|
14
23
|
this.store.set(key, value);
|
|
24
|
+
this.log.debug("[TabSync] \u2190 remote set", key, value);
|
|
15
25
|
}
|
|
16
26
|
this.emit(key, value);
|
|
17
27
|
};
|
|
@@ -20,6 +30,7 @@ var TabSync = class {
|
|
|
20
30
|
set(key, value) {
|
|
21
31
|
this.store.set(key, value);
|
|
22
32
|
this.bc.postMessage({ key, value });
|
|
33
|
+
this.log.debug("[TabSync] \u2192 set", key, value);
|
|
23
34
|
this.emit(key, value);
|
|
24
35
|
}
|
|
25
36
|
/** Get current value from local store. */
|
|
@@ -30,6 +41,7 @@ var TabSync = class {
|
|
|
30
41
|
delete(key) {
|
|
31
42
|
this.store.delete(key);
|
|
32
43
|
this.bc.postMessage({ key, value: void 0, deleted: true });
|
|
44
|
+
this.log.debug("[TabSync] \u2192 delete", key);
|
|
33
45
|
this.emit(key, void 0);
|
|
34
46
|
}
|
|
35
47
|
/** Check if a key exists in the local store. */
|
|
@@ -71,6 +83,7 @@ var TabSync = class {
|
|
|
71
83
|
/** Clear all keys and notify listeners. */
|
|
72
84
|
clear() {
|
|
73
85
|
const keys = [...this.store.keys()];
|
|
86
|
+
this.log.debug("[TabSync] \u2192 clear", keys);
|
|
74
87
|
this.store.clear();
|
|
75
88
|
for (const key of keys) {
|
|
76
89
|
this.bc.postMessage({ key, value: void 0, deleted: true });
|
|
@@ -99,4 +112,4 @@ var TabSync = class {
|
|
|
99
112
|
export {
|
|
100
113
|
TabSync
|
|
101
114
|
};
|
|
102
|
-
//# sourceMappingURL=chunk-
|
|
115
|
+
//# sourceMappingURL=chunk-7WBM2C7H.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/TabSync.ts"],"sourcesContent":["import './utils/disposable';\nimport type { Unsubscribe, Logger } from './types';\n\ninterface SyncMessage {\n key: string;\n value: unknown;\n deleted?: boolean;\n}\n\ninterface TabSyncOptions {\n /** Enable debug logging (default: false). */\n debug?: boolean;\n /** Custom logger (default: console). */\n logger?: Logger;\n}\n\nconst NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };\n\n/**\n * Cross-tab state synchronization via BroadcastChannel.\n * No WebSocket needed — works standalone for sharing state between browser tabs.\n *\n * @example\n * const sync = new TabSync('my-app');\n * sync.set('theme', 'dark');\n * sync.on('theme', (theme) => applyTheme(theme));\n *\n * @example\n * // With debug logging\n * const sync = new TabSync('my-app', { debug: true });\n */\nexport class TabSync implements Disposable {\n private store = new Map<string, unknown>();\n private listeners = new Map<string, Set<(value: unknown) => void>>();\n private bc: BroadcastChannel;\n private disposed = false;\n private readonly log: Logger;\n\n constructor(channel = 'tab-sync', options?: TabSyncOptions) {\n this.log = options?.debug ? (options.logger ?? console) : NOOP_LOGGER;\n this.log.debug('[TabSync] init', { channel });\n this.bc = new BroadcastChannel(channel);\n this.bc.onmessage = (ev: MessageEvent<SyncMessage>) => {\n const { key, value, deleted } = ev.data;\n if (deleted) {\n this.store.delete(key);\n this.log.debug('[TabSync] ← remote delete', key);\n } else {\n this.store.set(key, value);\n this.log.debug('[TabSync] ← remote set', key, value);\n }\n this.emit(key, value);\n };\n }\n\n /** Set a value and broadcast to all tabs. Local listeners also fire. */\n set<T>(key: string, value: T): void {\n this.store.set(key, value);\n this.bc.postMessage({ key, value } satisfies SyncMessage);\n this.log.debug('[TabSync] → set', key, value);\n this.emit(key, value);\n }\n\n /** Get current value from local store. */\n get<T>(key: string): T | undefined {\n return this.store.get(key) as T | undefined;\n }\n\n /** Delete a key and broadcast deletion to all tabs. */\n delete(key: string): void {\n this.store.delete(key);\n this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);\n this.log.debug('[TabSync] → delete', key);\n this.emit(key, undefined);\n }\n\n /** Check if a key exists in the local store. */\n has(key: string): boolean {\n return this.store.has(key);\n }\n\n /** Get all keys in the local store. */\n keys(): string[] {\n return [...this.store.keys()];\n }\n\n /** Get number of entries. */\n get size(): number {\n return this.store.size;\n }\n\n /**\n * Listen for changes to a key. Fires when any tab (including this one) calls set().\n *\n * @example\n * sync.on('cart', (cart) => updateBadge(cart.items.length));\n */\n on<T>(key: string, fn: (value: T) => void): Unsubscribe {\n let set = this.listeners.get(key);\n if (!set) {\n set = new Set();\n this.listeners.set(key, set);\n }\n const wrapper = fn as (value: unknown) => void;\n set.add(wrapper);\n return () => set!.delete(wrapper);\n }\n\n /** Listen for a key change once, then auto-unsubscribe. */\n once<T>(key: string, fn: (value: T) => void): Unsubscribe {\n const unsub = this.on<T>(key, (value) => {\n unsub();\n fn(value);\n });\n return unsub;\n }\n\n /** Clear all keys and notify listeners. */\n clear(): void {\n const keys = [...this.store.keys()];\n this.log.debug('[TabSync] → clear', keys);\n this.store.clear();\n for (const key of keys) {\n this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);\n this.emit(key, undefined);\n }\n }\n\n /** Dispose — close BroadcastChannel and clear all state. */\n dispose(): void {\n this[Symbol.dispose]();\n }\n\n private emit(key: string, value: unknown): void {\n const set = this.listeners.get(key);\n if (set) {\n for (const fn of set) fn(value);\n }\n }\n\n [Symbol.dispose](): void {\n if (this.disposed) return;\n this.disposed = true;\n this.bc.close();\n this.store.clear();\n this.listeners.clear();\n }\n}\n"],"mappings":";AAgBA,IAAM,cAAsB,EAAE,QAAQ;AAAC,GAAG,OAAO;AAAC,GAAG,OAAO;AAAC,GAAG,QAAQ;AAAC,EAAE;AAepE,IAAM,UAAN,MAAoC;AAAA,EACjC,QAAQ,oBAAI,IAAqB;AAAA,EACjC,YAAY,oBAAI,IAA2C;AAAA,EAC3D;AAAA,EACA,WAAW;AAAA,EACF;AAAA,EAEjB,YAAY,UAAU,YAAY,SAA0B;AAC1D,SAAK,MAAM,SAAS,QAAS,QAAQ,UAAU,UAAW;AAC1D,SAAK,IAAI,MAAM,kBAAkB,EAAE,QAAQ,CAAC;AAC5C,SAAK,KAAK,IAAI,iBAAiB,OAAO;AACtC,SAAK,GAAG,YAAY,CAAC,OAAkC;AACrD,YAAM,EAAE,KAAK,OAAO,QAAQ,IAAI,GAAG;AACnC,UAAI,SAAS;AACX,aAAK,MAAM,OAAO,GAAG;AACrB,aAAK,IAAI,MAAM,kCAA6B,GAAG;AAAA,MACjD,OAAO;AACL,aAAK,MAAM,IAAI,KAAK,KAAK;AACzB,aAAK,IAAI,MAAM,+BAA0B,KAAK,KAAK;AAAA,MACrD;AACA,WAAK,KAAK,KAAK,KAAK;AAAA,IACtB;AAAA,EACF;AAAA;AAAA,EAGA,IAAO,KAAa,OAAgB;AAClC,SAAK,MAAM,IAAI,KAAK,KAAK;AACzB,SAAK,GAAG,YAAY,EAAE,KAAK,MAAM,CAAuB;AACxD,SAAK,IAAI,MAAM,wBAAmB,KAAK,KAAK;AAC5C,SAAK,KAAK,KAAK,KAAK;AAAA,EACtB;AAAA;AAAA,EAGA,IAAO,KAA4B;AACjC,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC3B;AAAA;AAAA,EAGA,OAAO,KAAmB;AACxB,SAAK,MAAM,OAAO,GAAG;AACrB,SAAK,GAAG,YAAY,EAAE,KAAK,OAAO,QAAW,SAAS,KAAK,CAAuB;AAClF,SAAK,IAAI,MAAM,2BAAsB,GAAG;AACxC,SAAK,KAAK,KAAK,MAAS;AAAA,EAC1B;AAAA;AAAA,EAGA,IAAI,KAAsB;AACxB,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC3B;AAAA;AAAA,EAGA,OAAiB;AACf,WAAO,CAAC,GAAG,KAAK,MAAM,KAAK,CAAC;AAAA,EAC9B;AAAA;AAAA,EAGA,IAAI,OAAe;AACjB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,GAAM,KAAa,IAAqC;AACtD,QAAI,MAAM,KAAK,UAAU,IAAI,GAAG;AAChC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,UAAU,IAAI,KAAK,GAAG;AAAA,IAC7B;AACA,UAAM,UAAU;AAChB,QAAI,IAAI,OAAO;AACf,WAAO,MAAM,IAAK,OAAO,OAAO;AAAA,EAClC;AAAA;AAAA,EAGA,KAAQ,KAAa,IAAqC;AACxD,UAAM,QAAQ,KAAK,GAAM,KAAK,CAAC,UAAU;AACvC,YAAM;AACN,SAAG,KAAK;AAAA,IACV,CAAC;AACD,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,QAAc;AACZ,UAAM,OAAO,CAAC,GAAG,KAAK,MAAM,KAAK,CAAC;AAClC,SAAK,IAAI,MAAM,0BAAqB,IAAI;AACxC,SAAK,MAAM,MAAM;AACjB,eAAW,OAAO,MAAM;AACtB,WAAK,GAAG,YAAY,EAAE,KAAK,OAAO,QAAW,SAAS,KAAK,CAAuB;AAClF,WAAK,KAAK,KAAK,MAAS;AAAA,IAC1B;AAAA,EACF;AAAA;AAAA,EAGA,UAAgB;AACd,SAAK,OAAO,OAAO,EAAE;AAAA,EACvB;AAAA,EAEQ,KAAK,KAAa,OAAsB;AAC9C,UAAM,MAAM,KAAK,UAAU,IAAI,GAAG;AAClC,QAAI,KAAK;AACP,iBAAW,MAAM,IAAK,IAAG,KAAK;AAAA,IAChC;AAAA,EACF;AAAA,EAEA,CAAC,OAAO,OAAO,IAAU;AACvB,QAAI,KAAK,SAAU;AACnB,SAAK,WAAW;AAChB,SAAK,GAAG,MAAM;AACd,SAAK,MAAM,MAAM;AACjB,SAAK,UAAU,MAAM;AAAA,EACvB;AACF;","names":[]}
|
|
@@ -295,7 +295,7 @@ var SharedSocket = class {
|
|
|
295
295
|
this.ws.onclose = () => {
|
|
296
296
|
this.stopHeartbeat();
|
|
297
297
|
if (!this.disposed && this.opts.reconnect) {
|
|
298
|
-
this.
|
|
298
|
+
this.scheduleReconnect();
|
|
299
299
|
} else {
|
|
300
300
|
this.setState("closed");
|
|
301
301
|
}
|
|
@@ -335,10 +335,31 @@ var SharedSocket = class {
|
|
|
335
335
|
this.onStateChangeFns.add(fn);
|
|
336
336
|
return () => this.onStateChangeFns.delete(fn);
|
|
337
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Manually trigger a reconnect. Resets the retry counter and clears any
|
|
340
|
+
* scheduled backoff so the next attempt happens immediately. Use after
|
|
341
|
+
* `state === 'failed'` to let the user retry, or any time to force a
|
|
342
|
+
* fresh connection.
|
|
343
|
+
*/
|
|
338
344
|
reconnect() {
|
|
345
|
+
if (this.disposed) return;
|
|
346
|
+
this.clearReconnect();
|
|
347
|
+
this.reconnectAttempts = 0;
|
|
348
|
+
if (this.ws) {
|
|
349
|
+
this.ws.onclose = null;
|
|
350
|
+
this.ws.onmessage = null;
|
|
351
|
+
this.ws.onerror = null;
|
|
352
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
353
|
+
this.ws.close(1e3, "manual reconnect");
|
|
354
|
+
}
|
|
355
|
+
this.ws = null;
|
|
356
|
+
}
|
|
357
|
+
void this.connect();
|
|
358
|
+
}
|
|
359
|
+
scheduleReconnect() {
|
|
339
360
|
this.reconnectAttempts++;
|
|
340
361
|
if (this.reconnectAttempts > this.opts.reconnectMaxRetries) {
|
|
341
|
-
this.setState("
|
|
362
|
+
this.setState("failed");
|
|
342
363
|
return;
|
|
343
364
|
}
|
|
344
365
|
this.setState("reconnecting");
|
|
@@ -418,7 +439,14 @@ var WorkerSocket = class {
|
|
|
418
439
|
get state() {
|
|
419
440
|
return this._state;
|
|
420
441
|
}
|
|
421
|
-
connect() {
|
|
442
|
+
async connect() {
|
|
443
|
+
let authToken;
|
|
444
|
+
if (this.options.auth) {
|
|
445
|
+
authToken = await this.options.auth();
|
|
446
|
+
} else if (this.options.authToken) {
|
|
447
|
+
authToken = this.options.authToken;
|
|
448
|
+
}
|
|
449
|
+
const connectUrl = authToken ? this.buildUrl(authToken) : this.url;
|
|
422
450
|
const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();
|
|
423
451
|
this.worker = new Worker(workerUrl, { type: "module" });
|
|
424
452
|
this.worker.onmessage = (ev) => {
|
|
@@ -442,17 +470,30 @@ var WorkerSocket = class {
|
|
|
442
470
|
};
|
|
443
471
|
this.worker.postMessage({
|
|
444
472
|
type: "connect",
|
|
445
|
-
url:
|
|
473
|
+
url: connectUrl,
|
|
446
474
|
protocols: this.options.protocols ?? [],
|
|
447
475
|
reconnect: this.options.reconnect ?? true,
|
|
448
476
|
reconnectMaxDelay: this.options.reconnectMaxDelay ?? 3e4,
|
|
477
|
+
reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
|
|
449
478
|
heartbeatInterval: this.options.heartbeatInterval ?? 3e4,
|
|
450
|
-
bufferSize: this.options.sendBuffer ?? 100
|
|
479
|
+
bufferSize: this.options.sendBuffer ?? 100,
|
|
480
|
+
pingPayload: this.options.pingPayload
|
|
451
481
|
});
|
|
452
482
|
}
|
|
483
|
+
buildUrl(token) {
|
|
484
|
+
const param = this.options.authParam ?? "token";
|
|
485
|
+
const httpUrl = this.url.replace(/^ws(s?):\/\//, "http$1://");
|
|
486
|
+
const parsed = new URL(httpUrl);
|
|
487
|
+
parsed.searchParams.set(param, token);
|
|
488
|
+
return parsed.toString().replace(/^http(s?):\/\//, "ws$1://");
|
|
489
|
+
}
|
|
453
490
|
send(data) {
|
|
454
491
|
this.worker?.postMessage({ type: "send", data });
|
|
455
492
|
}
|
|
493
|
+
/** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
|
|
494
|
+
reconnect() {
|
|
495
|
+
this.worker?.postMessage({ type: "reconnect" });
|
|
496
|
+
}
|
|
456
497
|
disconnect() {
|
|
457
498
|
this.worker?.postMessage({ type: "disconnect" });
|
|
458
499
|
setTimeout(() => {
|
|
@@ -474,27 +515,43 @@ var WorkerSocket = class {
|
|
|
474
515
|
let ws = null, state = 'closed', buffer = [], disposed = false;
|
|
475
516
|
let heartbeatTimer = null, reconnectTimer = null;
|
|
476
517
|
let url = '', protocols = [], shouldReconnect = true;
|
|
477
|
-
let maxDelay = 30000,
|
|
518
|
+
let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
|
|
519
|
+
let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
|
|
478
520
|
|
|
479
521
|
function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
|
|
480
522
|
function connect() {
|
|
481
523
|
if (disposed) return;
|
|
482
524
|
setState('connecting');
|
|
483
525
|
ws = new WebSocket(url, protocols);
|
|
484
|
-
ws.onopen = () => {
|
|
526
|
+
ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
|
|
485
527
|
ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
|
|
486
528
|
ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
|
|
487
529
|
ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
|
|
488
530
|
}
|
|
489
531
|
function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
|
|
490
532
|
function flush() { const p = buffer.splice(0); p.forEach(send); }
|
|
491
|
-
function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(
|
|
533
|
+
function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(pingPayload); }, hbInterval); }
|
|
492
534
|
function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
|
|
493
|
-
function reconnect() {
|
|
535
|
+
function reconnect() {
|
|
536
|
+
attempts++;
|
|
537
|
+
if (attempts > maxRetries) { setState('failed'); return; }
|
|
538
|
+
setState('reconnecting');
|
|
539
|
+
const j = delay * 0.25 * (Math.random() * 2 - 1);
|
|
540
|
+
reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));
|
|
541
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
542
|
+
}
|
|
543
|
+
function manualReconnect() {
|
|
544
|
+
if (disposed) return;
|
|
545
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
546
|
+
attempts = 0; delay = 1000;
|
|
547
|
+
if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }
|
|
548
|
+
connect();
|
|
549
|
+
}
|
|
494
550
|
self.onmessage = (e) => {
|
|
495
551
|
const c = e.data;
|
|
496
|
-
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(); }
|
|
552
|
+
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(); }
|
|
497
553
|
if (c.type === 'send') send(c.data);
|
|
554
|
+
if (c.type === 'reconnect') manualReconnect();
|
|
498
555
|
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'); }
|
|
499
556
|
};
|
|
500
557
|
`;
|
|
@@ -634,6 +691,14 @@ var SharedWebSocket = class {
|
|
|
634
691
|
}
|
|
635
692
|
})
|
|
636
693
|
);
|
|
694
|
+
this.cleanups.push(
|
|
695
|
+
this.bus.subscribe("ws:reconnect", () => {
|
|
696
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
697
|
+
this.log.info("[SharedWS] manual reconnect requested by follower");
|
|
698
|
+
this.socket.reconnect();
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
);
|
|
637
702
|
this.cleanups.push(
|
|
638
703
|
this.bus.subscribe("ws:sync", (msg) => {
|
|
639
704
|
this.syncStore.set(msg.key, msg.value);
|
|
@@ -660,6 +725,9 @@ var SharedWebSocket = class {
|
|
|
660
725
|
case "reconnecting":
|
|
661
726
|
this.subs.emit("$lifecycle:reconnecting", void 0);
|
|
662
727
|
break;
|
|
728
|
+
case "reconnectFailed":
|
|
729
|
+
this.subs.emit("$lifecycle:reconnectFailed", void 0);
|
|
730
|
+
break;
|
|
663
731
|
case "leader":
|
|
664
732
|
this.subs.emit("$lifecycle:leader", msg.isLeader);
|
|
665
733
|
break;
|
|
@@ -757,6 +825,36 @@ var SharedWebSocket = class {
|
|
|
757
825
|
onReconnecting(fn) {
|
|
758
826
|
return this.subs.on("$lifecycle:reconnecting", fn);
|
|
759
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
|
|
830
|
+
* Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
|
|
831
|
+
* so the user can call `ws.reconnect()` to try again.
|
|
832
|
+
*
|
|
833
|
+
* @example
|
|
834
|
+
* ws.onReconnectFailed(() => {
|
|
835
|
+
* showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
|
|
836
|
+
* });
|
|
837
|
+
*/
|
|
838
|
+
onReconnectFailed(fn) {
|
|
839
|
+
return this.subs.on("$lifecycle:reconnectFailed", fn);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Manually trigger a reconnect. Resets the retry counter and attempts a
|
|
843
|
+
* fresh connection. Safe to call from any tab — the leader actually owns
|
|
844
|
+
* the socket, followers route the request via BroadcastChannel.
|
|
845
|
+
*
|
|
846
|
+
* Use after `onReconnectFailed` fires to let the user retry.
|
|
847
|
+
*
|
|
848
|
+
* @example
|
|
849
|
+
* snackbar.action('Reconnect', () => ws.reconnect());
|
|
850
|
+
*/
|
|
851
|
+
reconnect() {
|
|
852
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
853
|
+
this.socket.reconnect();
|
|
854
|
+
} else {
|
|
855
|
+
this.bus.publish("ws:reconnect", void 0);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
760
858
|
/** Called when this tab becomes leader or loses leadership. */
|
|
761
859
|
onLeaderChange(fn) {
|
|
762
860
|
return this.subs.on("$lifecycle:leader", fn);
|
|
@@ -1103,7 +1201,10 @@ var SharedWebSocket = class {
|
|
|
1103
1201
|
if (this.options.useWorker) {
|
|
1104
1202
|
return new WorkerSocket(this.url, {
|
|
1105
1203
|
...socketOptions,
|
|
1106
|
-
workerUrl: this.options.workerUrl
|
|
1204
|
+
workerUrl: this.options.workerUrl,
|
|
1205
|
+
auth: this.options.auth,
|
|
1206
|
+
authToken: this.options.authToken,
|
|
1207
|
+
authParam: this.options.authParam
|
|
1107
1208
|
});
|
|
1108
1209
|
}
|
|
1109
1210
|
return new SharedSocket(this.url, {
|
|
@@ -1138,7 +1239,7 @@ var SharedWebSocket = class {
|
|
|
1138
1239
|
this.bus.broadcast("ws:message", { event, data: payload });
|
|
1139
1240
|
});
|
|
1140
1241
|
this.socket.onStateChange((state) => {
|
|
1141
|
-
this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : `state: ${state}`);
|
|
1242
|
+
this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : state === "failed" ? "\u2717 reconnect failed" : `state: ${state}`);
|
|
1142
1243
|
switch (state) {
|
|
1143
1244
|
case "connected":
|
|
1144
1245
|
this.bus.broadcast("ws:lifecycle", { type: "connect" });
|
|
@@ -1150,6 +1251,10 @@ var SharedWebSocket = class {
|
|
|
1150
1251
|
case "reconnecting":
|
|
1151
1252
|
this.bus.broadcast("ws:lifecycle", { type: "reconnecting" });
|
|
1152
1253
|
break;
|
|
1254
|
+
case "failed":
|
|
1255
|
+
this.bus.broadcast("ws:lifecycle", { type: "reconnectFailed" });
|
|
1256
|
+
this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
|
|
1257
|
+
break;
|
|
1153
1258
|
}
|
|
1154
1259
|
});
|
|
1155
1260
|
this.cleanups.push(
|
|
@@ -1166,7 +1271,7 @@ var SharedWebSocket = class {
|
|
|
1166
1271
|
});
|
|
1167
1272
|
})
|
|
1168
1273
|
);
|
|
1169
|
-
this.socket.connect();
|
|
1274
|
+
void this.socket.connect();
|
|
1170
1275
|
}
|
|
1171
1276
|
reAuthenticateOnReconnect() {
|
|
1172
1277
|
if (!this._isAuthenticated || !this.socket) return;
|
|
@@ -1223,4 +1328,4 @@ export {
|
|
|
1223
1328
|
SubscriptionManager,
|
|
1224
1329
|
SharedWebSocket
|
|
1225
1330
|
};
|
|
1226
|
-
//# sourceMappingURL=chunk-
|
|
1331
|
+
//# sourceMappingURL=chunk-IK4HLA3K.js.map
|