@gwakko/shared-websocket 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +381 -0
  3. package/dist/MessageBus.d.ts +20 -0
  4. package/dist/SharedSocket.d.ts +37 -0
  5. package/dist/SharedWebSocket.d.ts +45 -0
  6. package/dist/SubscriptionManager.d.ts +14 -0
  7. package/dist/TabCoordinator.d.ts +36 -0
  8. package/dist/WorkerSocket.d.ts +42 -0
  9. package/dist/adapters/index.d.ts +0 -0
  10. package/dist/adapters/react.d.ts +79 -0
  11. package/dist/adapters/vue.d.ts +53 -0
  12. package/dist/chunk-SMH3X34N.cjs +737 -0
  13. package/dist/chunk-SMH3X34N.cjs.map +1 -0
  14. package/dist/chunk-TNEMKPGP.js +737 -0
  15. package/dist/chunk-TNEMKPGP.js.map +1 -0
  16. package/dist/index.cjs +46 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.ts +8 -0
  19. package/dist/index.js +46 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/react.cjs +100 -0
  22. package/dist/react.cjs.map +1 -0
  23. package/dist/react.js +100 -0
  24. package/dist/react.js.map +1 -0
  25. package/dist/types.d.ts +27 -0
  26. package/dist/utils/backoff.d.ts +2 -0
  27. package/dist/utils/disposable.d.ts +0 -0
  28. package/dist/utils/id.d.ts +1 -0
  29. package/dist/vue.cjs +93 -0
  30. package/dist/vue.cjs.map +1 -0
  31. package/dist/vue.js +93 -0
  32. package/dist/vue.js.map +1 -0
  33. package/dist/withSocket.d.ts +51 -0
  34. package/dist/worker/socket.worker.d.ts +51 -0
  35. package/package.json +74 -0
  36. package/src/MessageBus.ts +112 -0
  37. package/src/SharedSocket.ts +183 -0
  38. package/src/SharedWebSocket.ts +225 -0
  39. package/src/SubscriptionManager.ts +86 -0
  40. package/src/TabCoordinator.ts +162 -0
  41. package/src/WorkerSocket.ts +149 -0
  42. package/src/adapters/index.ts +3 -0
  43. package/src/adapters/react.ts +189 -0
  44. package/src/adapters/vue.ts +149 -0
  45. package/src/index.ts +8 -0
  46. package/src/types.ts +29 -0
  47. package/src/utils/backoff.ts +9 -0
  48. package/src/utils/disposable.ts +4 -0
  49. package/src/utils/id.ts +6 -0
  50. package/src/withSocket.ts +89 -0
  51. package/src/worker/socket.worker.ts +205 -0
