@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
@@ -0,0 +1,225 @@
1
+ import './utils/disposable';
2
+ import { generateId } from './utils/id';
3
+ import { MessageBus } from './MessageBus';
4
+ import { TabCoordinator } from './TabCoordinator';
5
+ import { SharedSocket } from './SharedSocket';
6
+ import { WorkerSocket } from './WorkerSocket';
7
+ import { SubscriptionManager } from './SubscriptionManager';
8
+ import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler } from './types';
9
+
10
+ /** Common interface for both SharedSocket and WorkerSocket. */
11
+ interface SocketAdapter {
12
+ readonly state: string;
13
+ connect(): void;
14
+ send(data: unknown): void;
15
+ disconnect(): void;
16
+ onMessage(fn: EventHandler): Unsubscribe;
17
+ onStateChange(fn: (state: string) => void): Unsubscribe;
18
+ [Symbol.dispose](): void;
19
+ }
20
+
21
+ /**
22
+ * SharedWebSocket — shares ONE WebSocket connection across browser tabs.
23
+ *
24
+ * One tab becomes the "leader" and holds the WebSocket.
25
+ * Other tabs are "followers" receiving data via BroadcastChannel.
26
+ * If the leader closes, a new leader is elected automatically.
27
+ */
28
+ export class SharedWebSocket implements Disposable {
29
+ private bus: MessageBus;
30
+ private coordinator: TabCoordinator;
31
+ private socket: SocketAdapter | null = null;
32
+ private subs = new SubscriptionManager();
33
+ private syncStore = new Map<string, unknown>();
34
+ private tabId: string;
35
+ private cleanups: Unsubscribe[] = [];
36
+ private disposed = false;
37
+
38
+ constructor(
39
+ private readonly url: string,
40
+ private readonly options: SharedWebSocketOptions = {},
41
+ ) {
42
+ this.tabId = generateId();
43
+ this.bus = new MessageBus('shared-ws', this.tabId);
44
+ this.coordinator = new TabCoordinator(this.bus, this.tabId, {
45
+ electionTimeout: options.electionTimeout,
46
+ heartbeatInterval: options.leaderHeartbeat,
47
+ leaderTimeout: options.leaderTimeout,
48
+ });
49
+
50
+ // When ANY tab receives a WS message via bus → emit to local subscribers
51
+ this.cleanups.push(
52
+ this.bus.subscribe<{ event: string; data: unknown }>('ws:message', (msg) => {
53
+ this.subs.emit(msg.event, msg.data);
54
+ }),
55
+ );
56
+
57
+ // Leader listens for send requests from followers
58
+ this.cleanups.push(
59
+ this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {
60
+ if (this.coordinator.isLeader && this.socket) {
61
+ this.socket.send({ event: msg.event, data: msg.data });
62
+ }
63
+ }),
64
+ );
65
+
66
+ // Sync across tabs
67
+ this.cleanups.push(
68
+ this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {
69
+ this.syncStore.set(msg.key, msg.value);
70
+ this.subs.emit(`sync:${msg.key}`, msg.value);
71
+ }),
72
+ );
73
+
74
+ // Leader lifecycle
75
+ this.coordinator.onBecomeLeader(() => this.onBecomeLeader());
76
+ this.coordinator.onLoseLeadership(() => this.onLoseLeadership());
77
+
78
+ // Cleanup on tab close
79
+ if (typeof window !== 'undefined') {
80
+ const onBeforeUnload = () => this[Symbol.dispose]();
81
+ window.addEventListener('beforeunload', onBeforeUnload);
82
+ this.cleanups.push(() => window.removeEventListener('beforeunload', onBeforeUnload));
83
+ }
84
+ }
85
+
86
+ get connected(): boolean {
87
+ return this.socket?.state === 'connected' || !this.coordinator.isLeader;
88
+ }
89
+
90
+ get tabRole(): TabRole {
91
+ return this.coordinator.isLeader ? 'leader' : 'follower';
92
+ }
93
+
94
+ /** Start leader election and connect. */
95
+ async connect(): Promise<void> {
96
+ await this.coordinator.elect();
97
+ }
98
+
99
+ /** Subscribe to server events (works in ALL tabs). */
100
+ on(event: string, handler: EventHandler): Unsubscribe {
101
+ return this.subs.on(event, handler);
102
+ }
103
+
104
+ once(event: string, handler: EventHandler): Unsubscribe {
105
+ return this.subs.once(event, handler);
106
+ }
107
+
108
+ off(event: string, handler?: EventHandler): void {
109
+ this.subs.off(event, handler);
110
+ }
111
+
112
+ /** Async generator for consuming events. */
113
+ stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
114
+ return this.subs.stream(event, signal);
115
+ }
116
+
117
+ /** Send message to server (auto-routed through leader). */
118
+ send(event: string, data: unknown): void {
119
+ if (this.coordinator.isLeader && this.socket) {
120
+ this.socket.send({ event, data });
121
+ } else {
122
+ this.bus.publish('ws:send', { event, data });
123
+ }
124
+ }
125
+
126
+ /** Request/response through server via leader. */
127
+ async request<T>(event: string, data: unknown, timeout = 5000): Promise<T> {
128
+ return this.bus.request('ws:request', { event, data }, timeout);
129
+ }
130
+
131
+ /** Sync state across tabs (no server roundtrip). */
132
+ sync<T>(key: string, value: T): void {
133
+ this.syncStore.set(key, value);
134
+ this.bus.broadcast('ws:sync', { key, value });
135
+ }
136
+
137
+ getSync<T>(key: string): T | undefined {
138
+ return this.syncStore.get(key) as T | undefined;
139
+ }
140
+
141
+ onSync<T>(key: string, fn: (value: T) => void): Unsubscribe {
142
+ return this.subs.on(`sync:${key}`, fn as EventHandler);
143
+ }
144
+
145
+ disconnect(): void {
146
+ this[Symbol.dispose]();
147
+ }
148
+
149
+ private createSocket(): SocketAdapter {
150
+ const socketOptions = {
151
+ protocols: this.options.protocols,
152
+ reconnect: this.options.reconnect,
153
+ reconnectMaxDelay: this.options.reconnectMaxDelay,
154
+ heartbeatInterval: this.options.heartbeatInterval,
155
+ sendBuffer: this.options.sendBuffer,
156
+ };
157
+
158
+ if (this.options.useWorker) {
159
+ // WebSocket runs in a Web Worker — main thread stays free
160
+ return new WorkerSocket(this.url, {
161
+ ...socketOptions,
162
+ workerUrl: this.options.workerUrl,
163
+ });
164
+ }
165
+
166
+ // WebSocket runs in main thread (default)
167
+ return new SharedSocket(this.url, {
168
+ ...socketOptions,
169
+ auth: this.options.auth,
170
+ });
171
+ }
172
+
173
+ private onBecomeLeader(): void {
174
+ this.socket = this.createSocket();
175
+
176
+ this.socket.onMessage((data: any) => {
177
+ const event = data?.event ?? 'message';
178
+ const payload = data?.data ?? data;
179
+ // Broadcast to ALL tabs (including self)
180
+ this.bus.broadcast('ws:message', { event, data: payload });
181
+ });
182
+
183
+ // Handle send requests from followers (request/response pattern)
184
+ this.cleanups.push(
185
+ this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {
186
+ return new Promise((resolve) => {
187
+ const unsub = this.socket!.onMessage((response: any) => {
188
+ if (response?.event === req.event || response?.requestId) {
189
+ unsub();
190
+ resolve(response?.data ?? response);
191
+ }
192
+ });
193
+ this.socket!.send({ event: req.event, data: req.data });
194
+ });
195
+ }),
196
+ );
197
+
198
+ this.socket.connect();
199
+ }
200
+
201
+ private onLoseLeadership(): void {
202
+ if (this.socket) {
203
+ this.socket[Symbol.dispose]();
204
+ this.socket = null;
205
+ }
206
+ }
207
+
208
+ [Symbol.dispose](): void {
209
+ if (this.disposed) return;
210
+ this.disposed = true;
211
+
212
+ this.coordinator[Symbol.dispose]();
213
+
214
+ if (this.socket) {
215
+ this.socket[Symbol.dispose]();
216
+ this.socket = null;
217
+ }
218
+
219
+ for (const unsub of this.cleanups) unsub();
220
+ this.cleanups = [];
221
+ this.subs[Symbol.dispose]();
222
+ this.bus[Symbol.dispose]();
223
+ this.syncStore.clear();
224
+ }
225
+ }
@@ -0,0 +1,86 @@
1
+ import './utils/disposable';
2
+ import type { EventHandler, Unsubscribe } from './types';
3
+
4
+ export class SubscriptionManager implements Disposable {
5
+ private handlers = new Map<string, Set<EventHandler>>();
6
+ private lastMessages = new Map<string, unknown>();
7
+
8
+ on(event: string, handler: EventHandler): Unsubscribe {
9
+ let set = this.handlers.get(event);
10
+ if (!set) {
11
+ set = new Set();
12
+ this.handlers.set(event, set);
13
+ }
14
+ set.add(handler);
15
+ return () => set!.delete(handler);
16
+ }
17
+
18
+ once(event: string, handler: EventHandler): Unsubscribe {
19
+ const wrapper: EventHandler = (data) => {
20
+ unsub();
21
+ handler(data);
22
+ };
23
+ const unsub = this.on(event, wrapper);
24
+ return unsub;
25
+ }
26
+
27
+ off(event: string, handler?: EventHandler): void {
28
+ if (handler) {
29
+ this.handlers.get(event)?.delete(handler);
30
+ } else {
31
+ this.handlers.delete(event);
32
+ }
33
+ }
34
+
35
+ emit(event: string, data: unknown): void {
36
+ this.lastMessages.set(event, data);
37
+ const set = this.handlers.get(event);
38
+ if (set) {
39
+ for (const fn of set) fn(data);
40
+ }
41
+ }
42
+
43
+ getLastMessage(event: string): unknown | undefined {
44
+ return this.lastMessages.get(event);
45
+ }
46
+
47
+ async *stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
48
+ const queue: unknown[] = [];
49
+ let resolve: (() => void) | null = null;
50
+ let done = false;
51
+
52
+ const unsub = this.on(event, (data) => {
53
+ queue.push(data);
54
+ resolve?.();
55
+ });
56
+
57
+ const onAbort = () => {
58
+ done = true;
59
+ resolve?.();
60
+ };
61
+ signal?.addEventListener('abort', onAbort);
62
+
63
+ try {
64
+ while (!done) {
65
+ if (queue.length > 0) {
66
+ yield queue.shift()!;
67
+ } else {
68
+ await new Promise<void>((r) => { resolve = r; });
69
+ resolve = null;
70
+ }
71
+ }
72
+ } finally {
73
+ unsub();
74
+ signal?.removeEventListener('abort', onAbort);
75
+ }
76
+ }
77
+
78
+ offAll(): void {
79
+ this.handlers.clear();
80
+ this.lastMessages.clear();
81
+ }
82
+
83
+ [Symbol.dispose](): void {
84
+ this.offAll();
85
+ }
86
+ }
@@ -0,0 +1,162 @@
1
+ import './utils/disposable';
2
+ import { MessageBus } from './MessageBus';
3
+ import type { Unsubscribe } from './types';
4
+
5
+ interface CoordinatorOptions {
6
+ electionTimeout?: number; // ms to wait for rejection (default 200)
7
+ heartbeatInterval?: number; // ms between heartbeats (default 2000)
8
+ leaderTimeout?: number; // ms without heartbeat to trigger election (default 5000)
9
+ }
10
+
11
+ export class TabCoordinator implements Disposable {
12
+ private _isLeader = false;
13
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
14
+ private leaderCheckTimer: ReturnType<typeof setInterval> | null = null;
15
+ private lastHeartbeat = 0;
16
+ private disposed = false;
17
+
18
+ private onBecomeLeaderFns = new Set<() => void>();
19
+ private onLoseLeadershipFns = new Set<() => void>();
20
+ private cleanups: Unsubscribe[] = [];
21
+
22
+ private readonly electionTimeout: number;
23
+ private readonly heartbeatInterval: number;
24
+ private readonly leaderTimeout: number;
25
+
26
+ constructor(
27
+ private readonly bus: MessageBus,
28
+ private readonly tabId: string,
29
+ options: CoordinatorOptions = {},
30
+ ) {
31
+ this.electionTimeout = options.electionTimeout ?? 200;
32
+ this.heartbeatInterval = options.heartbeatInterval ?? 2000;
33
+ this.leaderTimeout = options.leaderTimeout ?? 5000;
34
+
35
+ // Listen for election requests — reject if we are leader
36
+ this.cleanups.push(
37
+ this.bus.subscribe<{ tabId: string }>('coord:election', () => {
38
+ if (this._isLeader) {
39
+ this.bus.publish('coord:reject', { tabId: this.tabId });
40
+ }
41
+ }),
42
+ );
43
+
44
+ // Listen for heartbeats
45
+ this.cleanups.push(
46
+ this.bus.subscribe<{ tabId: string }>('coord:heartbeat', () => {
47
+ this.lastHeartbeat = Date.now();
48
+ }),
49
+ );
50
+
51
+ // Listen for abdication
52
+ this.cleanups.push(
53
+ this.bus.subscribe('coord:abdicate', () => {
54
+ if (!this._isLeader && !this.disposed) {
55
+ this.elect();
56
+ }
57
+ }),
58
+ );
59
+ }
60
+
61
+ get isLeader(): boolean {
62
+ return this._isLeader;
63
+ }
64
+
65
+ async elect(): Promise<void> {
66
+ if (this.disposed) return;
67
+
68
+ return new Promise<void>((resolve) => {
69
+ let rejected = false;
70
+
71
+ const unsub = this.bus.subscribe('coord:reject', () => {
72
+ rejected = true;
73
+ unsub();
74
+ // We are follower — start monitoring leader heartbeat
75
+ this.startLeaderCheck();
76
+ resolve();
77
+ });
78
+
79
+ this.bus.publish('coord:election', { tabId: this.tabId });
80
+
81
+ setTimeout(() => {
82
+ unsub();
83
+ if (!rejected && !this.disposed) {
84
+ this.becomeLeader();
85
+ }
86
+ resolve();
87
+ }, this.electionTimeout);
88
+ });
89
+ }
90
+
91
+ abdicate(): void {
92
+ if (!this._isLeader) return;
93
+ this._isLeader = false;
94
+ this.stopHeartbeat();
95
+ this.bus.publish('coord:abdicate', { tabId: this.tabId });
96
+ for (const fn of this.onLoseLeadershipFns) fn();
97
+ }
98
+
99
+ onBecomeLeader(fn: () => void): Unsubscribe {
100
+ this.onBecomeLeaderFns.add(fn);
101
+ return () => this.onBecomeLeaderFns.delete(fn);
102
+ }
103
+
104
+ onLoseLeadership(fn: () => void): Unsubscribe {
105
+ this.onLoseLeadershipFns.add(fn);
106
+ return () => this.onLoseLeadershipFns.delete(fn);
107
+ }
108
+
109
+ private becomeLeader(): void {
110
+ this._isLeader = true;
111
+ this.stopLeaderCheck();
112
+ this.startHeartbeat();
113
+ for (const fn of this.onBecomeLeaderFns) fn();
114
+ }
115
+
116
+ private startHeartbeat(): void {
117
+ this.stopHeartbeat();
118
+ this.heartbeatTimer = setInterval(() => {
119
+ this.bus.publish('coord:heartbeat', { tabId: this.tabId });
120
+ }, this.heartbeatInterval);
121
+ // Send immediately
122
+ this.bus.publish('coord:heartbeat', { tabId: this.tabId });
123
+ }
124
+
125
+ private stopHeartbeat(): void {
126
+ if (this.heartbeatTimer) {
127
+ clearInterval(this.heartbeatTimer);
128
+ this.heartbeatTimer = null;
129
+ }
130
+ }
131
+
132
+ private startLeaderCheck(): void {
133
+ this.stopLeaderCheck();
134
+ this.lastHeartbeat = Date.now();
135
+ this.leaderCheckTimer = setInterval(() => {
136
+ if (Date.now() - this.lastHeartbeat > this.leaderTimeout && !this.disposed) {
137
+ this.stopLeaderCheck();
138
+ this.elect();
139
+ }
140
+ }, 1000);
141
+ }
142
+
143
+ private stopLeaderCheck(): void {
144
+ if (this.leaderCheckTimer) {
145
+ clearInterval(this.leaderCheckTimer);
146
+ this.leaderCheckTimer = null;
147
+ }
148
+ }
149
+
150
+ [Symbol.dispose](): void {
151
+ this.disposed = true;
152
+ if (this._isLeader) {
153
+ this.abdicate();
154
+ }
155
+ this.stopHeartbeat();
156
+ this.stopLeaderCheck();
157
+ for (const unsub of this.cleanups) unsub();
158
+ this.cleanups = [];
159
+ this.onBecomeLeaderFns.clear();
160
+ this.onLoseLeadershipFns.clear();
161
+ }
162
+ }
@@ -0,0 +1,149 @@
1
+ import './utils/disposable';
2
+ import type { SocketState, Unsubscribe, EventHandler } from './types';
3
+
4
+ /**
5
+ * WorkerSocket — WebSocket running inside a Web Worker.
6
+ *
7
+ * Same interface as SharedSocket, but WebSocket lives off main thread.
8
+ * Benefits: heartbeat timers and JSON parsing don't block UI rendering.
9
+ *
10
+ * Use when:
11
+ * - High message rate (50+ msgs/sec)
12
+ * - Heavy JSON payloads
13
+ * - UI does complex rendering that could block main thread
14
+ *
15
+ * Don't use when:
16
+ * - Low message rate (simple chat, notifications)
17
+ * - Bundle size matters (adds worker file)
18
+ * - Debugging (Worker DevTools is less convenient)
19
+ */
20
+ export class WorkerSocket implements Disposable {
21
+ private worker: Worker | null = null;
22
+ private _state: SocketState = 'closed';
23
+
24
+ private onMessageFns = new Set<EventHandler>();
25
+ private onStateChangeFns = new Set<(state: SocketState) => void>();
26
+
27
+ constructor(
28
+ private url: string,
29
+ private options: {
30
+ protocols?: string[];
31
+ reconnect?: boolean;
32
+ reconnectMaxDelay?: number;
33
+ heartbeatInterval?: number;
34
+ sendBuffer?: number;
35
+ workerUrl?: string | URL;
36
+ } = {},
37
+ ) {}
38
+
39
+ get state(): SocketState {
40
+ return this._state;
41
+ }
42
+
43
+ connect(): void {
44
+ // Create worker from inline blob if no workerUrl provided
45
+ const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();
46
+
47
+ this.worker = new Worker(workerUrl, { type: 'module' });
48
+
49
+ this.worker.onmessage = (ev: MessageEvent) => {
50
+ const msg = ev.data;
51
+
52
+ switch (msg.type) {
53
+ case 'state':
54
+ this._state = msg.state;
55
+ for (const fn of this.onStateChangeFns) fn(msg.state);
56
+ break;
57
+
58
+ case 'message':
59
+ for (const fn of this.onMessageFns) fn(msg.data);
60
+ break;
61
+
62
+ case 'open':
63
+ // State already set via 'state' message
64
+ break;
65
+
66
+ case 'close':
67
+ break;
68
+
69
+ case 'error':
70
+ console.error('WorkerSocket error:', msg.message);
71
+ break;
72
+ }
73
+ };
74
+
75
+ this.worker.postMessage({
76
+ type: 'connect',
77
+ url: this.url,
78
+ protocols: this.options.protocols ?? [],
79
+ reconnect: this.options.reconnect ?? true,
80
+ reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,
81
+ heartbeatInterval: this.options.heartbeatInterval ?? 30_000,
82
+ bufferSize: this.options.sendBuffer ?? 100,
83
+ });
84
+ }
85
+
86
+ send(data: unknown): void {
87
+ this.worker?.postMessage({ type: 'send', data });
88
+ }
89
+
90
+ disconnect(): void {
91
+ this.worker?.postMessage({ type: 'disconnect' });
92
+ setTimeout(() => {
93
+ this.worker?.terminate();
94
+ this.worker = null;
95
+ }, 100);
96
+ this._state = 'closed';
97
+ }
98
+
99
+ onMessage(fn: EventHandler): Unsubscribe {
100
+ this.onMessageFns.add(fn);
101
+ return () => this.onMessageFns.delete(fn);
102
+ }
103
+
104
+ onStateChange(fn: (state: SocketState) => void): Unsubscribe {
105
+ this.onStateChangeFns.add(fn);
106
+ return () => this.onStateChangeFns.delete(fn);
107
+ }
108
+
109
+ private createWorkerBlob(): URL {
110
+ // Inline the worker code as a blob URL
111
+ // In production, use a bundler (Vite, webpack) to handle worker imports
112
+ const code = `
113
+ let ws = null, state = 'closed', buffer = [], disposed = false;
114
+ let heartbeatTimer = null, reconnectTimer = null;
115
+ let url = '', protocols = [], shouldReconnect = true;
116
+ let maxDelay = 30000, hbInterval = 30000, maxBuf = 100, delay = 1000;
117
+
118
+ function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
119
+ function connect() {
120
+ if (disposed) return;
121
+ setState('connecting');
122
+ ws = new WebSocket(url, protocols);
123
+ ws.onopen = () => { setState('connected'); delay = 1000; self.postMessage({ type: 'open' }); flush(); startHB(); };
124
+ ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
125
+ ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
126
+ ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
127
+ }
128
+ function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
129
+ function flush() { const p = buffer.splice(0); p.forEach(send); }
130
+ function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send('{"type":"ping"}'); }, hbInterval); }
131
+ function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
132
+ 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); }
133
+ self.onmessage = (e) => {
134
+ const c = e.data;
135
+ if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; connect(); }
136
+ if (c.type === 'send') send(c.data);
137
+ if (c.type === 'disconnect') { disposed = true; stopHB(); if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) { ws.onclose = null; if (ws.readyState < 2) ws.close(1000); ws = null; } buffer = []; setState('closed'); }
138
+ };
139
+ `;
140
+ const blob = new Blob([code], { type: 'application/javascript' });
141
+ return new URL(URL.createObjectURL(blob));
142
+ }
143
+
144
+ [Symbol.dispose](): void {
145
+ this.disconnect();
146
+ this.onMessageFns.clear();
147
+ this.onStateChangeFns.clear();
148
+ }
149
+ }
@@ -0,0 +1,3 @@
1
+ // Import framework-specific adapters directly:
2
+ // import { createSharedWebSocket, useSocketEvent } from 'shared-websocket/adapters/react';
3
+ // import { createSharedWebSocketPlugin, useSocketEvent } from 'shared-websocket/adapters/vue';