@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,189 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ useEffectEvent,
7
+ type ReactNode,
8
+ createElement,
9
+ } from 'react';
10
+ import { SharedWebSocket } from '../SharedWebSocket';
11
+ import type { SharedWebSocketOptions, TabRole } from '../types';
12
+
13
+ // ─── Context ─────────────────────────────────────────────
14
+
15
+ const SharedWSContext = createContext<SharedWebSocket | null>(null);
16
+
17
+ /**
18
+ * Provider props — pass URL and options as props for flexibility.
19
+ *
20
+ * @example
21
+ * <SharedWebSocketProvider url="wss://api.example.com/ws" options={{ auth: getToken }}>
22
+ * <App />
23
+ * </SharedWebSocketProvider>
24
+ */
25
+ export interface SharedWebSocketProviderProps {
26
+ url: string;
27
+ options?: SharedWebSocketOptions;
28
+ children: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Provider component — creates SharedWebSocket from props, auto-disposes on unmount.
33
+ *
34
+ * @example
35
+ * function App() {
36
+ * return (
37
+ * <SharedWebSocketProvider
38
+ * url="wss://api.example.com/ws"
39
+ * options={{
40
+ * auth: () => localStorage.getItem('token')!,
41
+ * useWorker: true,
42
+ * }}
43
+ * >
44
+ * <Dashboard />
45
+ * </SharedWebSocketProvider>
46
+ * );
47
+ * }
48
+ */
49
+ export function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {
50
+ const [socket] = useState(() => {
51
+ const ws = new SharedWebSocket(url, options);
52
+ ws.connect();
53
+ return ws;
54
+ });
55
+
56
+ useEffect(() => {
57
+ return () => {
58
+ socket[Symbol.dispose]();
59
+ };
60
+ }, [socket]);
61
+
62
+ return createElement(SharedWSContext.Provider, { value: socket }, children);
63
+ }
64
+
65
+ /**
66
+ * Access the SharedWebSocket instance from context.
67
+ *
68
+ * @example
69
+ * const ws = useSharedWebSocket();
70
+ * ws.send('chat.message', { text: 'Hello' });
71
+ */
72
+ export function useSharedWebSocket(): SharedWebSocket {
73
+ const ctx = useContext(SharedWSContext);
74
+ if (!ctx) {
75
+ throw new Error('useSharedWebSocket must be used within <SharedWebSocketProvider>');
76
+ }
77
+ return ctx;
78
+ }
79
+
80
+ // ─── Hooks ───────────────────────────────────────────────
81
+
82
+ /**
83
+ * Subscribe to a WebSocket event. Returns the latest received value.
84
+ * Uses useEffectEvent for a stable callback ref — no stale closures.
85
+ *
86
+ * @example
87
+ * const order = useSocketEvent<Order>('order.created');
88
+ */
89
+ export function useSocketEvent<T>(event: string): T | undefined {
90
+ const socket = useSharedWebSocket();
91
+ const [value, setValue] = useState<T | undefined>(undefined);
92
+
93
+ const onEvent = useEffectEvent((data: T) => {
94
+ setValue(data);
95
+ });
96
+
97
+ useEffect(() => {
98
+ const unsub = socket.on(event, onEvent);
99
+ return unsub;
100
+ }, [socket, event]);
101
+
102
+ return value;
103
+ }
104
+
105
+ /**
106
+ * Accumulate WebSocket events into an array.
107
+ * Uses useEffectEvent — handler always sees latest state without re-subscribing.
108
+ *
109
+ * @example
110
+ * const messages = useSocketStream<ChatMessage>('chat.message');
111
+ */
112
+ export function useSocketStream<T>(event: string): T[] {
113
+ const socket = useSharedWebSocket();
114
+ const [items, setItems] = useState<T[]>([]);
115
+
116
+ const onEvent = useEffectEvent((data: T) => {
117
+ setItems((prev) => [...prev, data]);
118
+ });
119
+
120
+ useEffect(() => {
121
+ setItems([]);
122
+ const unsub = socket.on(event, onEvent);
123
+ return unsub;
124
+ }, [socket, event]);
125
+
126
+ return items;
127
+ }
128
+
129
+ /**
130
+ * Two-way state sync across browser tabs.
131
+ * Uses useEffectEvent for stable sync callback.
132
+ *
133
+ * @example
134
+ * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });
135
+ * // setCart in one tab → updates all tabs instantly
136
+ */
137
+ export function useSocketSync<T>(
138
+ key: string,
139
+ initialValue: T,
140
+ ): [T, (value: T) => void] {
141
+ const socket = useSharedWebSocket();
142
+ const [value, setValue] = useState<T>(() => {
143
+ return socket.getSync<T>(key) ?? initialValue;
144
+ });
145
+
146
+ const onSync = useEffectEvent((synced: T) => {
147
+ setValue(synced);
148
+ });
149
+
150
+ useEffect(() => {
151
+ const unsub = socket.onSync<T>(key, onSync);
152
+ return unsub;
153
+ }, [socket, key]);
154
+
155
+ const setAndSync = useEffectEvent((newValue: T) => {
156
+ setValue(newValue);
157
+ socket.sync(key, newValue);
158
+ });
159
+
160
+ return [value, setAndSync];
161
+ }
162
+
163
+ /**
164
+ * Reactive connection status.
165
+ * Uses useEffectEvent to avoid re-creating interval on state change.
166
+ *
167
+ * @example
168
+ * const { connected, tabRole } = useSocketStatus();
169
+ */
170
+ export function useSocketStatus(): {
171
+ connected: boolean;
172
+ tabRole: TabRole;
173
+ } {
174
+ const socket = useSharedWebSocket();
175
+ const [connected, setConnected] = useState(socket.connected);
176
+ const [tabRole, setTabRole] = useState<TabRole>(socket.tabRole);
177
+
178
+ const tick = useEffectEvent(() => {
179
+ setConnected(socket.connected);
180
+ setTabRole(socket.tabRole);
181
+ });
182
+
183
+ useEffect(() => {
184
+ const interval = setInterval(tick, 1000);
185
+ return () => clearInterval(interval);
186
+ }, [socket]);
187
+
188
+ return { connected, tabRole };
189
+ }
@@ -0,0 +1,149 @@
1
+ import {
2
+ ref,
3
+ onUnmounted,
4
+ inject,
5
+ readonly,
6
+ watch,
7
+ type Ref,
8
+ type InjectionKey,
9
+ type App,
10
+ } from 'vue';
11
+ import { SharedWebSocket } from '../SharedWebSocket';
12
+ import type { SharedWebSocketOptions, TabRole } from '../types';
13
+
14
+ // ─── Plugin ──────────────────────────────────────────────
15
+
16
+ export const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');
17
+
18
+ /**
19
+ * Vue 3 plugin for SharedWebSocket.
20
+ *
21
+ * @example
22
+ * const app = createApp(App);
23
+ * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws'));
24
+ */
25
+ export function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {
26
+ return {
27
+ install(app: App) {
28
+ const socket = new SharedWebSocket(url, options);
29
+ socket.connect();
30
+ app.provide(SharedWebSocketKey, socket);
31
+
32
+ // Cleanup on app unmount
33
+ const originalUnmount = app.unmount.bind(app);
34
+ app.unmount = () => {
35
+ socket[Symbol.dispose]();
36
+ originalUnmount();
37
+ };
38
+ },
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Access the SharedWebSocket instance from provided context.
44
+ *
45
+ * @example
46
+ * const ws = useSharedWebSocket();
47
+ */
48
+ export function useSharedWebSocket(): SharedWebSocket {
49
+ const socket = inject(SharedWebSocketKey);
50
+ if (!socket) {
51
+ throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');
52
+ }
53
+ return socket;
54
+ }
55
+
56
+ // ─── Composables ─────────────────────────────────────────
57
+
58
+ /**
59
+ * Subscribe to a WebSocket event. Returns reactive ref with latest value.
60
+ *
61
+ * @example
62
+ * const order = useSocketEvent<Order>('order.created');
63
+ */
64
+ export function useSocketEvent<T>(event: string): Ref<T | undefined> {
65
+ const socket = useSharedWebSocket();
66
+ const value = ref<T | undefined>(undefined) as Ref<T | undefined>;
67
+
68
+ const unsub = socket.on(event, (data: T) => {
69
+ value.value = data;
70
+ });
71
+
72
+ onUnmounted(unsub);
73
+ return readonly(value) as Ref<T | undefined>;
74
+ }
75
+
76
+ /**
77
+ * Accumulate WebSocket events into reactive array.
78
+ *
79
+ * @example
80
+ * const messages = useSocketStream<ChatMessage>('chat.message');
81
+ */
82
+ export function useSocketStream<T>(event: string): Ref<T[]> {
83
+ const socket = useSharedWebSocket();
84
+ const items = ref<T[]>([]) as Ref<T[]>;
85
+
86
+ const unsub = socket.on(event, (data: T) => {
87
+ items.value = [...items.value, data];
88
+ });
89
+
90
+ onUnmounted(unsub);
91
+ return readonly(items) as Ref<T[]>;
92
+ }
93
+
94
+ /**
95
+ * Two-way state sync across browser tabs via reactive ref.
96
+ *
97
+ * @example
98
+ * const cart = useSocketSync<Cart>('cart', { items: [] });
99
+ * cart.value = { items: [1, 2, 3] }; // syncs to all tabs
100
+ */
101
+ export function useSocketSync<T>(key: string, initialValue: T): Ref<T> {
102
+ const socket = useSharedWebSocket();
103
+ const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;
104
+
105
+ const unsub = socket.onSync<T>(key, (v) => {
106
+ value.value = v;
107
+ });
108
+
109
+ // Watch for local changes → sync to other tabs
110
+ watch(
111
+ value,
112
+ (newVal) => {
113
+ socket.sync(key, newVal);
114
+ },
115
+ { deep: true },
116
+ );
117
+
118
+ onUnmounted(unsub);
119
+ return value;
120
+ }
121
+
122
+ /**
123
+ * Reactive connection status.
124
+ *
125
+ * @example
126
+ * const { connected, tabRole } = useSocketStatus();
127
+ */
128
+ export function useSocketStatus(): {
129
+ connected: Ref<boolean>;
130
+ tabRole: Ref<TabRole>;
131
+ } {
132
+ const socket = useSharedWebSocket();
133
+ const connected = ref(socket.connected);
134
+ const tabRole = ref<TabRole>(socket.tabRole);
135
+
136
+ let timer: ReturnType<typeof setInterval>;
137
+
138
+ timer = setInterval(() => {
139
+ connected.value = socket.connected;
140
+ tabRole.value = socket.tabRole;
141
+ }, 1000);
142
+
143
+ onUnmounted(() => clearInterval(timer));
144
+
145
+ return {
146
+ connected: readonly(connected) as Ref<boolean>,
147
+ tabRole: readonly(tabRole) as Ref<TabRole>,
148
+ };
149
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { SharedWebSocket } from './SharedWebSocket';
2
+ export { withSocket, type WithSocketCallback, type WithSocketOptions, type SocketScope } from './withSocket';
3
+ export { MessageBus } from './MessageBus';
4
+ export { TabCoordinator } from './TabCoordinator';
5
+ export { SharedSocket } from './SharedSocket';
6
+ export { WorkerSocket } from './WorkerSocket';
7
+ export { SubscriptionManager } from './SubscriptionManager';
8
+ export type { SharedWebSocketOptions, SocketState, TabRole, Unsubscribe, EventHandler } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
2
+ export type TabRole = 'leader' | 'follower';
3
+ export type Unsubscribe = () => void;
4
+ export type EventHandler = (data: any) => void;
5
+
6
+ export interface BusMessage {
7
+ id: string;
8
+ source: string;
9
+ topic: string;
10
+ type: 'publish' | 'request' | 'response' | 'broadcast';
11
+ data: unknown;
12
+ timestamp: number;
13
+ }
14
+
15
+ export interface SharedWebSocketOptions {
16
+ protocols?: string[];
17
+ reconnect?: boolean;
18
+ reconnectMaxDelay?: number;
19
+ heartbeatInterval?: number;
20
+ electionTimeout?: number;
21
+ leaderHeartbeat?: number;
22
+ leaderTimeout?: number;
23
+ sendBuffer?: number;
24
+ auth?: () => string | Promise<string>;
25
+ /** Run WebSocket inside a Web Worker (offloads JSON parsing, heartbeat from main thread). */
26
+ useWorker?: boolean;
27
+ /** Custom worker URL (if useWorker is true and you want to provide your own worker file). */
28
+ workerUrl?: string | URL;
29
+ }
@@ -0,0 +1,9 @@
1
+ /** Exponential backoff generator with jitter. */
2
+ export function* backoff(base = 1000, max = 30_000): Generator<number> {
3
+ let delay = base;
4
+ while (true) {
5
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1);
6
+ yield Math.min(delay + jitter, max);
7
+ delay = Math.min(delay * 2, max);
8
+ }
9
+ }
@@ -0,0 +1,4 @@
1
+ /** Polyfill Symbol.dispose if not available. */
2
+ if (typeof Symbol.dispose === 'undefined') {
3
+ (Symbol as any).dispose = Symbol('Symbol.dispose');
4
+ }
@@ -0,0 +1,6 @@
1
+ export function generateId(): string {
2
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
3
+ return crypto.randomUUID();
4
+ }
5
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
6
+ }
@@ -0,0 +1,89 @@
1
+ import { SharedWebSocket } from './SharedWebSocket';
2
+ import type { SharedWebSocketOptions } from './types';
3
+
4
+ /**
5
+ * Callback context — destructure what you need.
6
+ */
7
+ export interface SocketScope {
8
+ /** The SharedWebSocket instance. */
9
+ ws: SharedWebSocket;
10
+ /** AbortSignal — aborted when scope exits (use with stream/fetch). */
11
+ signal: AbortSignal;
12
+ }
13
+
14
+ export interface WithSocketOptions extends SharedWebSocketOptions {
15
+ /** External AbortSignal — aborts the scope and disposes the socket. */
16
+ signal?: AbortSignal;
17
+ }
18
+
19
+ /**
20
+ * Scoped WebSocket lifecycle — creates, connects, and auto-disposes.
21
+ * Guarantees cleanup even on errors. No polyfills needed.
22
+ *
23
+ * @example
24
+ * // Basic — destructure { ws }
25
+ * await withSocket('wss://api.example.com/ws', async ({ ws }) => {
26
+ * ws.on('order.created', (order) => console.log(order));
27
+ * await longRunningWork();
28
+ * });
29
+ *
30
+ * @example
31
+ * // With auth and signal
32
+ * await withSocket('wss://api.example.com/ws', {
33
+ * auth: () => localStorage.getItem('token')!,
34
+ * }, async ({ ws, signal }) => {
35
+ * for await (const msg of ws.stream('chat.messages', signal)) {
36
+ * renderMessage(msg);
37
+ * }
38
+ * });
39
+ *
40
+ * @example
41
+ * // External cancellation
42
+ * const controller = new AbortController();
43
+ * setTimeout(() => controller.abort(), 30_000);
44
+ *
45
+ * await withSocket('wss://api.example.com/ws', {
46
+ * signal: controller.signal,
47
+ * }, async ({ ws, signal }) => {
48
+ * ws.on('notifications', (n) => showToast(n));
49
+ * // Stays alive until controller aborts or scope exits
50
+ * await new Promise((_, reject) => signal.addEventListener('abort', reject));
51
+ * });
52
+ */
53
+ export async function withSocket(
54
+ url: string,
55
+ optionsOrCallback: WithSocketOptions | WithSocketCallback,
56
+ maybeCallback?: WithSocketCallback,
57
+ ): Promise<void> {
58
+ let options: WithSocketOptions | undefined;
59
+ let callback: WithSocketCallback;
60
+
61
+ if (typeof optionsOrCallback === 'function') {
62
+ callback = optionsOrCallback;
63
+ } else {
64
+ options = optionsOrCallback;
65
+ callback = maybeCallback!;
66
+ }
67
+
68
+ const ws = new SharedWebSocket(url, options);
69
+ const controller = new AbortController();
70
+
71
+ // Link external signal
72
+ if (options?.signal) {
73
+ if (options.signal.aborted) {
74
+ ws[Symbol.dispose]();
75
+ throw options.signal.reason ?? new Error('Aborted');
76
+ }
77
+ options.signal.addEventListener('abort', () => controller.abort(options!.signal!.reason), { once: true });
78
+ }
79
+
80
+ try {
81
+ await ws.connect();
82
+ await callback({ ws, signal: controller.signal });
83
+ } finally {
84
+ controller.abort();
85
+ ws[Symbol.dispose]();
86
+ }
87
+ }
88
+
89
+ export type WithSocketCallback = (scope: SocketScope) => void | Promise<void>;
@@ -0,0 +1,205 @@
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
+
19
+ type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
20
+
21
+ interface WorkerCommand {
22
+ type: 'connect' | 'send' | 'disconnect';
23
+ url?: string;
24
+ protocols?: string[];
25
+ data?: unknown;
26
+ reconnect?: boolean;
27
+ reconnectMaxDelay?: number;
28
+ heartbeatInterval?: number;
29
+ bufferSize?: number;
30
+ }
31
+
32
+ let ws: WebSocket | null = null;
33
+ let state: SocketState = 'closed';
34
+ let buffer: unknown[] = [];
35
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
36
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
37
+ let disposed = false;
38
+
39
+ let currentUrl = '';
40
+ let currentProtocols: string[] = [];
41
+ let shouldReconnect = true;
42
+ let maxDelay = 30_000;
43
+ let heartbeatInterval = 30_000;
44
+ let maxBuffer = 100;
45
+
46
+ // Backoff state
47
+ let backoffDelay = 1000;
48
+
49
+ function setState(s: SocketState) {
50
+ state = s;
51
+ self.postMessage({ type: 'state', state: s });
52
+ }
53
+
54
+ function connect(url: string, protocols: string[]) {
55
+ if (disposed) return;
56
+
57
+ currentUrl = url;
58
+ currentProtocols = protocols;
59
+ backoffDelay = 1000;
60
+
61
+ doConnect();
62
+ }
63
+
64
+ function doConnect() {
65
+ if (disposed) return;
66
+ setState('connecting');
67
+
68
+ try {
69
+ ws = new WebSocket(currentUrl, currentProtocols);
70
+ } catch (e) {
71
+ self.postMessage({ type: 'error', message: String(e) });
72
+ if (shouldReconnect) scheduleReconnect();
73
+ return;
74
+ }
75
+
76
+ ws.onopen = () => {
77
+ setState('connected');
78
+ backoffDelay = 1000;
79
+ self.postMessage({ type: 'open' });
80
+ flushBuffer();
81
+ startHeartbeat();
82
+ };
83
+
84
+ ws.onmessage = (ev: MessageEvent) => {
85
+ let data: unknown;
86
+ try {
87
+ data = JSON.parse(ev.data as string);
88
+ } catch {
89
+ data = ev.data;
90
+ }
91
+ self.postMessage({ type: 'message', data });
92
+ };
93
+
94
+ ws.onclose = (ev) => {
95
+ stopHeartbeat();
96
+ self.postMessage({ type: 'close', code: ev.code, reason: ev.reason });
97
+
98
+ if (!disposed && shouldReconnect && ev.code !== 1000) {
99
+ scheduleReconnect();
100
+ } else {
101
+ setState('closed');
102
+ }
103
+ };
104
+
105
+ ws.onerror = () => {
106
+ self.postMessage({ type: 'error', message: 'WebSocket error' });
107
+ };
108
+ }
109
+
110
+ function send(data: unknown) {
111
+ if (state === 'connected' && ws?.readyState === WebSocket.OPEN) {
112
+ ws.send(JSON.stringify(data));
113
+ } else if (state === 'connecting' || state === 'reconnecting') {
114
+ if (buffer.length < maxBuffer) {
115
+ buffer.push(data);
116
+ }
117
+ }
118
+ }
119
+
120
+ function disconnect() {
121
+ disposed = true;
122
+ shouldReconnect = false;
123
+ stopHeartbeat();
124
+ clearReconnect();
125
+
126
+ if (ws) {
127
+ ws.onclose = null;
128
+ ws.onmessage = null;
129
+ ws.onerror = null;
130
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
131
+ ws.close(1000, 'worker disconnect');
132
+ }
133
+ ws = null;
134
+ }
135
+
136
+ buffer = [];
137
+ setState('closed');
138
+ }
139
+
140
+ function flushBuffer() {
141
+ const pending = buffer.splice(0);
142
+ for (const item of pending) {
143
+ send(item);
144
+ }
145
+ }
146
+
147
+ function startHeartbeat() {
148
+ stopHeartbeat();
149
+ heartbeatTimer = setInterval(() => {
150
+ if (ws?.readyState === WebSocket.OPEN) {
151
+ ws.send(JSON.stringify({ type: 'ping' }));
152
+ }
153
+ }, heartbeatInterval);
154
+ }
155
+
156
+ function stopHeartbeat() {
157
+ if (heartbeatTimer) {
158
+ clearInterval(heartbeatTimer);
159
+ heartbeatTimer = null;
160
+ }
161
+ }
162
+
163
+ function scheduleReconnect() {
164
+ setState('reconnecting');
165
+ clearReconnect();
166
+
167
+ const jitter = backoffDelay * 0.25 * (Math.random() * 2 - 1);
168
+ const delay = Math.min(backoffDelay + jitter, maxDelay);
169
+
170
+ reconnectTimer = setTimeout(() => {
171
+ if (!disposed) doConnect();
172
+ }, delay);
173
+
174
+ backoffDelay = Math.min(backoffDelay * 2, maxDelay);
175
+ }
176
+
177
+ function clearReconnect() {
178
+ if (reconnectTimer) {
179
+ clearTimeout(reconnectTimer);
180
+ reconnectTimer = null;
181
+ }
182
+ }
183
+
184
+ // Listen for commands from main thread
185
+ self.onmessage = (ev: MessageEvent<WorkerCommand>) => {
186
+ const cmd = ev.data;
187
+
188
+ switch (cmd.type) {
189
+ case 'connect':
190
+ if (cmd.reconnect !== undefined) shouldReconnect = cmd.reconnect;
191
+ if (cmd.reconnectMaxDelay) maxDelay = cmd.reconnectMaxDelay;
192
+ if (cmd.heartbeatInterval) heartbeatInterval = cmd.heartbeatInterval;
193
+ if (cmd.bufferSize) maxBuffer = cmd.bufferSize;
194
+ connect(cmd.url!, cmd.protocols ?? []);
195
+ break;
196
+
197
+ case 'send':
198
+ send(cmd.data);
199
+ break;
200
+
201
+ case 'disconnect':
202
+ disconnect();
203
+ break;
204
+ }
205
+ };