@mooncompany/uplink-chat 0.5.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.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Uplink Watchdog
|
|
5
|
+
*
|
|
6
|
+
* Monitors the server process and restarts it on crash with exponential backoff.
|
|
7
|
+
* Spawned by `uplink-chat -d` — runs detached, manages the server lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Exponential backoff on crash (1s, 2s, 4s... max 60s)
|
|
11
|
+
* - Clean exit detection (code 0, SIGTERM) → watchdog exits too
|
|
12
|
+
* - Crash loop protection (5 crashes in 2 minutes → stop)
|
|
13
|
+
* - State file for status reporting (.uplink-watchdog.json)
|
|
14
|
+
* - Log file for restart history (.uplink-watchdog.log)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn, execSync } from 'child_process';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { dirname, join } from 'path';
|
|
20
|
+
import { writeFileSync, appendFileSync, readFileSync, existsSync, unlinkSync, openSync } from 'fs';
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = dirname(__filename);
|
|
24
|
+
const ROOT = join(__dirname, '..');
|
|
25
|
+
|
|
26
|
+
const PID_FILE = join(ROOT, '.uplink-watchdog.pid');
|
|
27
|
+
const STATE_FILE = join(ROOT, '.uplink-watchdog.json');
|
|
28
|
+
const LOG_FILE = join(ROOT, '.uplink-watchdog.log');
|
|
29
|
+
const SERVER_PATH = join(ROOT, 'server.js');
|
|
30
|
+
|
|
31
|
+
// Configuration
|
|
32
|
+
const MAX_BACKOFF_MS = 60000; // 60 seconds max backoff
|
|
33
|
+
const INITIAL_BACKOFF_MS = 1000; // 1 second initial backoff
|
|
34
|
+
const STABLE_THRESHOLD_MS = 30000; // 30s of stable running resets backoff
|
|
35
|
+
const CRASH_WINDOW_MS = 120000; // 2 minute window for crash loop detection
|
|
36
|
+
const MAX_CRASHES_IN_WINDOW = 5; // 5 crashes in window = stop
|
|
37
|
+
|
|
38
|
+
// State
|
|
39
|
+
let serverProcess = null;
|
|
40
|
+
let backoffMs = INITIAL_BACKOFF_MS;
|
|
41
|
+
let restartCount = 0;
|
|
42
|
+
let startedAt = Date.now();
|
|
43
|
+
let serverStartedAt = null;
|
|
44
|
+
let crashTimestamps = [];
|
|
45
|
+
let shuttingDown = false;
|
|
46
|
+
|
|
47
|
+
// Parse environment from argv (passed as JSON)
|
|
48
|
+
const envArg = process.argv[2];
|
|
49
|
+
let serverEnv = { ...process.env };
|
|
50
|
+
if (envArg) {
|
|
51
|
+
try {
|
|
52
|
+
const extraEnv = JSON.parse(envArg);
|
|
53
|
+
Object.assign(serverEnv, extraEnv);
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore parse errors
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Read config.json for server settings (networkAccess, etc.)
|
|
60
|
+
try {
|
|
61
|
+
const configPath = join(ROOT, 'config.json');
|
|
62
|
+
const configData = readFileSync(configPath, 'utf8');
|
|
63
|
+
const config = JSON.parse(configData);
|
|
64
|
+
|
|
65
|
+
// Apply networkAccess setting if not already set by env/CLI
|
|
66
|
+
if (!serverEnv.UPLINK_HOST && config.networkAccess === true) {
|
|
67
|
+
serverEnv.UPLINK_HOST = '0.0.0.0';
|
|
68
|
+
logToFile('Network access enabled via config.json — binding to 0.0.0.0');
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Config not found or invalid — use defaults
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Append a timestamped line to the watchdog log
|
|
76
|
+
*/
|
|
77
|
+
function logToFile(message) {
|
|
78
|
+
const timestamp = new Date().toISOString();
|
|
79
|
+
const line = `[${timestamp}] ${message}\n`;
|
|
80
|
+
try {
|
|
81
|
+
appendFileSync(LOG_FILE, line);
|
|
82
|
+
} catch {
|
|
83
|
+
// Can't log — ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write current watchdog state to JSON file
|
|
89
|
+
*/
|
|
90
|
+
function writeState() {
|
|
91
|
+
const state = {
|
|
92
|
+
status: shuttingDown ? 'stopping' : (serverProcess ? 'running' : 'stopped'),
|
|
93
|
+
watchdogPid: process.pid,
|
|
94
|
+
serverPid: serverProcess?.pid || null,
|
|
95
|
+
startedAt,
|
|
96
|
+
serverStartedAt,
|
|
97
|
+
restartCount,
|
|
98
|
+
backoffMs,
|
|
99
|
+
lastUpdated: Date.now(),
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
103
|
+
} catch {
|
|
104
|
+
// Ignore write errors
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Write watchdog PID file
|
|
110
|
+
*/
|
|
111
|
+
function writePidFile() {
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Clean up PID and state files
|
|
121
|
+
*/
|
|
122
|
+
function cleanup() {
|
|
123
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
124
|
+
// Update state to stopped
|
|
125
|
+
const state = {
|
|
126
|
+
status: 'stopped',
|
|
127
|
+
watchdogPid: null,
|
|
128
|
+
serverPid: null,
|
|
129
|
+
startedAt,
|
|
130
|
+
serverStartedAt: null,
|
|
131
|
+
restartCount,
|
|
132
|
+
backoffMs: INITIAL_BACKOFF_MS,
|
|
133
|
+
lastUpdated: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if we're in a crash loop
|
|
142
|
+
*/
|
|
143
|
+
function isInCrashLoop() {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
// Remove old timestamps outside the window
|
|
146
|
+
crashTimestamps = crashTimestamps.filter(t => now - t < CRASH_WINDOW_MS);
|
|
147
|
+
return crashTimestamps.length >= MAX_CRASHES_IN_WINDOW;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Kill the server process gracefully
|
|
152
|
+
*/
|
|
153
|
+
function killServer() {
|
|
154
|
+
if (!serverProcess) return;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (process.platform === 'win32') {
|
|
158
|
+
try {
|
|
159
|
+
execSync(`taskkill /PID ${serverProcess.pid} /T /F`, { stdio: 'ignore' });
|
|
160
|
+
} catch {
|
|
161
|
+
serverProcess.kill();
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
serverProcess.kill('SIGTERM');
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Process might already be dead
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Spawn the server process and monitor it
|
|
173
|
+
*/
|
|
174
|
+
function spawnServer() {
|
|
175
|
+
if (shuttingDown) return;
|
|
176
|
+
|
|
177
|
+
serverStartedAt = Date.now();
|
|
178
|
+
|
|
179
|
+
logToFile(`Starting server (attempt ${restartCount + 1}, backoff: ${backoffMs}ms)`);
|
|
180
|
+
|
|
181
|
+
const serverLogFd = openSync(join(ROOT, '.uplink-server.log'), 'a');
|
|
182
|
+
serverProcess = spawn(process.execPath, [SERVER_PATH], {
|
|
183
|
+
cwd: ROOT,
|
|
184
|
+
env: serverEnv,
|
|
185
|
+
stdio: ['ignore', serverLogFd, serverLogFd],
|
|
186
|
+
detached: false,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
writeFileSync(join(ROOT, '.uplink.pid'), String(serverProcess.pid));
|
|
191
|
+
} catch {}
|
|
192
|
+
|
|
193
|
+
writeState();
|
|
194
|
+
|
|
195
|
+
const stableTimer = setTimeout(() => {
|
|
196
|
+
// Server has been running stably for STABLE_THRESHOLD_MS — reset backoff
|
|
197
|
+
backoffMs = INITIAL_BACKOFF_MS;
|
|
198
|
+
logToFile('Server stable — backoff reset');
|
|
199
|
+
writeState();
|
|
200
|
+
}, STABLE_THRESHOLD_MS);
|
|
201
|
+
|
|
202
|
+
serverProcess.on('exit', (code, signal) => {
|
|
203
|
+
clearTimeout(stableTimer);
|
|
204
|
+
serverProcess = null;
|
|
205
|
+
|
|
206
|
+
if (shuttingDown) {
|
|
207
|
+
logToFile(`Server exited during shutdown (code: ${code}, signal: ${signal})`);
|
|
208
|
+
writeState();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Clean exit (code 0) → watchdog exits too
|
|
213
|
+
if (code === 0) {
|
|
214
|
+
logToFile(`Server exited cleanly (code: 0) — watchdog exiting`);
|
|
215
|
+
cleanup();
|
|
216
|
+
process.exit(0);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (signal === 'SIGTERM') {
|
|
220
|
+
logToFile(`Server killed by SIGTERM — treating as crash, will restart`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Crash — record it
|
|
224
|
+
restartCount++;
|
|
225
|
+
crashTimestamps.push(Date.now());
|
|
226
|
+
logToFile(`Server crashed (code: ${code}, signal: ${signal}) — restart #${restartCount}`);
|
|
227
|
+
|
|
228
|
+
// Check for crash loop
|
|
229
|
+
if (isInCrashLoop()) {
|
|
230
|
+
logToFile(`CRASH LOOP DETECTED: ${MAX_CRASHES_IN_WINDOW} crashes in ${CRASH_WINDOW_MS / 1000}s — stopping watchdog`);
|
|
231
|
+
cleanup();
|
|
232
|
+
process.exit(1);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Schedule restart with backoff
|
|
237
|
+
logToFile(`Restarting in ${backoffMs}ms...`);
|
|
238
|
+
writeState();
|
|
239
|
+
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
// Double backoff for next time (capped)
|
|
242
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
243
|
+
spawnServer();
|
|
244
|
+
}, backoffMs);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
serverProcess.on('error', (err) => {
|
|
248
|
+
clearTimeout(stableTimer);
|
|
249
|
+
logToFile(`Server process error: ${err.message}`);
|
|
250
|
+
serverProcess = null;
|
|
251
|
+
|
|
252
|
+
if (shuttingDown) return;
|
|
253
|
+
|
|
254
|
+
restartCount++;
|
|
255
|
+
crashTimestamps.push(Date.now());
|
|
256
|
+
|
|
257
|
+
if (isInCrashLoop()) {
|
|
258
|
+
logToFile(`CRASH LOOP DETECTED — stopping watchdog`);
|
|
259
|
+
cleanup();
|
|
260
|
+
process.exit(1);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
setTimeout(() => {
|
|
265
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
266
|
+
spawnServer();
|
|
267
|
+
}, backoffMs);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Graceful shutdown handler
|
|
273
|
+
*/
|
|
274
|
+
function gracefulShutdown(signal) {
|
|
275
|
+
if (shuttingDown) return;
|
|
276
|
+
shuttingDown = true;
|
|
277
|
+
|
|
278
|
+
logToFile(`Watchdog received ${signal} — shutting down`);
|
|
279
|
+
|
|
280
|
+
if (serverProcess) {
|
|
281
|
+
killServer();
|
|
282
|
+
|
|
283
|
+
// Give server 10 seconds to exit gracefully
|
|
284
|
+
const forceKillTimer = setTimeout(() => {
|
|
285
|
+
logToFile('Force killing server after timeout');
|
|
286
|
+
try {
|
|
287
|
+
serverProcess?.kill('SIGKILL');
|
|
288
|
+
} catch {}
|
|
289
|
+
cleanup();
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}, 10000);
|
|
292
|
+
forceKillTimer.unref();
|
|
293
|
+
|
|
294
|
+
// Wait for server to exit
|
|
295
|
+
serverProcess.on('exit', () => {
|
|
296
|
+
clearTimeout(forceKillTimer);
|
|
297
|
+
cleanup();
|
|
298
|
+
process.exit(0);
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
cleanup();
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Register signal handlers
|
|
307
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
308
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
309
|
+
|
|
310
|
+
// Windows: handle Ctrl+C
|
|
311
|
+
if (process.platform === 'win32') {
|
|
312
|
+
process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Write PID file and start
|
|
316
|
+
writePidFile();
|
|
317
|
+
logToFile(`Watchdog started (PID: ${process.pid})`);
|
|
318
|
+
spawnServer();
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Broadcast Module
|
|
3
|
+
* Broadcasting utilities for all clients, sync, and OpenClaw push
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WebSocket } from 'ws';
|
|
7
|
+
import { log } from '../utils.js';
|
|
8
|
+
import { WEBSOCKET } from '../config.js';
|
|
9
|
+
import { broadcastToProxyClients } from '../gateway-proxy.js';
|
|
10
|
+
import { wsClients, safeSend } from './connections.js';
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// Broadcast Circuit Breaker
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
const broadcastCircuitBreaker = {
|
|
17
|
+
failures: 0,
|
|
18
|
+
lastFailure: 0,
|
|
19
|
+
isOpen: false,
|
|
20
|
+
threshold: WEBSOCKET.broadcastCircuitBreaker.threshold,
|
|
21
|
+
resetTimeMs: WEBSOCKET.broadcastCircuitBreaker.resetTimeMs,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function checkCircuitBreaker() {
|
|
25
|
+
if (!broadcastCircuitBreaker.isOpen) return true;
|
|
26
|
+
|
|
27
|
+
// Check if reset time has passed
|
|
28
|
+
if (Date.now() - broadcastCircuitBreaker.lastFailure > broadcastCircuitBreaker.resetTimeMs) {
|
|
29
|
+
broadcastCircuitBreaker.isOpen = false;
|
|
30
|
+
broadcastCircuitBreaker.failures = 0;
|
|
31
|
+
log('info', '[WS] Broadcast circuit breaker reset');
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function recordBroadcastFailure() {
|
|
39
|
+
broadcastCircuitBreaker.failures++;
|
|
40
|
+
broadcastCircuitBreaker.lastFailure = Date.now();
|
|
41
|
+
|
|
42
|
+
if (broadcastCircuitBreaker.failures >= broadcastCircuitBreaker.threshold) {
|
|
43
|
+
broadcastCircuitBreaker.isOpen = true;
|
|
44
|
+
log('warn', `[WS] Broadcast circuit breaker OPEN after ${broadcastCircuitBreaker.failures} failures`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================
|
|
49
|
+
// Message ID Generation
|
|
50
|
+
// ============================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a unique message ID for deduplication
|
|
54
|
+
* Format: msg_<timestamp>_<random>
|
|
55
|
+
*/
|
|
56
|
+
export function generateMessageId() {
|
|
57
|
+
const timestamp = Date.now();
|
|
58
|
+
const random = Math.random().toString(36).substring(2, 10);
|
|
59
|
+
return `msg_${timestamp}_${random}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================
|
|
63
|
+
// Deduplication
|
|
64
|
+
// ============================================
|
|
65
|
+
|
|
66
|
+
// Deduplication cache for recently broadcast messages (prevents sync + push duplicates)
|
|
67
|
+
const recentBroadcasts = new Map(); // hash -> timestamp
|
|
68
|
+
const DEDUP_WINDOW_MS = WEBSOCKET.dedupWindowMs;
|
|
69
|
+
const MAX_RECENT_BROADCASTS = WEBSOCKET.maxRecentBroadcasts;
|
|
70
|
+
|
|
71
|
+
function getMessageHash(content, satelliteId) {
|
|
72
|
+
// DJB2 hash of full content string for reliable deduplication
|
|
73
|
+
const str = `${satelliteId}:${content || ''}`;
|
|
74
|
+
let hash = 5381;
|
|
75
|
+
for (let i = 0; i < str.length; i++) {
|
|
76
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
77
|
+
}
|
|
78
|
+
return hash.toString(36);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cleanupRecentBroadcasts() {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
for (const [hash, timestamp] of recentBroadcasts) {
|
|
84
|
+
if (now - timestamp > DEDUP_WINDOW_MS) {
|
|
85
|
+
recentBroadcasts.delete(hash);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Hard cap to prevent unbounded growth if cleanup can't keep up
|
|
89
|
+
if (recentBroadcasts.size > MAX_RECENT_BROADCASTS) {
|
|
90
|
+
const entries = Array.from(recentBroadcasts.entries());
|
|
91
|
+
entries.sort((a, b) => a[1] - b[1]);
|
|
92
|
+
const toRemove = recentBroadcasts.size - MAX_RECENT_BROADCASTS;
|
|
93
|
+
for (let i = 0; i < toRemove; i++) {
|
|
94
|
+
recentBroadcasts.delete(entries[i][0]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Track a broadcast for deduplication
|
|
100
|
+
export function trackBroadcast(content, satelliteId) {
|
|
101
|
+
cleanupRecentBroadcasts();
|
|
102
|
+
const hash = getMessageHash(content, satelliteId);
|
|
103
|
+
recentBroadcasts.set(hash, Date.now());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================
|
|
107
|
+
// Core Broadcasting
|
|
108
|
+
// ============================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Broadcast a message to all clients except one (optional exclusion)
|
|
112
|
+
*/
|
|
113
|
+
export function broadcast(message, excludeClientId = null) {
|
|
114
|
+
// Check circuit breaker
|
|
115
|
+
if (!checkCircuitBreaker()) {
|
|
116
|
+
log('debug', '[WS] Broadcast skipped - circuit breaker open');
|
|
117
|
+
return { sent: 0, failed: 0, skipped: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const data = typeof message === 'string' ? message : JSON.stringify(message);
|
|
121
|
+
|
|
122
|
+
// Snapshot clients to avoid race condition during iteration
|
|
123
|
+
const clientsSnapshot = Array.from(wsClients.entries());
|
|
124
|
+
|
|
125
|
+
let sent = 0;
|
|
126
|
+
let failed = 0;
|
|
127
|
+
|
|
128
|
+
for (const [clientId, client] of clientsSnapshot) {
|
|
129
|
+
if (clientId === excludeClientId) continue;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
133
|
+
if (safeSend(client.ws, data)) {
|
|
134
|
+
sent++;
|
|
135
|
+
} else {
|
|
136
|
+
failed++;
|
|
137
|
+
recordBroadcastFailure();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
failed++;
|
|
142
|
+
recordBroadcastFailure();
|
|
143
|
+
log('warn', `[WS] Broadcast failed for ${clientId}: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (failed > 0) {
|
|
148
|
+
log('debug', `[WS] Broadcast: ${sent} sent, ${failed} failed`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { sent, failed, skipped: false };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Broadcast a message to ALL connected clients (no exclusion)
|
|
156
|
+
*/
|
|
157
|
+
export function broadcastToAll(data) {
|
|
158
|
+
// Check circuit breaker
|
|
159
|
+
if (!checkCircuitBreaker()) {
|
|
160
|
+
log('debug', '[WS] BroadcastToAll skipped - circuit breaker open');
|
|
161
|
+
return { sent: 0, failed: 0, skipped: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
|
165
|
+
|
|
166
|
+
// Snapshot clients to avoid race condition during iteration
|
|
167
|
+
const clientsSnapshot = Array.from(wsClients.values());
|
|
168
|
+
|
|
169
|
+
let sent = 0;
|
|
170
|
+
let failed = 0;
|
|
171
|
+
|
|
172
|
+
for (const client of clientsSnapshot) {
|
|
173
|
+
try {
|
|
174
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
175
|
+
if (safeSend(client.ws, message)) {
|
|
176
|
+
sent++;
|
|
177
|
+
} else {
|
|
178
|
+
failed++;
|
|
179
|
+
recordBroadcastFailure();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
failed++;
|
|
184
|
+
recordBroadcastFailure();
|
|
185
|
+
log('warn', `[WS] BroadcastToAll failed: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (failed > 0) {
|
|
190
|
+
log('debug', `[WS] BroadcastToAll: ${sent} sent, ${failed} failed`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { sent, failed, skipped: false };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Send a message to a specific client by ID
|
|
198
|
+
*/
|
|
199
|
+
export function sendToClient(clientId, message) {
|
|
200
|
+
const client = wsClients.get(clientId);
|
|
201
|
+
if (client && client.ws.readyState === WebSocket.OPEN) {
|
|
202
|
+
return safeSend(client.ws, message);
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ============================================
|
|
208
|
+
// Sync Broadcasting (Cross-Device Sync)
|
|
209
|
+
// ============================================
|
|
210
|
+
|
|
211
|
+
// Rate limiting for sync broadcasts
|
|
212
|
+
const syncBroadcastRateLimit = {
|
|
213
|
+
count: 0,
|
|
214
|
+
windowStart: Date.now(),
|
|
215
|
+
maxPerSecond: WEBSOCKET.syncBroadcastRateLimit.maxPerSecond,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Broadcast a sync message to all clients except the sender
|
|
220
|
+
* Used for real-time cross-device message synchronization
|
|
221
|
+
*/
|
|
222
|
+
export function broadcastSyncMessage(role, content, satelliteId, messageId, excludeClientId = null, requestId = null) {
|
|
223
|
+
// Rate limiting: max N broadcasts per second
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
if (now - syncBroadcastRateLimit.windowStart > 1000) {
|
|
226
|
+
syncBroadcastRateLimit.count = 0;
|
|
227
|
+
syncBroadcastRateLimit.windowStart = now;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (syncBroadcastRateLimit.count >= syncBroadcastRateLimit.maxPerSecond) {
|
|
231
|
+
log('warn', '[WS] Sync broadcast rate limit exceeded');
|
|
232
|
+
return { sent: 0, failed: 0, rateLimited: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
syncBroadcastRateLimit.count++;
|
|
236
|
+
|
|
237
|
+
// Track assistant messages for deduplication (prevents OpenClaw push duplicates)
|
|
238
|
+
if (role === 'assistant' && content) {
|
|
239
|
+
trackBroadcast(content, satelliteId || 'main');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const syncMessage = {
|
|
243
|
+
type: 'event',
|
|
244
|
+
event: 'sync',
|
|
245
|
+
payload: {
|
|
246
|
+
messageId: messageId || generateMessageId(),
|
|
247
|
+
role,
|
|
248
|
+
content,
|
|
249
|
+
satelliteId: satelliteId || 'main',
|
|
250
|
+
timestamp: now,
|
|
251
|
+
...(requestId ? { requestId } : {}),
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Broadcast to direct WebSocket clients (/ws)
|
|
256
|
+
const directResult = broadcast(syncMessage, excludeClientId);
|
|
257
|
+
|
|
258
|
+
// Also broadcast to Gateway proxy clients (/ws/gateway) for mobile sync
|
|
259
|
+
const proxyResult = broadcastToProxyClients(syncMessage);
|
|
260
|
+
|
|
261
|
+
const totalSent = directResult.sent + proxyResult.sent;
|
|
262
|
+
const totalFailed = directResult.failed + proxyResult.failed;
|
|
263
|
+
|
|
264
|
+
log('debug', `[WS] Sync broadcast: ${role} message to ${totalSent} clients (direct: ${directResult.sent}, proxy: ${proxyResult.sent}, satellite: ${satelliteId})`);
|
|
265
|
+
|
|
266
|
+
return { sent: totalSent, failed: totalFailed, skipped: directResult.skipped };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Broadcast sync.thinking event to all clients except the sender
|
|
271
|
+
*/
|
|
272
|
+
export function broadcastSyncThinking(requestId, satelliteId, excludeClientId = null) {
|
|
273
|
+
const message = {
|
|
274
|
+
type: 'event',
|
|
275
|
+
event: 'sync.thinking',
|
|
276
|
+
payload: { requestId, satelliteId: satelliteId || 'main' },
|
|
277
|
+
};
|
|
278
|
+
broadcast(message, excludeClientId);
|
|
279
|
+
broadcastToProxyClients(message);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Broadcast sync.tool event to all clients except the sender
|
|
284
|
+
*/
|
|
285
|
+
export function broadcastSyncTool(requestId, tool, satelliteId, excludeClientId = null) {
|
|
286
|
+
const message = {
|
|
287
|
+
type: 'event',
|
|
288
|
+
event: 'sync.tool',
|
|
289
|
+
payload: { requestId, tool, satelliteId: satelliteId || 'main' },
|
|
290
|
+
};
|
|
291
|
+
broadcast(message, excludeClientId);
|
|
292
|
+
broadcastToProxyClients(message);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Broadcast sync.complete event with usage stats to all clients
|
|
297
|
+
* Sent when a response finishes so WS-streaming clients can update token display
|
|
298
|
+
*/
|
|
299
|
+
export function broadcastSyncComplete(requestId, usage, satelliteId, excludeClientId = null) {
|
|
300
|
+
if (!usage) return;
|
|
301
|
+
const message = {
|
|
302
|
+
type: 'event',
|
|
303
|
+
event: 'sync.complete',
|
|
304
|
+
payload: { requestId, usage, satelliteId: satelliteId || 'main' },
|
|
305
|
+
};
|
|
306
|
+
broadcast(message, excludeClientId);
|
|
307
|
+
broadcastToProxyClients(message);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ============================================
|
|
311
|
+
// OpenClaw Push Broadcasting
|
|
312
|
+
// ============================================
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Broadcast OpenClaw push messages to all WebSocket clients
|
|
316
|
+
* Maps payload.type to prefixed WebSocket message type
|
|
317
|
+
*/
|
|
318
|
+
export function broadcastOpenClawPush(payload) {
|
|
319
|
+
// Skip if this message was recently broadcast via sync (dedup)
|
|
320
|
+
if (payload.type === 'message' && payload.content) {
|
|
321
|
+
cleanupRecentBroadcasts();
|
|
322
|
+
const hash = getMessageHash(payload.content, payload.satelliteId);
|
|
323
|
+
if (recentBroadcasts.has(hash)) {
|
|
324
|
+
log('debug', `[WS] OpenClaw push: skipping duplicate for satellite ${payload.satelliteId}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const payloadData = {
|
|
330
|
+
satelliteId: payload.satelliteId,
|
|
331
|
+
content: payload.content,
|
|
332
|
+
tool: payload.tool,
|
|
333
|
+
error: payload.error,
|
|
334
|
+
audioUrl: payload.audioUrl,
|
|
335
|
+
usage: payload.usage,
|
|
336
|
+
requestId: payload.requestId,
|
|
337
|
+
timestamp: payload.timestamp
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Remove undefined fields from payload
|
|
341
|
+
Object.keys(payloadData).forEach(key => {
|
|
342
|
+
if (payloadData[key] === undefined) delete payloadData[key];
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const wsMessage = {
|
|
346
|
+
type: 'event',
|
|
347
|
+
event: `openclaw.${payload.type}`,
|
|
348
|
+
payload: payloadData,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Broadcast to direct /ws clients
|
|
352
|
+
const directResult = broadcastToAll(wsMessage);
|
|
353
|
+
|
|
354
|
+
// Also broadcast to Gateway proxy clients (/ws/gateway)
|
|
355
|
+
const proxyResult = broadcastToProxyClients(wsMessage);
|
|
356
|
+
|
|
357
|
+
const hasSent = directResult.sent > 0 || proxyResult.sent > 0;
|
|
358
|
+
log('info', `[WS] OpenClaw push: ${payload.type} for satellite ${payload.satelliteId} (direct: ${directResult.sent}, proxy: ${proxyResult.sent})`);
|
|
359
|
+
}
|