fluxy-bot 0.8.9 → 0.9.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 (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -2,167 +2,145 @@ import { useRef, useCallback, useMemo } from 'react';
2
2
 
3
3
  // Extend Window for vendor-prefixed SpeechRecognition
4
4
  interface SpeechRecognitionEvent extends Event {
5
- results: SpeechRecognitionResultList;
6
- resultIndex: number;
5
+ results: SpeechRecognitionResultList;
6
+ resultIndex: number;
7
7
  }
8
8
 
9
9
  type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
10
10
 
11
11
  interface SpeechRecognitionInstance extends EventTarget {
12
- continuous: boolean;
13
- interimResults: boolean;
14
- lang: string;
15
- start(): void;
16
- stop(): void;
17
- abort(): void;
18
- onresult: ((event: SpeechRecognitionEvent) => void) | null;
19
- onend: (() => void) | null;
20
- onerror: ((event: Event & { error: string }) => void) | null;
12
+ continuous: boolean;
13
+ interimResults: boolean;
14
+ lang: string;
15
+ start(): void;
16
+ stop(): void;
17
+ abort(): void;
18
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
19
+ onend: (() => void) | null;
20
+ onerror: ((event: Event & { error: string }) => void) | null;
21
21
  }
22
22
 
23
23
  const getSpeechRecognitionCtor = (): SpeechRecognitionConstructor | null => {
24
- if (typeof window === 'undefined') return null;
25
- return (
26
- (window as unknown as Record<string, SpeechRecognitionConstructor>)
27
- .SpeechRecognition ??
28
- (window as unknown as Record<string, SpeechRecognitionConstructor>)
29
- .webkitSpeechRecognition ??
30
- null
31
- );
24
+ if (typeof window === 'undefined') return null;
25
+ return (
26
+ (window as unknown as Record<string, SpeechRecognitionConstructor>).SpeechRecognition ??
27
+ (window as unknown as Record<string, SpeechRecognitionConstructor>).webkitSpeechRecognition ??
28
+ null
29
+ );
32
30
  };
33
31
 
34
32
  export const isWebSpeechSupported = getSpeechRecognitionCtor() !== null;
35
33
 
36
34
  export function useSpeechRecognition() {
37
- const instanceRef = useRef<SpeechRecognitionInstance | null>(null);
38
- const transcriptRef = useRef('');
39
- const resolveRef = useRef<((text: string) => void) | null>(null);
40
-
41
- const isSupported = useMemo(() => isWebSpeechSupported, []);
42
-
43
- const start = useCallback(() => {
44
- console.log('[SpeechRecognition] start()');
45
- const Ctor = getSpeechRecognitionCtor();
46
- if (!Ctor) {
47
- console.log('[SpeechRecognition] not available');
48
- return;
49
- }
50
-
51
- // Clean up any lingering instance
52
- if (instanceRef.current) {
53
- try {
54
- instanceRef.current.abort();
55
- } catch {}
56
- }
57
-
58
- const recognition = new Ctor();
59
- recognition.continuous = false; // single utterance — matches press-hold-release UX
60
- recognition.interimResults = true; // get progressive updates while speaking
61
- recognition.lang = navigator.language || 'en-US';
62
-
63
- transcriptRef.current = '';
64
- instanceRef.current = recognition;
65
-
66
- recognition.onresult = (event: SpeechRecognitionEvent) => {
67
- // Grab the latest result — it's always the most complete/accurate
68
- const last = event.results[event.results.length - 1];
69
- const text = last[0].transcript;
70
- console.log(
71
- '[SpeechRecognition] onresult:',
72
- JSON.stringify(text),
73
- 'isFinal:',
74
- last.isFinal
75
- );
76
- transcriptRef.current = text;
77
- };
78
-
79
- recognition.onend = () => {
80
- console.log(
81
- '[SpeechRecognition] onend, transcript:',
82
- JSON.stringify(transcriptRef.current)
83
- );
84
- instanceRef.current = null;
85
- // If stop() is waiting for a result, resolve it now
86
- if (resolveRef.current) {
87
- resolveRef.current(transcriptRef.current);
88
- resolveRef.current = null;
89
- }
90
- };
91
-
92
- recognition.onerror = event => {
93
- console.error('[SpeechRecognition] error:', event.error);
94
- instanceRef.current = null;
95
- if (resolveRef.current) {
96
- resolveRef.current(transcriptRef.current);
97
- resolveRef.current = null;
98
- }
99
- };
100
-
101
- try {
102
- recognition.start();
103
- console.log('[SpeechRecognition] started, lang:', recognition.lang);
104
- } catch (e) {
105
- console.error('[SpeechRecognition] start failed:', e);
106
- }
107
- }, []);
108
-
109
- const stop = useCallback((): Promise<string> => {
110
- console.log(
111
- '[SpeechRecognition] stop(), transcript so far:',
112
- JSON.stringify(transcriptRef.current)
113
- );
114
- return new Promise(resolve => {
115
- const instance = instanceRef.current;
116
- if (!instance) {
117
- // Already ended (e.g. short utterance auto-stopped)
118
- console.log(
119
- '[SpeechRecognition] no instance, resolving immediately'
120
- );
121
- resolve(transcriptRef.current);
122
- return;
123
- }
124
-
125
- // Wait for onend to fire with the final transcript
126
- resolveRef.current = resolve;
127
-
128
- try {
129
- instance.stop(); // triggers onend after finalizing
130
- } catch {
131
- instanceRef.current = null;
132
- resolve(transcriptRef.current);
133
- resolveRef.current = null;
134
- }
135
-
136
- // Safety timeout in case onend never fires
137
- setTimeout(() => {
138
- if (resolveRef.current) {
139
- console.log(
140
- '[SpeechRecognition] safety timeout, resolving with:',
141
- JSON.stringify(transcriptRef.current)
142
- );
143
- resolveRef.current(transcriptRef.current);
144
- resolveRef.current = null;
145
- instanceRef.current = null;
146
- }
147
- }, 2000);
148
- });
149
- }, []);
150
-
151
- const abort = useCallback(() => {
152
- console.log('[SpeechRecognition] abort()');
153
- transcriptRef.current = '';
154
- const instance = instanceRef.current;
155
- if (instance) {
156
- try {
157
- instance.abort();
158
- } catch {}
159
- instanceRef.current = null;
160
- }
35
+ const instanceRef = useRef<SpeechRecognitionInstance | null>(null);
36
+ const transcriptRef = useRef('');
37
+ const resolveRef = useRef<((text: string) => void) | null>(null);
38
+
39
+ const isSupported = useMemo(() => isWebSpeechSupported, []);
40
+
41
+ const start = useCallback(() => {
42
+ console.log('[SpeechRecognition] start()');
43
+ const Ctor = getSpeechRecognitionCtor();
44
+ if (!Ctor) {
45
+ console.log('[SpeechRecognition] not available');
46
+ return;
47
+ }
48
+
49
+ // Clean up any lingering instance
50
+ if (instanceRef.current) {
51
+ try { instanceRef.current.abort(); } catch {}
52
+ }
53
+
54
+ const recognition = new Ctor();
55
+ recognition.continuous = false; // single utterance — matches press-hold-release UX
56
+ recognition.interimResults = true; // get progressive updates while speaking
57
+ recognition.lang = navigator.language || 'en-US';
58
+
59
+ transcriptRef.current = '';
60
+ instanceRef.current = recognition;
61
+
62
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
63
+ // Grab the latest result — it's always the most complete/accurate
64
+ const last = event.results[event.results.length - 1];
65
+ const text = last[0].transcript;
66
+ console.log('[SpeechRecognition] onresult:', JSON.stringify(text), 'isFinal:', last.isFinal);
67
+ transcriptRef.current = text;
68
+ };
69
+
70
+ recognition.onend = () => {
71
+ console.log('[SpeechRecognition] onend, transcript:', JSON.stringify(transcriptRef.current));
72
+ instanceRef.current = null;
73
+ // If stop() is waiting for a result, resolve it now
74
+ if (resolveRef.current) {
75
+ resolveRef.current(transcriptRef.current);
76
+ resolveRef.current = null;
77
+ }
78
+ };
79
+
80
+ recognition.onerror = (event) => {
81
+ console.error('[SpeechRecognition] error:', event.error);
82
+ instanceRef.current = null;
83
+ if (resolveRef.current) {
84
+ resolveRef.current(transcriptRef.current);
85
+ resolveRef.current = null;
86
+ }
87
+ };
88
+
89
+ try {
90
+ recognition.start();
91
+ console.log('[SpeechRecognition] started, lang:', recognition.lang);
92
+ } catch (e) {
93
+ console.error('[SpeechRecognition] start failed:', e);
94
+ }
95
+ }, []);
96
+
97
+ const stop = useCallback((): Promise<string> => {
98
+ console.log('[SpeechRecognition] stop(), transcript so far:', JSON.stringify(transcriptRef.current));
99
+ return new Promise((resolve) => {
100
+ const instance = instanceRef.current;
101
+ if (!instance) {
102
+ // Already ended (e.g. short utterance auto-stopped)
103
+ console.log('[SpeechRecognition] no instance, resolving immediately');
104
+ resolve(transcriptRef.current);
105
+ return;
106
+ }
107
+
108
+ // Wait for onend to fire with the final transcript
109
+ resolveRef.current = resolve;
110
+
111
+ try {
112
+ instance.stop(); // triggers onend after finalizing
113
+ } catch {
114
+ instanceRef.current = null;
115
+ resolve(transcriptRef.current);
116
+ resolveRef.current = null;
117
+ }
118
+
119
+ // Safety timeout in case onend never fires
120
+ setTimeout(() => {
161
121
  if (resolveRef.current) {
162
- resolveRef.current('');
163
- resolveRef.current = null;
122
+ console.log('[SpeechRecognition] safety timeout, resolving with:', JSON.stringify(transcriptRef.current));
123
+ resolveRef.current(transcriptRef.current);
124
+ resolveRef.current = null;
125
+ instanceRef.current = null;
164
126
  }
165
- }, []);
166
-
167
- return { start, stop, abort, isSupported };
127
+ }, 2000);
128
+ });
129
+ }, []);
130
+
131
+ const abort = useCallback(() => {
132
+ console.log('[SpeechRecognition] abort()');
133
+ transcriptRef.current = '';
134
+ const instance = instanceRef.current;
135
+ if (instance) {
136
+ try { instance.abort(); } catch {}
137
+ instanceRef.current = null;
138
+ }
139
+ if (resolveRef.current) {
140
+ resolveRef.current('');
141
+ resolveRef.current = null;
142
+ }
143
+ }, []);
144
+
145
+ return { start, stop, abort, isSupported };
168
146
  }
