@gwakko/shared-websocket 0.2.0 → 0.3.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 +53 -10
- package/dist/SharedSocket.d.ts +3 -0
- package/dist/adapters/vue.d.ts +57 -7
- package/dist/{chunk-TNEMKPGP.js → chunk-JJTAPRPG.js} +21 -9
- package/dist/chunk-JJTAPRPG.js.map +1 -0
- package/dist/{chunk-SMH3X34N.cjs → chunk-Q4OKSJX7.cjs} +21 -9
- package/dist/chunk-Q4OKSJX7.cjs.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +1 -1
- package/dist/react.cjs +2 -2
- package/dist/react.js +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/vue.cjs +27 -11
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +24 -8
- package/dist/vue.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedSocket.ts +30 -8
- package/src/SharedWebSocket.ts +2 -0
- package/src/adapters/vue.ts +78 -14
- package/src/types.ts +5 -0
- package/dist/chunk-SMH3X34N.cjs.map +0 -1
- package/dist/chunk-TNEMKPGP.js.map +0 -1
package/dist/vue.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var _chunkQ4OKSJX7cjs = require('./chunk-Q4OKSJX7.cjs');
|
|
4
4
|
|
|
5
5
|
// src/adapters/vue.ts
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ var SharedWebSocketKey = /* @__PURE__ */ Symbol("SharedWebSocket");
|
|
|
14
14
|
function createSharedWebSocketPlugin(url, options) {
|
|
15
15
|
return {
|
|
16
16
|
install(app) {
|
|
17
|
-
const socket = new (0,
|
|
17
|
+
const socket = new (0, _chunkQ4OKSJX7cjs.SharedWebSocket)(url, options);
|
|
18
18
|
socket.connect();
|
|
19
19
|
app.provide(SharedWebSocketKey, socket);
|
|
20
20
|
const originalUnmount = app.unmount.bind(app);
|
|
@@ -32,29 +32,38 @@ function useSharedWebSocket() {
|
|
|
32
32
|
}
|
|
33
33
|
return socket;
|
|
34
34
|
}
|
|
35
|
-
function useSocketEvent(event) {
|
|
35
|
+
function useSocketEvent(event, callback) {
|
|
36
36
|
const socket = useSharedWebSocket();
|
|
37
37
|
const value = _vue.ref.call(void 0, void 0);
|
|
38
38
|
const unsub = socket.on(event, (data) => {
|
|
39
|
-
|
|
39
|
+
if (callback) {
|
|
40
|
+
callback(data);
|
|
41
|
+
} else {
|
|
42
|
+
value.value = data;
|
|
43
|
+
}
|
|
40
44
|
});
|
|
41
45
|
_vue.onUnmounted.call(void 0, unsub);
|
|
42
46
|
return _vue.readonly.call(void 0, value);
|
|
43
47
|
}
|
|
44
|
-
function useSocketStream(event) {
|
|
48
|
+
function useSocketStream(event, callback) {
|
|
45
49
|
const socket = useSharedWebSocket();
|
|
46
50
|
const items = _vue.ref.call(void 0, []);
|
|
47
51
|
const unsub = socket.on(event, (data) => {
|
|
48
|
-
|
|
52
|
+
if (callback) {
|
|
53
|
+
callback(data);
|
|
54
|
+
} else {
|
|
55
|
+
items.value = [...items.value, data];
|
|
56
|
+
}
|
|
49
57
|
});
|
|
50
58
|
_vue.onUnmounted.call(void 0, unsub);
|
|
51
59
|
return _vue.readonly.call(void 0, items);
|
|
52
60
|
}
|
|
53
|
-
function useSocketSync(key, initialValue) {
|
|
61
|
+
function useSocketSync(key, initialValue, callback) {
|
|
54
62
|
const socket = useSharedWebSocket();
|
|
55
63
|
const value = _vue.ref.call(void 0, _nullishCoalesce(socket.getSync(key), () => ( initialValue)));
|
|
56
64
|
const unsub = socket.onSync(key, (v) => {
|
|
57
65
|
value.value = v;
|
|
66
|
+
_optionalChain([callback, 'optionalCall', _ => _(v)]);
|
|
58
67
|
});
|
|
59
68
|
_vue.watch.call(void 0,
|
|
60
69
|
value,
|
|
@@ -66,12 +75,18 @@ function useSocketSync(key, initialValue) {
|
|
|
66
75
|
_vue.onUnmounted.call(void 0, unsub);
|
|
67
76
|
return value;
|
|
68
77
|
}
|
|
78
|
+
function useSocketCallback(event, callback) {
|
|
79
|
+
const socket = useSharedWebSocket();
|
|
80
|
+
const unsub = socket.on(event, (data) => {
|
|
81
|
+
callback(data);
|
|
82
|
+
});
|
|
83
|
+
_vue.onUnmounted.call(void 0, unsub);
|
|
84
|
+
}
|
|
69
85
|
function useSocketStatus() {
|
|
70
86
|
const socket = useSharedWebSocket();
|
|
71
87
|
const connected = _vue.ref.call(void 0, socket.connected);
|
|
72
88
|
const tabRole = _vue.ref.call(void 0, socket.tabRole);
|
|
73
|
-
|
|
74
|
-
timer = setInterval(() => {
|
|
89
|
+
const timer = setInterval(() => {
|
|
75
90
|
connected.value = socket.connected;
|
|
76
91
|
tabRole.value = socket.tabRole;
|
|
77
92
|
}, 1e3);
|
|
@@ -89,5 +104,6 @@ function useSocketStatus() {
|
|
|
89
104
|
|
|
90
105
|
|
|
91
106
|
|
|
92
|
-
|
|
107
|
+
|
|
108
|
+
exports.SharedWebSocketKey = SharedWebSocketKey; exports.createSharedWebSocketPlugin = createSharedWebSocketPlugin; exports.useSharedWebSocket = useSharedWebSocket; exports.useSocketCallback = useSocketCallback; exports.useSocketEvent = useSocketEvent; exports.useSocketStatus = useSocketStatus; exports.useSocketStream = useSocketStream; exports.useSocketSync = useSocketSync;
|
|
93
109
|
//# sourceMappingURL=vue.cjs.map
|
package/dist/vue.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","../src/adapters/vue.ts"],"names":[],"mappings":"AAAA;AACE;AACF,wDAA6B;AAC7B;AACA;ACJA;AACE;AACA;AACA;AACA;AACA;AAAA,0BAIK;AAMA,IAAM,mBAAA,kBAAoD,MAAA,CAAO,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","../src/adapters/vue.ts"],"names":[],"mappings":"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","file":"/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","sourcesContent":[null,"import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const unsub = socket.on(event, (data: T) => {\n if (callback) {\n callback(data);\n } else {\n value.value = data;\n }\n });\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const unsub = socket.on(event, (data: T) => {\n if (callback) {\n callback(data);\n } else {\n items.value = [...items.value, data];\n }\n });\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: T) => {\n callback(data);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n"]}
|
package/dist/vue.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SharedWebSocket
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-JJTAPRPG.js";
|
|
4
4
|
|
|
5
5
|
// src/adapters/vue.ts
|
|
6
6
|
import {
|
|
@@ -32,29 +32,38 @@ function useSharedWebSocket() {
|
|
|
32
32
|
}
|
|
33
33
|
return socket;
|
|
34
34
|
}
|
|
35
|
-
function useSocketEvent(event) {
|
|
35
|
+
function useSocketEvent(event, callback) {
|
|
36
36
|
const socket = useSharedWebSocket();
|
|
37
37
|
const value = ref(void 0);
|
|
38
38
|
const unsub = socket.on(event, (data) => {
|
|
39
|
-
|
|
39
|
+
if (callback) {
|
|
40
|
+
callback(data);
|
|
41
|
+
} else {
|
|
42
|
+
value.value = data;
|
|
43
|
+
}
|
|
40
44
|
});
|
|
41
45
|
onUnmounted(unsub);
|
|
42
46
|
return readonly(value);
|
|
43
47
|
}
|
|
44
|
-
function useSocketStream(event) {
|
|
48
|
+
function useSocketStream(event, callback) {
|
|
45
49
|
const socket = useSharedWebSocket();
|
|
46
50
|
const items = ref([]);
|
|
47
51
|
const unsub = socket.on(event, (data) => {
|
|
48
|
-
|
|
52
|
+
if (callback) {
|
|
53
|
+
callback(data);
|
|
54
|
+
} else {
|
|
55
|
+
items.value = [...items.value, data];
|
|
56
|
+
}
|
|
49
57
|
});
|
|
50
58
|
onUnmounted(unsub);
|
|
51
59
|
return readonly(items);
|
|
52
60
|
}
|
|
53
|
-
function useSocketSync(key, initialValue) {
|
|
61
|
+
function useSocketSync(key, initialValue, callback) {
|
|
54
62
|
const socket = useSharedWebSocket();
|
|
55
63
|
const value = ref(socket.getSync(key) ?? initialValue);
|
|
56
64
|
const unsub = socket.onSync(key, (v) => {
|
|
57
65
|
value.value = v;
|
|
66
|
+
callback?.(v);
|
|
58
67
|
});
|
|
59
68
|
watch(
|
|
60
69
|
value,
|
|
@@ -66,12 +75,18 @@ function useSocketSync(key, initialValue) {
|
|
|
66
75
|
onUnmounted(unsub);
|
|
67
76
|
return value;
|
|
68
77
|
}
|
|
78
|
+
function useSocketCallback(event, callback) {
|
|
79
|
+
const socket = useSharedWebSocket();
|
|
80
|
+
const unsub = socket.on(event, (data) => {
|
|
81
|
+
callback(data);
|
|
82
|
+
});
|
|
83
|
+
onUnmounted(unsub);
|
|
84
|
+
}
|
|
69
85
|
function useSocketStatus() {
|
|
70
86
|
const socket = useSharedWebSocket();
|
|
71
87
|
const connected = ref(socket.connected);
|
|
72
88
|
const tabRole = ref(socket.tabRole);
|
|
73
|
-
|
|
74
|
-
timer = setInterval(() => {
|
|
89
|
+
const timer = setInterval(() => {
|
|
75
90
|
connected.value = socket.connected;
|
|
76
91
|
tabRole.value = socket.tabRole;
|
|
77
92
|
}, 1e3);
|
|
@@ -85,6 +100,7 @@ export {
|
|
|
85
100
|
SharedWebSocketKey,
|
|
86
101
|
createSharedWebSocketPlugin,
|
|
87
102
|
useSharedWebSocket,
|
|
103
|
+
useSocketCallback,
|
|
88
104
|
useSocketEvent,
|
|
89
105
|
useSocketStatus,
|
|
90
106
|
useSocketStream,
|
package/dist/vue.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws'));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n
|
|
1
|
+
{"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const unsub = socket.on(event, (data: T) => {\n if (callback) {\n callback(data);\n } else {\n value.value = data;\n }\n });\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const unsub = socket.on(event, (data: T) => {\n if (callback) {\n callback(data);\n } else {\n items.value = [...items.value, data];\n }\n });\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: T) => {\n callback(data);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n"],"mappings":";;;;;AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AAMA,IAAM,qBAAoD,uBAAO,iBAAiB;AAYlF,SAAS,4BAA4B,KAAa,SAAkC;AACzF,SAAO;AAAA,IACL,QAAQ,KAAU;AAChB,YAAM,SAAS,IAAI,gBAAgB,KAAK,OAAO;AAC/C,aAAO,QAAQ;AACf,UAAI,QAAQ,oBAAoB,MAAM;AAEtC,YAAM,kBAAkB,IAAI,QAAQ,KAAK,GAAG;AAC5C,UAAI,UAAU,MAAM;AAClB,eAAO,OAAO,OAAO,EAAE;AACvB,wBAAgB;AAAA,MAClB;AAAA,IACF;AAAA,EACF;AACF;AASO,SAAS,qBAAsC;AACpD,QAAM,SAAS,OAAO,kBAAkB;AACxC,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,+EAA+E;AAAA,EACjG;AACA,SAAO;AACT;AAoBO,SAAS,eAAkB,OAAe,UAAkD;AACjG,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAmB,MAAS;AAE1C,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,QAAI,UAAU;AACZ,eAAS,IAAI;AAAA,IACf,OAAO;AACL,YAAM,QAAQ;AAAA,IAChB;AAAA,EACF,CAAC;AAED,cAAY,KAAK;AACjB,SAAO,SAAS,KAAK;AACvB;AAyBO,SAAS,gBAAmB,OAAe,UAAwC;AACxF,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAS,CAAC,CAAC;AAEzB,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,QAAI,UAAU;AACZ,eAAS,IAAI;AAAA,IACf,OAAO;AACL,YAAM,QAAQ,CAAC,GAAG,MAAM,OAAO,IAAI;AAAA,IACrC;AAAA,EACF,CAAC;AAED,cAAY,KAAK;AACjB,SAAO,SAAS,KAAK;AACvB;AAmBO,SAAS,cAAiB,KAAa,cAAiB,UAAuC;AACpG,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAO,OAAO,QAAW,GAAG,KAAK,YAAY;AAE3D,QAAM,QAAQ,OAAO,OAAU,KAAK,CAAC,MAAM;AACzC,UAAM,QAAQ;AACd,eAAW,CAAC;AAAA,EACd,CAAC;AAED;AAAA,IACE;AAAA,IACA,CAAC,WAAW;AACV,aAAO,KAAK,KAAK,MAAM;AAAA,IACzB;AAAA,IACA,EAAE,MAAM,KAAK;AAAA,EACf;AAEA,cAAY,KAAK;AACjB,SAAO;AACT;AAUO,SAAS,kBAAqB,OAAe,UAAmC;AACrF,QAAM,SAAS,mBAAmB;AAElC,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,aAAS,IAAI;AAAA,EACf,CAAC;AAED,cAAY,KAAK;AACnB;AAQO,SAAS,kBAGd;AACA,QAAM,SAAS,mBAAmB;AAClC,QAAM,YAAY,IAAI,OAAO,SAAS;AACtC,QAAM,UAAU,IAAa,OAAO,OAAO;AAE3C,QAAM,QAAQ,YAAY,MAAM;AAC9B,cAAU,QAAQ,OAAO;AACzB,YAAQ,QAAQ,OAAO;AAAA,EACzB,GAAG,GAAI;AAEP,cAAY,MAAM,cAAc,KAAK,CAAC;AAEtC,SAAO;AAAA,IACL,WAAW,SAAS,SAAS;AAAA,IAC7B,SAAS,SAAS,OAAO;AAAA,EAC3B;AACF;","names":[]}
|
package/package.json
CHANGED
package/src/SharedSocket.ts
CHANGED
|
@@ -9,6 +9,8 @@ interface SharedSocketOptions {
|
|
|
9
9
|
heartbeatInterval?: number;
|
|
10
10
|
sendBuffer?: number;
|
|
11
11
|
auth?: () => string | Promise<string>;
|
|
12
|
+
authToken?: string;
|
|
13
|
+
authParam?: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export class SharedSocket implements Disposable {
|
|
@@ -22,7 +24,11 @@ export class SharedSocket implements Disposable {
|
|
|
22
24
|
private onMessageFns = new Set<EventHandler>();
|
|
23
25
|
private onStateChangeFns = new Set<(state: SocketState) => void>();
|
|
24
26
|
|
|
25
|
-
private readonly opts: Required<Omit<SharedSocketOptions, 'auth'
|
|
27
|
+
private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam'>> & {
|
|
28
|
+
auth?: () => string | Promise<string>;
|
|
29
|
+
authToken?: string;
|
|
30
|
+
authParam: string;
|
|
31
|
+
};
|
|
26
32
|
|
|
27
33
|
constructor(
|
|
28
34
|
private url: string,
|
|
@@ -35,6 +41,8 @@ export class SharedSocket implements Disposable {
|
|
|
35
41
|
heartbeatInterval: options.heartbeatInterval ?? 30_000,
|
|
36
42
|
sendBuffer: options.sendBuffer ?? 100,
|
|
37
43
|
auth: options.auth,
|
|
44
|
+
authToken: options.authToken,
|
|
45
|
+
authParam: options.authParam ?? 'token',
|
|
38
46
|
};
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -47,13 +55,7 @@ export class SharedSocket implements Disposable {
|
|
|
47
55
|
|
|
48
56
|
this.setState('connecting');
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
if (this.opts.auth) {
|
|
52
|
-
const token = await this.opts.auth();
|
|
53
|
-
const sep = connectUrl.includes('?') ? '&' : '?';
|
|
54
|
-
connectUrl = `${connectUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
58
|
+
const connectUrl = await this.buildUrl();
|
|
57
59
|
this.ws = new WebSocket(connectUrl, this.opts.protocols);
|
|
58
60
|
|
|
59
61
|
this.ws.onopen = () => {
|
|
@@ -169,6 +171,26 @@ export class SharedSocket implements Disposable {
|
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
private async buildUrl(): Promise<string> {
|
|
175
|
+
// Resolve token: callback > static > none
|
|
176
|
+
let token: string | undefined;
|
|
177
|
+
if (this.opts.auth) {
|
|
178
|
+
token = await this.opts.auth();
|
|
179
|
+
} else if (this.opts.authToken) {
|
|
180
|
+
token = this.opts.authToken;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!token) return this.url;
|
|
184
|
+
|
|
185
|
+
// WebSocket URLs (ws://, wss://) are not fully supported by URL API.
|
|
186
|
+
// Convert to http(s) for parsing, then back to ws(s).
|
|
187
|
+
const httpUrl = this.url.replace(/^ws(s?):\/\//, 'http$1://');
|
|
188
|
+
const parsed = new URL(httpUrl);
|
|
189
|
+
parsed.searchParams.set(this.opts.authParam, token);
|
|
190
|
+
|
|
191
|
+
return parsed.toString().replace(/^http(s?):\/\//, 'ws$1://');
|
|
192
|
+
}
|
|
193
|
+
|
|
172
194
|
private setState(state: SocketState): void {
|
|
173
195
|
this._state = state;
|
|
174
196
|
for (const fn of this.onStateChangeFns) fn(state);
|
package/src/SharedWebSocket.ts
CHANGED
package/src/adapters/vue.ts
CHANGED
|
@@ -20,7 +20,10 @@ export const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedW
|
|
|
20
20
|
*
|
|
21
21
|
* @example
|
|
22
22
|
* const app = createApp(App);
|
|
23
|
-
* app.use(createSharedWebSocketPlugin('wss://api.example.com/ws'
|
|
23
|
+
* app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
|
|
24
|
+
* auth: () => localStorage.getItem('token')!,
|
|
25
|
+
* useWorker: true,
|
|
26
|
+
* }));
|
|
24
27
|
*/
|
|
25
28
|
export function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {
|
|
26
29
|
return {
|
|
@@ -29,7 +32,6 @@ export function createSharedWebSocketPlugin(url: string, options?: SharedWebSock
|
|
|
29
32
|
socket.connect();
|
|
30
33
|
app.provide(SharedWebSocketKey, socket);
|
|
31
34
|
|
|
32
|
-
// Cleanup on app unmount
|
|
33
35
|
const originalUnmount = app.unmount.bind(app);
|
|
34
36
|
app.unmount = () => {
|
|
35
37
|
socket[Symbol.dispose]();
|
|
@@ -44,6 +46,7 @@ export function createSharedWebSocketPlugin(url: string, options?: SharedWebSock
|
|
|
44
46
|
*
|
|
45
47
|
* @example
|
|
46
48
|
* const ws = useSharedWebSocket();
|
|
49
|
+
* ws.send('chat.message', { text: 'Hello' });
|
|
47
50
|
*/
|
|
48
51
|
export function useSharedWebSocket(): SharedWebSocket {
|
|
49
52
|
const socket = inject(SharedWebSocketKey);
|
|
@@ -56,17 +59,31 @@ export function useSharedWebSocket(): SharedWebSocket {
|
|
|
56
59
|
// ─── Composables ─────────────────────────────────────────
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
|
-
* Subscribe to a WebSocket event.
|
|
62
|
+
* Subscribe to a WebSocket event.
|
|
63
|
+
* - Without callback: returns reactive ref with latest value.
|
|
64
|
+
* - With callback: calls your handler on each event.
|
|
60
65
|
*
|
|
61
66
|
* @example
|
|
67
|
+
* // Reactive state
|
|
62
68
|
* const order = useSocketEvent<Order>('order.created');
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* // Custom callback
|
|
72
|
+
* useSocketEvent<Order>('order.created', (order) => {
|
|
73
|
+
* playSound('new-order');
|
|
74
|
+
* analytics.track('order_received', order);
|
|
75
|
+
* });
|
|
63
76
|
*/
|
|
64
|
-
export function useSocketEvent<T>(event: string): Ref<T | undefined> {
|
|
77
|
+
export function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {
|
|
65
78
|
const socket = useSharedWebSocket();
|
|
66
79
|
const value = ref<T | undefined>(undefined) as Ref<T | undefined>;
|
|
67
80
|
|
|
68
81
|
const unsub = socket.on(event, (data: T) => {
|
|
69
|
-
|
|
82
|
+
if (callback) {
|
|
83
|
+
callback(data);
|
|
84
|
+
} else {
|
|
85
|
+
value.value = data;
|
|
86
|
+
}
|
|
70
87
|
});
|
|
71
88
|
|
|
72
89
|
onUnmounted(unsub);
|
|
@@ -74,17 +91,38 @@ export function useSocketEvent<T>(event: string): Ref<T | undefined> {
|
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
/**
|
|
77
|
-
* Accumulate WebSocket events
|
|
94
|
+
* Accumulate WebSocket events.
|
|
95
|
+
* - Without callback: returns reactive array.
|
|
96
|
+
* - With callback: calls your handler — manage your own state.
|
|
78
97
|
*
|
|
79
98
|
* @example
|
|
99
|
+
* // Default accumulation
|
|
80
100
|
* const messages = useSocketStream<ChatMessage>('chat.message');
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* // Custom — keep last 50
|
|
104
|
+
* const messages = ref<ChatMessage[]>([]);
|
|
105
|
+
* useSocketStream<ChatMessage>('chat.message', (msg) => {
|
|
106
|
+
* messages.value = [msg, ...messages.value].slice(0, 50);
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* // Custom — filter by type
|
|
111
|
+
* const errors = ref<LogEntry[]>([]);
|
|
112
|
+
* useSocketStream<LogEntry>('log.entry', (entry) => {
|
|
113
|
+
* if (entry.level === 'error') errors.value = [...errors.value, entry];
|
|
114
|
+
* });
|
|
81
115
|
*/
|
|
82
|
-
export function useSocketStream<T>(event: string): Ref<T[]> {
|
|
116
|
+
export function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {
|
|
83
117
|
const socket = useSharedWebSocket();
|
|
84
118
|
const items = ref<T[]>([]) as Ref<T[]>;
|
|
85
119
|
|
|
86
120
|
const unsub = socket.on(event, (data: T) => {
|
|
87
|
-
|
|
121
|
+
if (callback) {
|
|
122
|
+
callback(data);
|
|
123
|
+
} else {
|
|
124
|
+
items.value = [...items.value, data];
|
|
125
|
+
}
|
|
88
126
|
});
|
|
89
127
|
|
|
90
128
|
onUnmounted(unsub);
|
|
@@ -92,21 +130,31 @@ export function useSocketStream<T>(event: string): Ref<T[]> {
|
|
|
92
130
|
}
|
|
93
131
|
|
|
94
132
|
/**
|
|
95
|
-
* Two-way state sync across browser tabs
|
|
133
|
+
* Two-way state sync across browser tabs.
|
|
134
|
+
* - Without callback: reactive ref synced across tabs.
|
|
135
|
+
* - With callback: called when any tab updates this key — side effects.
|
|
96
136
|
*
|
|
97
137
|
* @example
|
|
138
|
+
* // Reactive two-way sync
|
|
98
139
|
* const cart = useSocketSync<Cart>('cart', { items: [] });
|
|
99
140
|
* cart.value = { items: [1, 2, 3] }; // syncs to all tabs
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* // With side effect callback
|
|
144
|
+
* const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {
|
|
145
|
+
* document.title = `Cart (${cart.items.length})`;
|
|
146
|
+
* analytics.track('cart_updated');
|
|
147
|
+
* });
|
|
100
148
|
*/
|
|
101
|
-
export function useSocketSync<T>(key: string, initialValue: T): Ref<T> {
|
|
149
|
+
export function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {
|
|
102
150
|
const socket = useSharedWebSocket();
|
|
103
151
|
const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;
|
|
104
152
|
|
|
105
153
|
const unsub = socket.onSync<T>(key, (v) => {
|
|
106
154
|
value.value = v;
|
|
155
|
+
callback?.(v);
|
|
107
156
|
});
|
|
108
157
|
|
|
109
|
-
// Watch for local changes → sync to other tabs
|
|
110
158
|
watch(
|
|
111
159
|
value,
|
|
112
160
|
(newVal) => {
|
|
@@ -119,6 +167,24 @@ export function useSocketSync<T>(key: string, initialValue: T): Ref<T> {
|
|
|
119
167
|
return value;
|
|
120
168
|
}
|
|
121
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Fire-and-forget event handler — no state, no ref.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* useSocketCallback<Notification>('notification', (n) => {
|
|
175
|
+
* showToast(n.title);
|
|
176
|
+
* });
|
|
177
|
+
*/
|
|
178
|
+
export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
|
|
179
|
+
const socket = useSharedWebSocket();
|
|
180
|
+
|
|
181
|
+
const unsub = socket.on(event, (data: T) => {
|
|
182
|
+
callback(data);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
onUnmounted(unsub);
|
|
186
|
+
}
|
|
187
|
+
|
|
122
188
|
/**
|
|
123
189
|
* Reactive connection status.
|
|
124
190
|
*
|
|
@@ -133,9 +199,7 @@ export function useSocketStatus(): {
|
|
|
133
199
|
const connected = ref(socket.connected);
|
|
134
200
|
const tabRole = ref<TabRole>(socket.tabRole);
|
|
135
201
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
timer = setInterval(() => {
|
|
202
|
+
const timer = setInterval(() => {
|
|
139
203
|
connected.value = socket.connected;
|
|
140
204
|
tabRole.value = socket.tabRole;
|
|
141
205
|
}, 1000);
|
package/src/types.ts
CHANGED
|
@@ -21,7 +21,12 @@ export interface SharedWebSocketOptions {
|
|
|
21
21
|
leaderHeartbeat?: number;
|
|
22
22
|
leaderTimeout?: number;
|
|
23
23
|
sendBuffer?: number;
|
|
24
|
+
/** Auth token provider — called before each connect/reconnect. */
|
|
24
25
|
auth?: () => string | Promise<string>;
|
|
26
|
+
/** Static auth token (alternative to auth callback). */
|
|
27
|
+
authToken?: string;
|
|
28
|
+
/** Query parameter name for the token (default: "token"). */
|
|
29
|
+
authParam?: string;
|
|
25
30
|
/** Run WebSocket inside a Web Worker (offloads JSON parsing, heartbeat from main thread). */
|
|
26
31
|
useWorker?: boolean;
|
|
27
32
|
/** Custom worker URL (if useWorker is true and you want to provide your own worker file). */
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/chunk-SMH3X34N.cjs","../src/utils/disposable.ts","../src/utils/id.ts","../src/MessageBus.ts","../src/TabCoordinator.ts","../src/utils/backoff.ts","../src/SharedSocket.ts","../src/WorkerSocket.ts","../src/SubscriptionManager.ts","../src/SharedWebSocket.ts"],"names":[],"mappings":"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","file":"/Users/gwakko/Projects/shared-websocket/dist/chunk-SMH3X34N.cjs","sourcesContent":[null,"/** Polyfill Symbol.dispose if not available. */\nif (typeof Symbol.dispose === 'undefined') {\n (Symbol as any).dispose = Symbol('Symbol.dispose');\n}\n","export function generateId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;\n}\n","import './utils/disposable';\nimport { generateId } from './utils/id';\nimport type { BusMessage, Unsubscribe } from './types';\n\ntype Listener = (msg: BusMessage) => void;\n\nexport class MessageBus implements Disposable {\n private channel: BroadcastChannel;\n private listeners = new Map<string, Set<Listener>>();\n private pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();\n\n constructor(\n channelName: string,\n private readonly tabId: string,\n ) {\n this.channel = new BroadcastChannel(channelName);\n this.channel.onmessage = (ev: MessageEvent<BusMessage>) => {\n this.handleMessage(ev.data);\n };\n }\n\n subscribe<T>(topic: string, fn: (data: T) => void): Unsubscribe {\n const wrapper: Listener = (msg) => {\n if (msg.source !== this.tabId) fn(msg.data as T);\n };\n this.addListener(topic, wrapper);\n return () => this.removeListener(topic, wrapper);\n }\n\n publish<T>(topic: string, data: T): void {\n this.postMessage({ topic, type: 'publish', data });\n }\n\n broadcast<T>(topic: string, data: T): void {\n const msg = this.createMessage(topic, 'broadcast', data);\n this.channel.postMessage(msg);\n // Also deliver to self\n this.handleMessage(msg);\n }\n\n async request<T, R>(topic: string, data: T, timeout = 5000): Promise<R> {\n const msg = this.createMessage(topic, 'request', data);\n return new Promise<R>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pendingRequests.delete(msg.id);\n reject(new Error(`MessageBus.request: timeout for topic \"${topic}\"`));\n }, timeout);\n this.pendingRequests.set(msg.id, { resolve: resolve as (v: unknown) => void, reject, timer });\n this.channel.postMessage(msg);\n });\n }\n\n respond<T, R>(topic: string, fn: (data: T) => R | Promise<R>): Unsubscribe {\n const wrapper: Listener = async (msg) => {\n if (msg.type !== 'request' || msg.source === this.tabId) return;\n const result = await fn(msg.data as T);\n this.postMessage({ topic, type: 'response', data: { requestId: msg.id, result } });\n };\n this.addListener(topic, wrapper);\n return () => this.removeListener(topic, wrapper);\n }\n\n private handleMessage(msg: BusMessage): void {\n // Handle response to pending request\n if (msg.type === 'response') {\n const payload = msg.data as { requestId: string; result: unknown };\n const pending = this.pendingRequests.get(payload.requestId);\n if (pending) {\n clearTimeout(pending.timer);\n this.pendingRequests.delete(payload.requestId);\n pending.resolve(payload.result);\n return;\n }\n }\n\n const listeners = this.listeners.get(msg.topic);\n if (listeners) {\n for (const fn of listeners) fn(msg);\n }\n }\n\n private postMessage(partial: Pick<BusMessage, 'topic' | 'type' | 'data'>): void {\n this.channel.postMessage(this.createMessage(partial.topic, partial.type, partial.data));\n }\n\n private createMessage(topic: string, type: BusMessage['type'], data: unknown): BusMessage {\n return { id: generateId(), source: this.tabId, topic, type, data, timestamp: Date.now() };\n }\n\n private addListener(topic: string, fn: Listener): void {\n let set = this.listeners.get(topic);\n if (!set) {\n set = new Set();\n this.listeners.set(topic, set);\n }\n set.add(fn);\n }\n\n private removeListener(topic: string, fn: Listener): void {\n this.listeners.get(topic)?.delete(fn);\n }\n\n [Symbol.dispose](): void {\n for (const pending of this.pendingRequests.values()) {\n clearTimeout(pending.timer);\n pending.reject(new Error('MessageBus disposed'));\n }\n this.pendingRequests.clear();\n this.listeners.clear();\n this.channel.close();\n }\n}\n","import './utils/disposable';\nimport { MessageBus } from './MessageBus';\nimport type { Unsubscribe } from './types';\n\ninterface CoordinatorOptions {\n electionTimeout?: number; // ms to wait for rejection (default 200)\n heartbeatInterval?: number; // ms between heartbeats (default 2000)\n leaderTimeout?: number; // ms without heartbeat to trigger election (default 5000)\n}\n\nexport class TabCoordinator implements Disposable {\n private _isLeader = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private leaderCheckTimer: ReturnType<typeof setInterval> | null = null;\n private lastHeartbeat = 0;\n private disposed = false;\n\n private onBecomeLeaderFns = new Set<() => void>();\n private onLoseLeadershipFns = new Set<() => void>();\n private cleanups: Unsubscribe[] = [];\n\n private readonly electionTimeout: number;\n private readonly heartbeatInterval: number;\n private readonly leaderTimeout: number;\n\n constructor(\n private readonly bus: MessageBus,\n private readonly tabId: string,\n options: CoordinatorOptions = {},\n ) {\n this.electionTimeout = options.electionTimeout ?? 200;\n this.heartbeatInterval = options.heartbeatInterval ?? 2000;\n this.leaderTimeout = options.leaderTimeout ?? 5000;\n\n // Listen for election requests — reject if we are leader\n this.cleanups.push(\n this.bus.subscribe<{ tabId: string }>('coord:election', () => {\n if (this._isLeader) {\n this.bus.publish('coord:reject', { tabId: this.tabId });\n }\n }),\n );\n\n // Listen for heartbeats\n this.cleanups.push(\n this.bus.subscribe<{ tabId: string }>('coord:heartbeat', () => {\n this.lastHeartbeat = Date.now();\n }),\n );\n\n // Listen for abdication\n this.cleanups.push(\n this.bus.subscribe('coord:abdicate', () => {\n if (!this._isLeader && !this.disposed) {\n this.elect();\n }\n }),\n );\n }\n\n get isLeader(): boolean {\n return this._isLeader;\n }\n\n async elect(): Promise<void> {\n if (this.disposed) return;\n\n return new Promise<void>((resolve) => {\n let rejected = false;\n\n const unsub = this.bus.subscribe('coord:reject', () => {\n rejected = true;\n unsub();\n // We are follower — start monitoring leader heartbeat\n this.startLeaderCheck();\n resolve();\n });\n\n this.bus.publish('coord:election', { tabId: this.tabId });\n\n setTimeout(() => {\n unsub();\n if (!rejected && !this.disposed) {\n this.becomeLeader();\n }\n resolve();\n }, this.electionTimeout);\n });\n }\n\n abdicate(): void {\n if (!this._isLeader) return;\n this._isLeader = false;\n this.stopHeartbeat();\n this.bus.publish('coord:abdicate', { tabId: this.tabId });\n for (const fn of this.onLoseLeadershipFns) fn();\n }\n\n onBecomeLeader(fn: () => void): Unsubscribe {\n this.onBecomeLeaderFns.add(fn);\n return () => this.onBecomeLeaderFns.delete(fn);\n }\n\n onLoseLeadership(fn: () => void): Unsubscribe {\n this.onLoseLeadershipFns.add(fn);\n return () => this.onLoseLeadershipFns.delete(fn);\n }\n\n private becomeLeader(): void {\n this._isLeader = true;\n this.stopLeaderCheck();\n this.startHeartbeat();\n for (const fn of this.onBecomeLeaderFns) fn();\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.bus.publish('coord:heartbeat', { tabId: this.tabId });\n }, this.heartbeatInterval);\n // Send immediately\n this.bus.publish('coord:heartbeat', { tabId: this.tabId });\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private startLeaderCheck(): void {\n this.stopLeaderCheck();\n this.lastHeartbeat = Date.now();\n this.leaderCheckTimer = setInterval(() => {\n if (Date.now() - this.lastHeartbeat > this.leaderTimeout && !this.disposed) {\n this.stopLeaderCheck();\n this.elect();\n }\n }, 1000);\n }\n\n private stopLeaderCheck(): void {\n if (this.leaderCheckTimer) {\n clearInterval(this.leaderCheckTimer);\n this.leaderCheckTimer = null;\n }\n }\n\n [Symbol.dispose](): void {\n this.disposed = true;\n if (this._isLeader) {\n this.abdicate();\n }\n this.stopHeartbeat();\n this.stopLeaderCheck();\n for (const unsub of this.cleanups) unsub();\n this.cleanups = [];\n this.onBecomeLeaderFns.clear();\n this.onLoseLeadershipFns.clear();\n }\n}\n","/** Exponential backoff generator with jitter. */\nexport function* backoff(base = 1000, max = 30_000): Generator<number> {\n let delay = base;\n while (true) {\n const jitter = delay * 0.25 * (Math.random() * 2 - 1);\n yield Math.min(delay + jitter, max);\n delay = Math.min(delay * 2, max);\n }\n}\n","import './utils/disposable';\nimport { backoff } from './utils/backoff';\nimport type { SocketState, Unsubscribe, EventHandler } from './types';\n\ninterface SharedSocketOptions {\n protocols?: string[];\n reconnect?: boolean;\n reconnectMaxDelay?: number;\n heartbeatInterval?: number;\n sendBuffer?: number;\n auth?: () => string | Promise<string>;\n}\n\nexport class SharedSocket implements Disposable {\n private ws: WebSocket | null = null;\n private _state: SocketState = 'closed';\n private buffer: unknown[] = [];\n private disposed = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n private onMessageFns = new Set<EventHandler>();\n private onStateChangeFns = new Set<(state: SocketState) => void>();\n\n private readonly opts: Required<Omit<SharedSocketOptions, 'auth'>> & { auth?: () => string | Promise<string> };\n\n constructor(\n private url: string,\n options: SharedSocketOptions = {},\n ) {\n this.opts = {\n protocols: options.protocols ?? [],\n reconnect: options.reconnect ?? true,\n reconnectMaxDelay: options.reconnectMaxDelay ?? 30_000,\n heartbeatInterval: options.heartbeatInterval ?? 30_000,\n sendBuffer: options.sendBuffer ?? 100,\n auth: options.auth,\n };\n }\n\n get state(): SocketState {\n return this._state;\n }\n\n async connect(): Promise<void> {\n if (this.disposed) return;\n\n this.setState('connecting');\n\n let connectUrl = this.url;\n if (this.opts.auth) {\n const token = await this.opts.auth();\n const sep = connectUrl.includes('?') ? '&' : '?';\n connectUrl = `${connectUrl}${sep}token=${encodeURIComponent(token)}`;\n }\n\n this.ws = new WebSocket(connectUrl, this.opts.protocols);\n\n this.ws.onopen = () => {\n this.setState('connected');\n this.flushBuffer();\n this.startHeartbeat();\n };\n\n this.ws.onmessage = (ev: MessageEvent) => {\n let data: unknown;\n try {\n data = JSON.parse(ev.data as string);\n } catch {\n data = ev.data;\n }\n for (const fn of this.onMessageFns) fn(data);\n };\n\n this.ws.onclose = () => {\n this.stopHeartbeat();\n if (!this.disposed && this.opts.reconnect) {\n this.reconnect();\n } else {\n this.setState('closed');\n }\n };\n\n this.ws.onerror = () => {\n // onclose will fire after onerror\n };\n }\n\n disconnect(): void {\n this.disposed = true;\n this.stopHeartbeat();\n this.clearReconnect();\n\n if (this.ws) {\n this.ws.onclose = null;\n this.ws.onmessage = null;\n this.ws.onerror = null;\n if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {\n this.ws.close(1000, 'client disconnect');\n }\n this.ws = null;\n }\n\n this.setState('closed');\n }\n\n send(data: unknown): void {\n if (this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify(data));\n } else if (this._state === 'reconnecting' || this._state === 'connecting') {\n if (this.buffer.length < this.opts.sendBuffer) {\n this.buffer.push(data);\n }\n }\n }\n\n onMessage(fn: EventHandler): Unsubscribe {\n this.onMessageFns.add(fn);\n return () => this.onMessageFns.delete(fn);\n }\n\n onStateChange(fn: (state: SocketState) => void): Unsubscribe {\n this.onStateChangeFns.add(fn);\n return () => this.onStateChangeFns.delete(fn);\n }\n\n private reconnect(): void {\n this.setState('reconnecting');\n const gen = backoff(1000, this.opts.reconnectMaxDelay);\n\n const attempt = () => {\n if (this.disposed) return;\n const delay = gen.next().value;\n this.reconnectTimer = setTimeout(() => {\n if (!this.disposed) this.connect();\n }, delay);\n };\n\n attempt();\n }\n\n private flushBuffer(): void {\n const pending = this.buffer.splice(0);\n for (const item of pending) {\n this.send(item);\n }\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ type: 'ping' }));\n }\n }, this.opts.heartbeatInterval);\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private clearReconnect(): void {\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n }\n\n private setState(state: SocketState): void {\n this._state = state;\n for (const fn of this.onStateChangeFns) fn(state);\n }\n\n [Symbol.dispose](): void {\n this.disconnect();\n this.onMessageFns.clear();\n this.onStateChangeFns.clear();\n this.buffer = [];\n }\n}\n","import './utils/disposable';\nimport type { SocketState, Unsubscribe, EventHandler } from './types';\n\n/**\n * WorkerSocket — WebSocket running inside a Web Worker.\n *\n * Same interface as SharedSocket, but WebSocket lives off main thread.\n * Benefits: heartbeat timers and JSON parsing don't block UI rendering.\n *\n * Use when:\n * - High message rate (50+ msgs/sec)\n * - Heavy JSON payloads\n * - UI does complex rendering that could block main thread\n *\n * Don't use when:\n * - Low message rate (simple chat, notifications)\n * - Bundle size matters (adds worker file)\n * - Debugging (Worker DevTools is less convenient)\n */\nexport class WorkerSocket implements Disposable {\n private worker: Worker | null = null;\n private _state: SocketState = 'closed';\n\n private onMessageFns = new Set<EventHandler>();\n private onStateChangeFns = new Set<(state: SocketState) => void>();\n\n constructor(\n private url: string,\n private options: {\n protocols?: string[];\n reconnect?: boolean;\n reconnectMaxDelay?: number;\n heartbeatInterval?: number;\n sendBuffer?: number;\n workerUrl?: string | URL;\n } = {},\n ) {}\n\n get state(): SocketState {\n return this._state;\n }\n\n connect(): void {\n // Create worker from inline blob if no workerUrl provided\n const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();\n\n this.worker = new Worker(workerUrl, { type: 'module' });\n\n this.worker.onmessage = (ev: MessageEvent) => {\n const msg = ev.data;\n\n switch (msg.type) {\n case 'state':\n this._state = msg.state;\n for (const fn of this.onStateChangeFns) fn(msg.state);\n break;\n\n case 'message':\n for (const fn of this.onMessageFns) fn(msg.data);\n break;\n\n case 'open':\n // State already set via 'state' message\n break;\n\n case 'close':\n break;\n\n case 'error':\n console.error('WorkerSocket error:', msg.message);\n break;\n }\n };\n\n this.worker.postMessage({\n type: 'connect',\n url: this.url,\n protocols: this.options.protocols ?? [],\n reconnect: this.options.reconnect ?? true,\n reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,\n heartbeatInterval: this.options.heartbeatInterval ?? 30_000,\n bufferSize: this.options.sendBuffer ?? 100,\n });\n }\n\n send(data: unknown): void {\n this.worker?.postMessage({ type: 'send', data });\n }\n\n disconnect(): void {\n this.worker?.postMessage({ type: 'disconnect' });\n setTimeout(() => {\n this.worker?.terminate();\n this.worker = null;\n }, 100);\n this._state = 'closed';\n }\n\n onMessage(fn: EventHandler): Unsubscribe {\n this.onMessageFns.add(fn);\n return () => this.onMessageFns.delete(fn);\n }\n\n onStateChange(fn: (state: SocketState) => void): Unsubscribe {\n this.onStateChangeFns.add(fn);\n return () => this.onStateChangeFns.delete(fn);\n }\n\n private createWorkerBlob(): URL {\n // Inline the worker code as a blob URL\n // In production, use a bundler (Vite, webpack) to handle worker imports\n const code = `\n let ws = null, state = 'closed', buffer = [], disposed = false;\n let heartbeatTimer = null, reconnectTimer = null;\n let url = '', protocols = [], shouldReconnect = true;\n let maxDelay = 30000, hbInterval = 30000, maxBuf = 100, delay = 1000;\n\n function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }\n function connect() {\n if (disposed) return;\n setState('connecting');\n ws = new WebSocket(url, protocols);\n ws.onopen = () => { setState('connected'); delay = 1000; self.postMessage({ type: 'open' }); flush(); startHB(); };\n ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };\n ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };\n ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };\n }\n function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }\n function flush() { const p = buffer.splice(0); p.forEach(send); }\n function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send('{\"type\":\"ping\"}'); }, hbInterval); }\n function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }\n function reconnect() { setState('reconnecting'); const j = delay * 0.25 * (Math.random() * 2 - 1); reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay)); delay = Math.min(delay * 2, maxDelay); }\n self.onmessage = (e) => {\n const c = e.data;\n 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(); }\n if (c.type === 'send') send(c.data);\n 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'); }\n };\n `;\n const blob = new Blob([code], { type: 'application/javascript' });\n return new URL(URL.createObjectURL(blob));\n }\n\n [Symbol.dispose](): void {\n this.disconnect();\n this.onMessageFns.clear();\n this.onStateChangeFns.clear();\n }\n}\n","import './utils/disposable';\nimport type { EventHandler, Unsubscribe } from './types';\n\nexport class SubscriptionManager implements Disposable {\n private handlers = new Map<string, Set<EventHandler>>();\n private lastMessages = new Map<string, unknown>();\n\n on(event: string, handler: EventHandler): Unsubscribe {\n let set = this.handlers.get(event);\n if (!set) {\n set = new Set();\n this.handlers.set(event, set);\n }\n set.add(handler);\n return () => set!.delete(handler);\n }\n\n once(event: string, handler: EventHandler): Unsubscribe {\n const wrapper: EventHandler = (data) => {\n unsub();\n handler(data);\n };\n const unsub = this.on(event, wrapper);\n return unsub;\n }\n\n off(event: string, handler?: EventHandler): void {\n if (handler) {\n this.handlers.get(event)?.delete(handler);\n } else {\n this.handlers.delete(event);\n }\n }\n\n emit(event: string, data: unknown): void {\n this.lastMessages.set(event, data);\n const set = this.handlers.get(event);\n if (set) {\n for (const fn of set) fn(data);\n }\n }\n\n getLastMessage(event: string): unknown | undefined {\n return this.lastMessages.get(event);\n }\n\n async *stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n const queue: unknown[] = [];\n let resolve: (() => void) | null = null;\n let done = false;\n\n const unsub = this.on(event, (data) => {\n queue.push(data);\n resolve?.();\n });\n\n const onAbort = () => {\n done = true;\n resolve?.();\n };\n signal?.addEventListener('abort', onAbort);\n\n try {\n while (!done) {\n if (queue.length > 0) {\n yield queue.shift()!;\n } else {\n await new Promise<void>((r) => { resolve = r; });\n resolve = null;\n }\n }\n } finally {\n unsub();\n signal?.removeEventListener('abort', onAbort);\n }\n }\n\n offAll(): void {\n this.handlers.clear();\n this.lastMessages.clear();\n }\n\n [Symbol.dispose](): void {\n this.offAll();\n }\n}\n","import './utils/disposable';\nimport { generateId } from './utils/id';\nimport { MessageBus } from './MessageBus';\nimport { TabCoordinator } from './TabCoordinator';\nimport { SharedSocket } from './SharedSocket';\nimport { WorkerSocket } from './WorkerSocket';\nimport { SubscriptionManager } from './SubscriptionManager';\nimport type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler } from './types';\n\n/** Common interface for both SharedSocket and WorkerSocket. */\ninterface SocketAdapter {\n readonly state: string;\n connect(): void;\n send(data: unknown): void;\n disconnect(): void;\n onMessage(fn: EventHandler): Unsubscribe;\n onStateChange(fn: (state: string) => void): Unsubscribe;\n [Symbol.dispose](): void;\n}\n\n/**\n * SharedWebSocket — shares ONE WebSocket connection across browser tabs.\n *\n * One tab becomes the \"leader\" and holds the WebSocket.\n * Other tabs are \"followers\" receiving data via BroadcastChannel.\n * If the leader closes, a new leader is elected automatically.\n */\nexport class SharedWebSocket implements Disposable {\n private bus: MessageBus;\n private coordinator: TabCoordinator;\n private socket: SocketAdapter | null = null;\n private subs = new SubscriptionManager();\n private syncStore = new Map<string, unknown>();\n private tabId: string;\n private cleanups: Unsubscribe[] = [];\n private disposed = false;\n\n constructor(\n private readonly url: string,\n private readonly options: SharedWebSocketOptions = {},\n ) {\n this.tabId = generateId();\n this.bus = new MessageBus('shared-ws', this.tabId);\n this.coordinator = new TabCoordinator(this.bus, this.tabId, {\n electionTimeout: options.electionTimeout,\n heartbeatInterval: options.leaderHeartbeat,\n leaderTimeout: options.leaderTimeout,\n });\n\n // When ANY tab receives a WS message via bus → emit to local subscribers\n this.cleanups.push(\n this.bus.subscribe<{ event: string; data: unknown }>('ws:message', (msg) => {\n this.subs.emit(msg.event, msg.data);\n }),\n );\n\n // Leader listens for send requests from followers\n this.cleanups.push(\n this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {\n if (this.coordinator.isLeader && this.socket) {\n this.socket.send({ event: msg.event, data: msg.data });\n }\n }),\n );\n\n // Sync across tabs\n this.cleanups.push(\n this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {\n this.syncStore.set(msg.key, msg.value);\n this.subs.emit(`sync:${msg.key}`, msg.value);\n }),\n );\n\n // Leader lifecycle\n this.coordinator.onBecomeLeader(() => this.onBecomeLeader());\n this.coordinator.onLoseLeadership(() => this.onLoseLeadership());\n\n // Cleanup on tab close\n if (typeof window !== 'undefined') {\n const onBeforeUnload = () => this[Symbol.dispose]();\n window.addEventListener('beforeunload', onBeforeUnload);\n this.cleanups.push(() => window.removeEventListener('beforeunload', onBeforeUnload));\n }\n }\n\n get connected(): boolean {\n return this.socket?.state === 'connected' || !this.coordinator.isLeader;\n }\n\n get tabRole(): TabRole {\n return this.coordinator.isLeader ? 'leader' : 'follower';\n }\n\n /** Start leader election and connect. */\n async connect(): Promise<void> {\n await this.coordinator.elect();\n }\n\n /** Subscribe to server events (works in ALL tabs). */\n on(event: string, handler: EventHandler): Unsubscribe {\n return this.subs.on(event, handler);\n }\n\n once(event: string, handler: EventHandler): Unsubscribe {\n return this.subs.once(event, handler);\n }\n\n off(event: string, handler?: EventHandler): void {\n this.subs.off(event, handler);\n }\n\n /** Async generator for consuming events. */\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n return this.subs.stream(event, signal);\n }\n\n /** Send message to server (auto-routed through leader). */\n send(event: string, data: unknown): void {\n if (this.coordinator.isLeader && this.socket) {\n this.socket.send({ event, data });\n } else {\n this.bus.publish('ws:send', { event, data });\n }\n }\n\n /** Request/response through server via leader. */\n async request<T>(event: string, data: unknown, timeout = 5000): Promise<T> {\n return this.bus.request('ws:request', { event, data }, timeout);\n }\n\n /** Sync state across tabs (no server roundtrip). */\n sync<T>(key: string, value: T): void {\n this.syncStore.set(key, value);\n this.bus.broadcast('ws:sync', { key, value });\n }\n\n getSync<T>(key: string): T | undefined {\n return this.syncStore.get(key) as T | undefined;\n }\n\n onSync<T>(key: string, fn: (value: T) => void): Unsubscribe {\n return this.subs.on(`sync:${key}`, fn as EventHandler);\n }\n\n disconnect(): void {\n this[Symbol.dispose]();\n }\n\n private createSocket(): SocketAdapter {\n const socketOptions = {\n protocols: this.options.protocols,\n reconnect: this.options.reconnect,\n reconnectMaxDelay: this.options.reconnectMaxDelay,\n heartbeatInterval: this.options.heartbeatInterval,\n sendBuffer: this.options.sendBuffer,\n };\n\n if (this.options.useWorker) {\n // WebSocket runs in a Web Worker — main thread stays free\n return new WorkerSocket(this.url, {\n ...socketOptions,\n workerUrl: this.options.workerUrl,\n });\n }\n\n // WebSocket runs in main thread (default)\n return new SharedSocket(this.url, {\n ...socketOptions,\n auth: this.options.auth,\n });\n }\n\n private onBecomeLeader(): void {\n this.socket = this.createSocket();\n\n this.socket.onMessage((data: any) => {\n const event = data?.event ?? 'message';\n const payload = data?.data ?? data;\n // Broadcast to ALL tabs (including self)\n this.bus.broadcast('ws:message', { event, data: payload });\n });\n\n // Handle send requests from followers (request/response pattern)\n this.cleanups.push(\n this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {\n return new Promise((resolve) => {\n const unsub = this.socket!.onMessage((response: any) => {\n if (response?.event === req.event || response?.requestId) {\n unsub();\n resolve(response?.data ?? response);\n }\n });\n this.socket!.send({ event: req.event, data: req.data });\n });\n }),\n );\n\n this.socket.connect();\n }\n\n private onLoseLeadership(): void {\n if (this.socket) {\n this.socket[Symbol.dispose]();\n this.socket = null;\n }\n }\n\n [Symbol.dispose](): void {\n if (this.disposed) return;\n this.disposed = true;\n\n this.coordinator[Symbol.dispose]();\n\n if (this.socket) {\n this.socket[Symbol.dispose]();\n this.socket = null;\n }\n\n for (const unsub of this.cleanups) unsub();\n this.cleanups = [];\n this.subs[Symbol.dispose]();\n this.bus[Symbol.dispose]();\n this.syncStore.clear();\n }\n}\n"]}
|