package/dist/vue.js ADDED
@@ -0,0 +1,93 @@
1
+ import {
2
+ SharedWebSocket
3
+ } from "./chunk-TNEMKPGP.js";
4
+
5
+ // src/adapters/vue.ts
6
+ import {
7
+ ref,
8
+ onUnmounted,
9
+ inject,
10
+ readonly,
11
+ watch
12
+ } from "vue";
13
+ var SharedWebSocketKey = /* @__PURE__ */ Symbol("SharedWebSocket");
14
+ function createSharedWebSocketPlugin(url, options) {
15
+ return {
16
+ install(app) {
17
+ const socket = new SharedWebSocket(url, options);
18
+ socket.connect();
19
+ app.provide(SharedWebSocketKey, socket);
20
+ const originalUnmount = app.unmount.bind(app);
21
+ app.unmount = () => {
22
+ socket[Symbol.dispose]();
23
+ originalUnmount();
24
+ };
25
+ }
26
+ };
27
+ }
28
+ function useSharedWebSocket() {
29
+ const socket = inject(SharedWebSocketKey);
30
+ if (!socket) {
31
+ throw new Error("useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?");
32
+ }
33
+ return socket;
34
+ }
35
+ function useSocketEvent(event) {
36
+ const socket = useSharedWebSocket();
37
+ const value = ref(void 0);
38
+ const unsub = socket.on(event, (data) => {
39
+ value.value = data;
40
+ });
41
+ onUnmounted(unsub);
42
+ return readonly(value);
43
+ }
44
+ function useSocketStream(event) {
45
+ const socket = useSharedWebSocket();
46
+ const items = ref([]);
47
+ const unsub = socket.on(event, (data) => {
48
+ items.value = [...items.value, data];
49
+ });
50
+ onUnmounted(unsub);
51
+ return readonly(items);
52
+ }
53
+ function useSocketSync(key, initialValue) {
54
+ const socket = useSharedWebSocket();
55
+ const value = ref(socket.getSync(key) ?? initialValue);
56
+ const unsub = socket.onSync(key, (v) => {
57
+ value.value = v;
58
+ });
59
+ watch(
60
+ value,
61
+ (newVal) => {
62
+ socket.sync(key, newVal);
63
+ },
64
+ { deep: true }
65
+ );
66
+ onUnmounted(unsub);
67
+ return value;
68
+ }
69
+ function useSocketStatus() {
70
+ const socket = useSharedWebSocket();
71
+ const connected = ref(socket.connected);
72
+ const tabRole = ref(socket.tabRole);
73
+ let timer;
74
+ timer = setInterval(() => {
75
+ connected.value = socket.connected;
76
+ tabRole.value = socket.tabRole;
77
+ }, 1e3);
78
+ onUnmounted(() => clearInterval(timer));
79
+ return {
80
+ connected: readonly(connected),
81
+ tabRole: readonly(tabRole)
82
+ };
83
+ }
84
+ export {
85
+ SharedWebSocketKey,
86
+ createSharedWebSocketPlugin,
87
+ useSharedWebSocket,
88
+ useSocketEvent,
89
+ useSocketStatus,
90
+ useSocketStream,
91
+ useSocketSync
92
+ };
93
+ //# sourceMappingURL=vue.js.map
@@ -0,0 +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 // Cleanup on app unmount\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 */\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. Returns reactive ref with latest value.\n *\n * @example\n * const order = useSocketEvent<Order>('order.created');\n */\nexport function useSocketEvent<T>(event: string): 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 value.value = data;\n });\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events into reactive array.\n *\n * @example\n * const messages = useSocketStream<ChatMessage>('chat.message');\n */\nexport function useSocketStream<T>(event: string): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const unsub = socket.on(event, (data: T) => {\n items.value = [...items.value, data];\n });\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs via reactive ref.\n *\n * @example\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n */\nexport function useSocketSync<T>(key: string, initialValue: T): 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 });\n\n // Watch for local changes → sync to other tabs\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 * 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 let timer: ReturnType<typeof setInterval>;\n\n 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;AASlF,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;AAGtC,YAAM,kBAAkB,IAAI,QAAQ,KAAK,GAAG;AAC5C,UAAI,UAAU,MAAM;AAClB,eAAO,OAAO,OAAO,EAAE;AACvB,wBAAgB;AAAA,MAClB;AAAA,IACF;AAAA,EACF;AACF;AAQO,SAAS,qBAAsC;AACpD,QAAM,SAAS,OAAO,kBAAkB;AACxC,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,+EAA+E;AAAA,EACjG;AACA,SAAO;AACT;AAUO,SAAS,eAAkB,OAAmC;AACnE,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAmB,MAAS;AAE1C,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,UAAM,QAAQ;AAAA,EAChB,CAAC;AAED,cAAY,KAAK;AACjB,SAAO,SAAS,KAAK;AACvB;AAQO,SAAS,gBAAmB,OAAyB;AAC1D,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAS,CAAC,CAAC;AAEzB,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,UAAM,QAAQ,CAAC,GAAG,MAAM,OAAO,IAAI;AAAA,EACrC,CAAC;AAED,cAAY,KAAK;AACjB,SAAO,SAAS,KAAK;AACvB;AASO,SAAS,cAAiB,KAAa,cAAyB;AACrE,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAO,OAAO,QAAW,GAAG,KAAK,YAAY;AAE3D,QAAM,QAAQ,OAAO,OAAU,KAAK,CAAC,MAAM;AACzC,UAAM,QAAQ;AAAA,EAChB,CAAC;AAGD;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;AAQO,SAAS,kBAGd;AACA,QAAM,SAAS,mBAAmB;AAClC,QAAM,YAAY,IAAI,OAAO,SAAS;AACtC,QAAM,UAAU,IAAa,OAAO,OAAO;AAE3C,MAAI;AAEJ,UAAQ,YAAY,MAAM;AACxB,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":[]}
@@ -0,0 +1,51 @@
1
+ import { SharedWebSocket } from './SharedWebSocket';
2
+ import type { SharedWebSocketOptions } from './types';
3
+ /**
4
+ * Callback context — destructure what you need.
5
+ */
6
+ export interface SocketScope {
7
+ /** The SharedWebSocket instance. */
8
+ ws: SharedWebSocket;
9
+ /** AbortSignal — aborted when scope exits (use with stream/fetch). */
10
+ signal: AbortSignal;
11
+ }
12
+ export interface WithSocketOptions extends SharedWebSocketOptions {
13
+ /** External AbortSignal — aborts the scope and disposes the socket. */
14
+ signal?: AbortSignal;
15
+ }
16
+ /**
17
+ * Scoped WebSocket lifecycle — creates, connects, and auto-disposes.
18
+ * Guarantees cleanup even on errors. No polyfills needed.
19
+ *
20
+ * @example
21
+ * // Basic — destructure { ws }
22
+ * await withSocket('wss://api.example.com/ws', async ({ ws }) => {
23
+ * ws.on('order.created', (order) => console.log(order));
24
+ * await longRunningWork();
25
+ * });
26
+ *
27
+ * @example
28
+ * // With auth and signal
29
+ * await withSocket('wss://api.example.com/ws', {
30
+ * auth: () => localStorage.getItem('token')!,
31
+ * }, async ({ ws, signal }) => {
32
+ * for await (const msg of ws.stream('chat.messages', signal)) {
33
+ * renderMessage(msg);
34
+ * }
35
+ * });
36
+ *
37
+ * @example
38
+ * // External cancellation
39
+ * const controller = new AbortController();
40
+ * setTimeout(() => controller.abort(), 30_000);
41
+ *
42
+ * await withSocket('wss://api.example.com/ws', {
43
+ * signal: controller.signal,
44
+ * }, async ({ ws, signal }) => {
45
+ * ws.on('notifications', (n) => showToast(n));
46
+ * // Stays alive until controller aborts or scope exits
47
+ * await new Promise((_, reject) => signal.addEventListener('abort', reject));
48
+ * });
49
+ */
50
+ export declare function withSocket(url: string, optionsOrCallback: WithSocketOptions | WithSocketCallback, maybeCallback?: WithSocketCallback): Promise<void>;
51
+ export type WithSocketCallback = (scope: SocketScope) => void | Promise<void>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WebSocket Worker — runs WebSocket connection off the main thread.
3
+ *
4
+ * Communication with main thread via postMessage:
5
+ *
6
+ * Main → Worker:
7
+ * { type: 'connect', url: string, protocols?: string[] }
8
+ * { type: 'send', data: unknown }
9
+ * { type: 'disconnect' }
10
+ *
11
+ * Worker → Main:
12
+ * { type: 'open' }
13
+ * { type: 'message', data: unknown }
14
+ * { type: 'close', code: number, reason: string }
15
+ * { type: 'error', message: string }
16
+ * { type: 'state', state: SocketState }
17
+ */
18
+ type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
19
+ interface WorkerCommand {
20
+ type: 'connect' | 'send' | 'disconnect';
21
+ url?: string;
22
+ protocols?: string[];
23
+ data?: unknown;
24
+ reconnect?: boolean;
25
+ reconnectMaxDelay?: number;
26
+ heartbeatInterval?: number;
27
+ bufferSize?: number;
28
+ }
29
+ declare let ws: WebSocket | null;
30
+ declare let state: SocketState;
31
+ declare let buffer: unknown[];
32
+ declare let heartbeatTimer: ReturnType<typeof setInterval> | null;
33
+ declare let reconnectTimer: ReturnType<typeof setTimeout> | null;
34
+ declare let disposed: boolean;
35
+ declare let currentUrl: string;
36
+ declare let currentProtocols: string[];
37
+ declare let shouldReconnect: boolean;
38
+ declare let maxDelay: number;
39
+ declare let heartbeatInterval: number;
40
+ declare let maxBuffer: number;
41
+ declare let backoffDelay: number;
42
+ declare function setState(s: SocketState): void;
43
+ declare function connect(url: string, protocols: string[]): void;
44
+ declare function doConnect(): void;
45
+ declare function send(data: unknown): void;
46
+ declare function disconnect(): void;
47
+ declare function flushBuffer(): void;
48
+ declare function startHeartbeat(): void;
49
+ declare function stopHeartbeat(): void;
50
+ declare function scheduleReconnect(): void;
51
+ declare function clearReconnect(): void;
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@gwakko/shared-websocket",
3
+ "version": "0.1.0",
4
+ "description": "Share ONE WebSocket connection across browser tabs — leader election, BroadcastChannel sync, optional Web Worker",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs",
13
+ "types": "./dist/index.d.ts"
14
+ },
15
+ "./react": {
16
+ "import": "./dist/react.js",
17
+ "require": "./dist/react.cjs",
18
+ "types": "./dist/react.d.ts"
19
+ },
20
+ "./vue": {
21
+ "import": "./dist/vue.js",
22
+ "require": "./dist/vue.cjs",
23
+ "types": "./dist/vue.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsup && tsc -p tsconfig.build.json",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest",
36
+ "typecheck": "tsc --noEmit",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "keywords": [
40
+ "websocket",
41
+ "broadcast-channel",
42
+ "shared",
43
+ "tabs",
44
+ "leader-election",
45
+ "real-time",
46
+ "react",
47
+ "vue",
48
+ "web-worker",
49
+ "sync"
50
+ ],
51
+ "author": "gwakko",
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/Gwakko/shared-websocket.git"
56
+ },
57
+ "homepage": "https://github.com/Gwakko/shared-websocket#readme",
58
+ "peerDependencies": {
59
+ "react": ">=19.0.0",
60
+ "vue": ">=3.4.0"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "react": { "optional": true },
64
+ "vue": { "optional": true }
65
+ },
66
+ "devDependencies": {
67
+ "@types/react": "^19.2.14",
68
+ "react": "^19.2.5",
69
+ "tsup": "^8.5.1",
70
+ "typescript": "^6.0.2",
71
+ "vitest": "^4.1.4",
72
+ "vue": "^3.5.32"
73
+ }
74
+ }
@@ -0,0 +1,112 @@
1
+ import './utils/disposable';
2
+ import { generateId } from './utils/id';
3
+ import type { BusMessage, Unsubscribe } from './types';
4
+
5
+ type Listener = (msg: BusMessage) => void;
6
+
7
+ export class MessageBus implements Disposable {
8
+ private channel: BroadcastChannel;
9
+ private listeners = new Map<string, Set<Listener>>();
10
+ private pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
11
+
12
+ constructor(
13
+ channelName: string,
14
+ private readonly tabId: string,
15
+ ) {
16
+ this.channel = new BroadcastChannel(channelName);
17
+ this.channel.onmessage = (ev: MessageEvent<BusMessage>) => {
18
+ this.handleMessage(ev.data);
19
+ };
20
+ }
21
+
22
+ subscribe<T>(topic: string, fn: (data: T) => void): Unsubscribe {
23
+ const wrapper: Listener = (msg) => {
24
+ if (msg.source !== this.tabId) fn(msg.data as T);
25
+ };
26
+ this.addListener(topic, wrapper);
27
+ return () => this.removeListener(topic, wrapper);
28
+ }
29
+
30
+ publish<T>(topic: string, data: T): void {
31
+ this.postMessage({ topic, type: 'publish', data });
32
+ }
33
+
34
+ broadcast<T>(topic: string, data: T): void {
35
+ const msg = this.createMessage(topic, 'broadcast', data);
36
+ this.channel.postMessage(msg);
37
+ // Also deliver to self
38
+ this.handleMessage(msg);
39
+ }
40
+
41
+ async request<T, R>(topic: string, data: T, timeout = 5000): Promise<R> {
42
+ const msg = this.createMessage(topic, 'request', data);
43
+ return new Promise<R>((resolve, reject) => {
44
+ const timer = setTimeout(() => {
45
+ this.pendingRequests.delete(msg.id);
46
+ reject(new Error(`MessageBus.request: timeout for topic "${topic}"`));
47
+ }, timeout);
48
+ this.pendingRequests.set(msg.id, { resolve: resolve as (v: unknown) => void, reject, timer });
49
+ this.channel.postMessage(msg);
50
+ });
51
+ }
52
+
53
+ respond<T, R>(topic: string, fn: (data: T) => R | Promise<R>): Unsubscribe {
54
+ const wrapper: Listener = async (msg) => {
55
+ if (msg.type !== 'request' || msg.source === this.tabId) return;
56
+ const result = await fn(msg.data as T);
57
+ this.postMessage({ topic, type: 'response', data: { requestId: msg.id, result } });
58
+ };
59
+ this.addListener(topic, wrapper);
60
+ return () => this.removeListener(topic, wrapper);
61
+ }
62
+
63
+ private handleMessage(msg: BusMessage): void {
64
+ // Handle response to pending request
65
+ if (msg.type === 'response') {
66
+ const payload = msg.data as { requestId: string; result: unknown };
67
+ const pending = this.pendingRequests.get(payload.requestId);
68
+ if (pending) {
69
+ clearTimeout(pending.timer);
70
+ this.pendingRequests.delete(payload.requestId);
71
+ pending.resolve(payload.result);
72
+ return;
73
+ }
74
+ }
75
+
76
+ const listeners = this.listeners.get(msg.topic);
77
+ if (listeners) {
78
+ for (const fn of listeners) fn(msg);
79
+ }
80
+ }
81
+
82
+ private postMessage(partial: Pick<BusMessage, 'topic' | 'type' | 'data'>): void {
83
+ this.channel.postMessage(this.createMessage(partial.topic, partial.type, partial.data));
84
+ }
85
+
86
+ private createMessage(topic: string, type: BusMessage['type'], data: unknown): BusMessage {
87
+ return { id: generateId(), source: this.tabId, topic, type, data, timestamp: Date.now() };
88
+ }
89
+
90
+ private addListener(topic: string, fn: Listener): void {
91
+ let set = this.listeners.get(topic);
92
+ if (!set) {
93
+ set = new Set();
94
+ this.listeners.set(topic, set);
95
+ }
96
+ set.add(fn);
97
+ }
98
+
99
+ private removeListener(topic: string, fn: Listener): void {
100
+ this.listeners.get(topic)?.delete(fn);
101
+ }
102
+
103
+ [Symbol.dispose](): void {
104
+ for (const pending of this.pendingRequests.values()) {
105
+ clearTimeout(pending.timer);
106
+ pending.reject(new Error('MessageBus disposed'));
107
+ }
108
+ this.pendingRequests.clear();
109
+ this.listeners.clear();
110
+ this.channel.close();
111
+ }
112
+ }
@@ -0,0 +1,183 @@
1
+ import './utils/disposable';
2
+ import { backoff } from './utils/backoff';
3
+ import type { SocketState, Unsubscribe, EventHandler } from './types';
4
+
5
+ interface SharedSocketOptions {
6
+ protocols?: string[];
7
+ reconnect?: boolean;
8
+ reconnectMaxDelay?: number;
9
+ heartbeatInterval?: number;
10
+ sendBuffer?: number;
11
+ auth?: () => string | Promise<string>;
12
+ }
13
+
14
+ export class SharedSocket implements Disposable {
15
+ private ws: WebSocket | null = null;
16
+ private _state: SocketState = 'closed';
17
+ private buffer: unknown[] = [];
18
+ private disposed = false;
19
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
20
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
21
+
22
+ private onMessageFns = new Set<EventHandler>();
23
+ private onStateChangeFns = new Set<(state: SocketState) => void>();
24
+
25
+ private readonly opts: Required<Omit<SharedSocketOptions, 'auth'>> & { auth?: () => string | Promise<string> };
26
+
27
+ constructor(
28
+ private url: string,
29
+ options: SharedSocketOptions = {},
30
+ ) {
31
+ this.opts = {
32
+ protocols: options.protocols ?? [],
33
+ reconnect: options.reconnect ?? true,
34
+ reconnectMaxDelay: options.reconnectMaxDelay ?? 30_000,
35
+ heartbeatInterval: options.heartbeatInterval ?? 30_000,
36
+ sendBuffer: options.sendBuffer ?? 100,
37
+ auth: options.auth,
38
+ };
39
+ }
40
+
41
+ get state(): SocketState {
42
+ return this._state;
43
+ }
44
+
45
+ async connect(): Promise<void> {
46
+ if (this.disposed) return;
47
+
48
+ this.setState('connecting');
49
+
50
+ let connectUrl = this.url;
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
+
57
+ this.ws = new WebSocket(connectUrl, this.opts.protocols);
58
+
59
+ this.ws.onopen = () => {
60
+ this.setState('connected');
61
+ this.flushBuffer();
62
+ this.startHeartbeat();
63
+ };
64
+
65
+ this.ws.onmessage = (ev: MessageEvent) => {
66
+ let data: unknown;
67
+ try {
68
+ data = JSON.parse(ev.data as string);
69
+ } catch {
70
+ data = ev.data;
71
+ }
72
+ for (const fn of this.onMessageFns) fn(data);
73
+ };
74
+
75
+ this.ws.onclose = () => {
76
+ this.stopHeartbeat();
77
+ if (!this.disposed && this.opts.reconnect) {
78
+ this.reconnect();
79
+ } else {
80
+ this.setState('closed');
81
+ }
82
+ };
83
+
84
+ this.ws.onerror = () => {
85
+ // onclose will fire after onerror
86
+ };
87
+ }
88
+
89
+ disconnect(): void {
90
+ this.disposed = true;
91
+ this.stopHeartbeat();
92
+ this.clearReconnect();
93
+
94
+ if (this.ws) {
95
+ this.ws.onclose = null;
96
+ this.ws.onmessage = null;
97
+ this.ws.onerror = null;
98
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
99
+ this.ws.close(1000, 'client disconnect');
100
+ }
101
+ this.ws = null;
102
+ }
103
+
104
+ this.setState('closed');
105
+ }
106
+
107
+ send(data: unknown): void {
108
+ if (this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
109
+ this.ws.send(JSON.stringify(data));
110
+ } else if (this._state === 'reconnecting' || this._state === 'connecting') {
111
+ if (this.buffer.length < this.opts.sendBuffer) {
112
+ this.buffer.push(data);
113
+ }
114
+ }
115
+ }
116
+
117
+ onMessage(fn: EventHandler): Unsubscribe {
118
+ this.onMessageFns.add(fn);
119
+ return () => this.onMessageFns.delete(fn);
120
+ }
121
+
122
+ onStateChange(fn: (state: SocketState) => void): Unsubscribe {
123
+ this.onStateChangeFns.add(fn);
124
+ return () => this.onStateChangeFns.delete(fn);
125
+ }
126
+
127
+ private reconnect(): void {
128
+ this.setState('reconnecting');
129
+ const gen = backoff(1000, this.opts.reconnectMaxDelay);
130
+
131
+ const attempt = () => {
132
+ if (this.disposed) return;
133
+ const delay = gen.next().value;
134
+ this.reconnectTimer = setTimeout(() => {
135
+ if (!this.disposed) this.connect();
136
+ }, delay);
137
+ };
138
+
139
+ attempt();
140
+ }
141
+
142
+ private flushBuffer(): void {
143
+ const pending = this.buffer.splice(0);
144
+ for (const item of pending) {
145
+ this.send(item);
146
+ }
147
+ }
148
+
149
+ private startHeartbeat(): void {
150
+ this.stopHeartbeat();
151
+ this.heartbeatTimer = setInterval(() => {
152
+ if (this.ws?.readyState === WebSocket.OPEN) {
153
+ this.ws.send(JSON.stringify({ type: 'ping' }));
154
+ }
155
+ }, this.opts.heartbeatInterval);
156
+ }
157
+
158
+ private stopHeartbeat(): void {
159
+ if (this.heartbeatTimer) {
160
+ clearInterval(this.heartbeatTimer);
161
+ this.heartbeatTimer = null;
162
+ }
163
+ }
164
+
165
+ private clearReconnect(): void {
166
+ if (this.reconnectTimer) {
167
+ clearTimeout(this.reconnectTimer);
168
+ this.reconnectTimer = null;
169
+ }
170
+ }
171
+
172
+ private setState(state: SocketState): void {
173
+ this._state = state;
174
+ for (const fn of this.onStateChangeFns) fn(state);
175
+ }
176
+
177
+ [Symbol.dispose](): void {
178
+ this.disconnect();
179
+ this.onMessageFns.clear();
180
+ this.onStateChangeFns.clear();
181
+ this.buffer = [];
182
+ }
183
+ }