@@ -3,39 +3,36 @@ const TOKEN_KEY = 'fluxy_token';
3
3
  let authFailureCallback: (() => void) | null = null;
4
4
 
5
5
  export function getAuthToken(): string | null {
6
- return localStorage.getItem(TOKEN_KEY);
6
+ return localStorage.getItem(TOKEN_KEY);
7
7
  }
8
8
 
9
9
  export function setAuthToken(token: string): void {
10
- localStorage.setItem(TOKEN_KEY, token);
10
+ localStorage.setItem(TOKEN_KEY, token);
11
11
  }
12
12
 
13
13
  export function clearAuthToken(): void {
14
- localStorage.removeItem(TOKEN_KEY);
14
+ localStorage.removeItem(TOKEN_KEY);
15
15
  }
16
16
 
17
17
  /** Register a callback for when a 401 is received (token expired mid-session) */
18
18
  export function onAuthFailure(cb: () => void): void {
19
- authFailureCallback = cb;
19
+ authFailureCallback = cb;
20
20
  }
21
21
 
22
- export async function authFetch(
23
- url: string,
24
- options: RequestInit = {}
25
- ): Promise<Response> {
26
- const token = getAuthToken();
27
- const headers = new Headers(options.headers);
28
- if (token) {
29
- headers.set('Authorization', `Bearer ${token}`);
30
- }
31
-
32
- const res = await fetch(url, { ...options, headers });
33
-
34
- if (res.status === 401 && token) {
35
- // Token was present but rejected — it expired
36
- clearAuthToken();
37
- authFailureCallback?.();
38
- }
39
-
40
- return res;
22
+ export async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
23
+ const token = getAuthToken();
24
+ const headers = new Headers(options.headers);
25
+ if (token) {
26
+ headers.set('Authorization', `Bearer ${token}`);
27
+ }
28
+
29
+ const res = await fetch(url, { ...options, headers });
30
+
31
+ if (res.status === 401 && token) {
32
+ // Token was present but rejected it expired
33
+ clearAuthToken();
34
+ authFailureCallback?.();
35
+ }
36
+
37
+ return res;
41
38
  }
