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.
- package/README.md +53 -84
- package/bin/cli.js +1717 -6
- package/components.json +18 -18
- package/package.json +98 -109
- package/postcss.config.js +3 -3
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/postinstall.js +47 -58
- package/shared/ai.ts +118 -202
- package/shared/config.ts +37 -39
- package/shared/logger.ts +6 -14
- package/shared/paths.ts +10 -16
- package/shared/relay.ts +89 -98
- package/supervisor/backend.ts +77 -89
- package/supervisor/chat/ARCHITECTURE.md +13 -14
- package/supervisor/chat/OnboardWizard.tsx +2194 -3419
- package/supervisor/chat/fluxy-main.tsx +495 -633
- package/supervisor/chat/fluxy.html +24 -36
- package/supervisor/chat/onboard-main.tsx +8 -8
- package/supervisor/chat/onboard.html +10 -13
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
- package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
- package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
- package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
- package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
- package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
- package/supervisor/chat/src/hooks/useChat.ts +210 -259
- package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
- package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
- package/supervisor/chat/src/lib/auth.ts +20 -23
- package/supervisor/chat/src/lib/ws-client.ts +113 -125
- package/supervisor/chat/src/styles/globals.css +76 -85
- package/supervisor/file-saver.ts +39 -52
- package/supervisor/fluxy-agent.ts +213 -292
- package/supervisor/index.ts +895 -1263
- package/supervisor/scheduler.ts +237 -299
- package/supervisor/tunnel.ts +113 -167
- package/supervisor/vite-dev.ts +51 -61
- package/supervisor/widget.js +135 -147
- package/supervisor/worker.ts +45 -49
- package/tsconfig.json +18 -24
- package/vite.config.ts +48 -55
- package/vite.fluxy.config.ts +34 -34
- package/worker/claude-auth.ts +202 -265
- package/worker/codex-auth.ts +144 -181
- package/worker/db.ts +63 -191
- package/worker/index.ts +601 -842
- package/workspace/MYSELF.md +1 -1
- package/workspace/PULSE.json +6 -6
- package/workspace/backend/index.ts +16 -19
- package/workspace/client/index.html +47 -60
- package/workspace/client/public/manifest.json +21 -21
- package/workspace/client/public/sw.js +29 -31
- package/workspace/client/src/App.tsx +91 -136
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
- package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
- package/workspace/client/src/components/Layout/Footer.tsx +12 -12
- package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
- package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
- package/workspace/client/src/components/ui/avatar.tsx +85 -85
- package/workspace/client/src/components/ui/badge.tsx +40 -41
- package/workspace/client/src/components/ui/button.tsx +56 -57
- package/workspace/client/src/components/ui/card.tsx +75 -75
- package/workspace/client/src/components/ui/dialog.tsx +114 -117
- package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
- package/workspace/client/src/components/ui/input.tsx +17 -17
- package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
- package/workspace/client/src/components/ui/select.tsx +145 -151
- package/workspace/client/src/components/ui/separator.tsx +21 -21
- package/workspace/client/src/components/ui/sheet.tsx +102 -102
- package/workspace/client/src/components/ui/skeleton.tsx +10 -10
- package/workspace/client/src/components/ui/switch.tsx +26 -26
- package/workspace/client/src/components/ui/tabs.tsx +69 -69
- package/workspace/client/src/components/ui/textarea.tsx +14 -14
- package/workspace/client/src/components/ui/tooltip.tsx +37 -37
- package/workspace/client/src/lib/utils.ts +1 -1
- package/workspace/client/src/main.tsx +3 -3
- package/workspace/client/src/styles/globals.css +76 -85
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
- package/bin/cli.backup.js +0 -2138
- package/cli/commands/daemon.ts +0 -42
- package/cli/commands/init.ts +0 -32
- package/cli/commands/start.ts +0 -113
- package/cli/commands/tunnel.temp.ts +0 -206
- package/cli/commands/tunnel.ts +0 -227
- package/cli/commands/update.ts +0 -163
- package/cli/core/base-adapter.ts +0 -156
- package/cli/core/cloudflared.ts +0 -113
- package/cli/core/config.ts +0 -73
- package/cli/core/os-detector.ts +0 -43
- package/cli/core/server.ts +0 -109
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -146
- package/cli/platforms/index.ts +0 -21
- package/cli/platforms/linux.ts +0 -156
- package/cli/platforms/win32.ts +0 -34
- 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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export function setAuthToken(token: string): void {
|
|
10
|
-
|
|
10
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function clearAuthToken(): void {
|
|
14
|
-
|
|
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
|
-
|
|
19
|
+
authFailureCallback = cb;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function authFetch(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
6
|
-
|
|
5
|
+
type: string;
|
|
6
|
+
data: any;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export class WsClient {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|