@@ -2,137 +2,125 @@ type MessageHandler = (msg: any) => void;
2
2
  type StatusHandler = (connected: boolean) => void;
3
3
 
4
4
  interface QueuedMessage {
5
- type: string;
6
- data: any;
5
+ type: string;
6
+ data: any;
7
7
  }
8
8
 
9
9
  export class WsClient {
10
- private ws: WebSocket | null = null;
11
- private handlers = new Map<string, Set<MessageHandler>>();
12
- private statusHandlers = new Set<StatusHandler>();
13
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
14
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
15
- private url: string;
16
- private queue: QueuedMessage[] = [];
17
- private intentionalClose = false;
18
- private reconnectDelay = 1000;
19
- private static MAX_RECONNECT_DELAY = 8000;
20
- private tokenGetter: (() => string | null) | null = null;
21
-
22
- constructor(url?: string, tokenGetter?: (() => string | null) | null) {
23
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
24
- const host = import.meta.env.DEV ? 'localhost:3000' : location.host;
25
- this.url = url ?? `${proto}//${host}/ws`;
26
- this.tokenGetter = tokenGetter ?? null;
10
+ private ws: WebSocket | null = null;
11
+ private handlers = new Map<string, Set<MessageHandler>>();
12
+ private statusHandlers = new Set<StatusHandler>();
13
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
14
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
15
+ private url: string;
16
+ private queue: QueuedMessage[] = [];
17
+ private intentionalClose = false;
18
+ private reconnectDelay = 1000;
19
+ private static MAX_RECONNECT_DELAY = 8000;
20
+ private tokenGetter: (() => string | null) | null = null;
21
+
22
+ constructor(url?: string, tokenGetter?: (() => string | null) | null) {
23
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
24
+ const host = import.meta.env.DEV ? 'localhost:3000' : location.host;
25
+ this.url = url ?? `${proto}//${host}/ws`;
26
+ this.tokenGetter = tokenGetter ?? null;
27
+ }
28
+
29
+ connect(): void {
30
+ this.intentionalClose = false;
31
+ let wsUrl = this.url;
32
+ if (this.tokenGetter) {
33
+ const token = this.tokenGetter();
34
+ if (token) {
35
+ const sep = wsUrl.includes('?') ? '&' : '?';
36
+ wsUrl = `${wsUrl}${sep}token=${token}`;
37
+ }
27
38
  }
28
-
29
- connect(): void {
30
- this.intentionalClose = false;
31
- let wsUrl = this.url;
32
- if (this.tokenGetter) {
33
- const token = this.tokenGetter();
34
- if (token) {
35
- const sep = wsUrl.includes('?') ? '&' : '?';
36
- wsUrl = `${wsUrl}${sep}token=${token}`;
37
- }
38
- }
39
- this.ws = new WebSocket(wsUrl);
40
-
41
- this.ws.onopen = () => {
42
- this.reconnectDelay = 1000;
43
- this.notifyStatus(true);
44
- this.flushQueue();
45
- this.startHeartbeat();
46
- };
47
-
48
- this.ws.onmessage = e => {
49
- // Ignore pong frames
50
- if (e.data === 'pong') return;
51
- const msg = JSON.parse(e.data);
52
- const handlers = this.handlers.get(msg.type);
53
- handlers?.forEach(h => h(msg.data));
54
- };
55
-
56
- this.ws.onclose = () => {
57
- this.stopHeartbeat();
58
- this.notifyStatus(false);
59
- if (!this.intentionalClose) {
60
- this.reconnectTimer = setTimeout(() => {
61
- this.reconnectDelay = Math.min(
62
- this.reconnectDelay * 2,
63
- WsClient.MAX_RECONNECT_DELAY
64
- );
65
- this.connect();
66
- }, this.reconnectDelay);
67
- }
68
- };
69
-
70
- this.ws.onerror = () => this.ws?.close();
71
- }
72
-
73
- disconnect(): void {
74
- this.intentionalClose = true;
75
- if (this.reconnectTimer) {
76
- clearTimeout(this.reconnectTimer);
77
- this.reconnectTimer = null;
78
- }
79
- this.stopHeartbeat();
80
- this.ws?.close();
81
- this.ws = null;
82
- }
83
-
84
- on(type: string, handler: MessageHandler): () => void {
85
- if (!this.handlers.has(type)) this.handlers.set(type, new Set());
86
- this.handlers.get(type)!.add(handler);
87
- return () => this.handlers.get(type)?.delete(handler);
88
- }
89
-
90
- onStatus(handler: StatusHandler): () => void {
91
- this.statusHandlers.add(handler);
92
- return () => this.statusHandlers.delete(handler);
93
- }
94
-
95
- send(type: string, data: any): void {
96
- const message = { type, data };
97
- if (this.ws?.readyState === WebSocket.OPEN) {
98
- this.ws.send(JSON.stringify(message));
99
- } else {
100
- // Queue for delivery on reconnect
101
- this.queue.push(message);
102
- }
103
- }
104
-
105
- get connected(): boolean {
106
- return this.ws?.readyState === WebSocket.OPEN;
107
- }
108
-
109
- private flushQueue(): void {
110
- while (
111
- this.queue.length > 0 &&
112
- this.ws?.readyState === WebSocket.OPEN
113
- ) {
114
- const msg = this.queue.shift()!;
115
- this.ws.send(JSON.stringify(msg));
116
- }
117
- }
118
-
119
- private notifyStatus(connected: boolean): void {
120
- this.statusHandlers.forEach(h => h(connected));
39
+ this.ws = new WebSocket(wsUrl);
40
+
41
+ this.ws.onopen = () => {
42
+ this.reconnectDelay = 1000;
43
+ this.notifyStatus(true);
44
+ this.flushQueue();
45
+ this.startHeartbeat();
46
+ };
47
+
48
+ this.ws.onmessage = (e) => {
49
+ // Ignore pong frames
50
+ if (e.data === 'pong') return;
51
+ const msg = JSON.parse(e.data);
52
+ const handlers = this.handlers.get(msg.type);
53
+ handlers?.forEach((h) => h(msg.data));
54
+ };
55
+
56
+ this.ws.onclose = () => {
57
+ this.stopHeartbeat();
58
+ this.notifyStatus(false);
59
+ if (!this.intentionalClose) {
60
+ this.reconnectTimer = setTimeout(() => {
61
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, WsClient.MAX_RECONNECT_DELAY);
62
+ this.connect();
63
+ }, this.reconnectDelay);
64
+ }
65
+ };
66
+
67
+ this.ws.onerror = () => this.ws?.close();
68
+ }
69
+
70
+ disconnect(): void {
71
+ this.intentionalClose = true;
72
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
73
+ this.stopHeartbeat();
74
+ this.ws?.close();
75
+ this.ws = null;
76
+ }
77
+
78
+ on(type: string, handler: MessageHandler): () => void {
79
+ if (!this.handlers.has(type)) this.handlers.set(type, new Set());
80
+ this.handlers.get(type)!.add(handler);
81
+ return () => this.handlers.get(type)?.delete(handler);
82
+ }
83
+
84
+ onStatus(handler: StatusHandler): () => void {
85
+ this.statusHandlers.add(handler);
86
+ return () => this.statusHandlers.delete(handler);
87
+ }
88
+
89
+ send(type: string, data: any): void {
90
+ const message = { type, data };
91
+ if (this.ws?.readyState === WebSocket.OPEN) {
92
+ this.ws.send(JSON.stringify(message));
93
+ } else {
94
+ // Queue for delivery on reconnect
95
+ this.queue.push(message);
121
96
  }
97
+ }
122
98
 
123
- private startHeartbeat(): void {
124
- this.stopHeartbeat();
125
- this.heartbeatTimer = setInterval(() => {
126
- if (this.ws?.readyState === WebSocket.OPEN) {
127
- this.ws.send('ping');
128
- }
129
- }, 25_000);
130
- }
99
+ get connected(): boolean {
100
+ return this.ws?.readyState === WebSocket.OPEN;
101
+ }
131
102
 
132
- private stopHeartbeat(): void {
133
- if (this.heartbeatTimer) {
134
- clearInterval(this.heartbeatTimer);
135
- this.heartbeatTimer = null;
136
- }
103
+ private flushQueue(): void {
104
+ while (this.queue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
105
+ const msg = this.queue.shift()!;
106
+ this.ws.send(JSON.stringify(msg));
137
107
  }
108
+ }
109
+
110
+ private notifyStatus(connected: boolean): void {
111
+ this.statusHandlers.forEach((h) => h(connected));
112
+ }
113
+
114
+ private startHeartbeat(): void {
115
+ this.stopHeartbeat();
116
+ this.heartbeatTimer = setInterval(() => {
117
+ if (this.ws?.readyState === WebSocket.OPEN) {
118
+ this.ws.send('ping');
119
+ }
120
+ }, 25_000);
121
+ }
122
+
123
+ private stopHeartbeat(): void {
124
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
125
+ }
138
126
  }