lightman-agent 1.0.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/agent.config.template.json +30 -0
- package/bin/cms-agent.js +233 -0
- package/nssm/nssm.exe +0 -0
- package/package.json +52 -0
- package/public/assets/index-CcBNCz6h.css +1 -0
- package/public/assets/index-H-8HDl46.js +1 -0
- package/public/index.html +19 -0
- package/scripts/guardian.ps1 +75 -0
- package/scripts/install-linux.sh +134 -0
- package/scripts/install-rpi.sh +117 -0
- package/scripts/install-windows.ps1 +529 -0
- package/scripts/launch-kiosk.vbs +101 -0
- package/scripts/lightman-agent.logrotate +12 -0
- package/scripts/lightman-agent.service +38 -0
- package/scripts/lightman-shell.bat +128 -0
- package/scripts/reinstall-windows.ps1 +26 -0
- package/scripts/restore-desktop.ps1 +32 -0
- package/scripts/setup.ps1 +116 -0
- package/scripts/setup.sh +115 -0
- package/scripts/uninstall-linux.sh +50 -0
- package/scripts/uninstall-windows.ps1 +54 -0
- package/src/commands/display.ts +177 -0
- package/src/commands/kiosk.ts +113 -0
- package/src/commands/maintenance.ts +106 -0
- package/src/commands/network.ts +129 -0
- package/src/commands/power.ts +163 -0
- package/src/commands/rpi.ts +45 -0
- package/src/commands/screenshot.ts +166 -0
- package/src/commands/serial.ts +17 -0
- package/src/commands/update.ts +124 -0
- package/src/index.ts +652 -0
- package/src/lib/config.ts +69 -0
- package/src/lib/identity.ts +40 -0
- package/src/lib/logger.ts +137 -0
- package/src/lib/platform.ts +10 -0
- package/src/lib/rpi.ts +180 -0
- package/src/lib/screens.ts +128 -0
- package/src/lib/types.ts +176 -0
- package/src/services/commands.ts +107 -0
- package/src/services/health.ts +161 -0
- package/src/services/kiosk.ts +395 -0
- package/src/services/localEvents.ts +60 -0
- package/src/services/logForwarder.ts +72 -0
- package/src/services/multiScreenKiosk.ts +324 -0
- package/src/services/oscBridge.ts +186 -0
- package/src/services/powerScheduler.ts +260 -0
- package/src/services/provisioning.ts +120 -0
- package/src/services/serialBridge.ts +230 -0
- package/src/services/serviceLauncher.ts +183 -0
- package/src/services/staticServer.ts +226 -0
- package/src/services/updater.ts +249 -0
- package/src/services/watchdog.ts +310 -0
- package/src/services/websocket.ts +152 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { readdirSync, statSync, unlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import type { KioskManager } from './kiosk.js';
|
|
4
|
+
import type { WsClient } from './websocket.js';
|
|
5
|
+
import type { HealthMonitor } from './health.js';
|
|
6
|
+
import type { Logger } from '../lib/logger.js';
|
|
7
|
+
import type { WatchdogConfig, CrashReport } from '../lib/types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG: WatchdogConfig = {
|
|
10
|
+
checkIntervalMs: 60_000,
|
|
11
|
+
kioskCrashCooldownMs: 300_000, // 5 min
|
|
12
|
+
highMemoryThresholdMb: 500,
|
|
13
|
+
highMemoryCooldownMs: 3_600_000, // 1 hour
|
|
14
|
+
highDiskThresholdPercent: 90,
|
|
15
|
+
highDiskCooldownMs: 3_600_000, // 1 hour
|
|
16
|
+
wsDisconnectedThresholdMs: 600_000, // 10 min
|
|
17
|
+
wsDisconnectedCooldownMs: 900_000, // 15 min
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CHROME_CACHE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // Once per day
|
|
21
|
+
const CHROME_CACHE_DIR = process.platform === 'win32'
|
|
22
|
+
? 'C:\\ProgramData\\Lightman\\chrome-kiosk\\Default\\Cache'
|
|
23
|
+
: '/tmp/lightman-chrome-cache';
|
|
24
|
+
|
|
25
|
+
interface RecoveryStats {
|
|
26
|
+
kioskRestarts: number;
|
|
27
|
+
memoryRestarts: number;
|
|
28
|
+
diskCleanups: number;
|
|
29
|
+
wsRestarts: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Watchdog {
|
|
33
|
+
private kioskManager: KioskManager;
|
|
34
|
+
private wsClient: WsClient;
|
|
35
|
+
private healthMonitor: HealthMonitor;
|
|
36
|
+
private logger: Logger;
|
|
37
|
+
private config: WatchdogConfig;
|
|
38
|
+
private shellMode: boolean;
|
|
39
|
+
private timer: NodeJS.Timeout | null = null;
|
|
40
|
+
private cooldowns: Map<string, number> = new Map();
|
|
41
|
+
private stats: RecoveryStats = {
|
|
42
|
+
kioskRestarts: 0,
|
|
43
|
+
memoryRestarts: 0,
|
|
44
|
+
diskCleanups: 0,
|
|
45
|
+
wsRestarts: 0,
|
|
46
|
+
};
|
|
47
|
+
private wsDisconnectedSince: number | null = null;
|
|
48
|
+
private shuttingDown = false;
|
|
49
|
+
private serverUrl: string;
|
|
50
|
+
private identity: { deviceId: string; apiKey: string };
|
|
51
|
+
private lastChromeCacheCleanup = 0;
|
|
52
|
+
private _multiScreenActive = false;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
kioskManager: KioskManager,
|
|
56
|
+
wsClient: WsClient,
|
|
57
|
+
healthMonitor: HealthMonitor,
|
|
58
|
+
logger: Logger,
|
|
59
|
+
serverUrl: string,
|
|
60
|
+
identity: { deviceId: string; apiKey: string },
|
|
61
|
+
config?: Partial<WatchdogConfig>,
|
|
62
|
+
shellMode?: boolean
|
|
63
|
+
) {
|
|
64
|
+
this.kioskManager = kioskManager;
|
|
65
|
+
this.wsClient = wsClient;
|
|
66
|
+
this.healthMonitor = healthMonitor;
|
|
67
|
+
this.logger = logger;
|
|
68
|
+
this.serverUrl = serverUrl;
|
|
69
|
+
this.identity = identity;
|
|
70
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
71
|
+
this.shellMode = shellMode ?? false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** When multi-screen kiosk is active, watchdog should NOT restart the single kiosk */
|
|
75
|
+
setMultiScreenActive(active: boolean): void {
|
|
76
|
+
this._multiScreenActive = active;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
start(): void {
|
|
80
|
+
this.timer = setInterval(() => {
|
|
81
|
+
this.check().catch((err) => {
|
|
82
|
+
this.logger.error('Watchdog check failed:', err instanceof Error ? err.message : String(err));
|
|
83
|
+
});
|
|
84
|
+
}, this.config.checkIntervalMs);
|
|
85
|
+
this.logger.info(`Watchdog started (interval: ${this.config.checkIntervalMs}ms)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
stop(): void {
|
|
89
|
+
if (this.timer) {
|
|
90
|
+
clearInterval(this.timer);
|
|
91
|
+
this.timer = null;
|
|
92
|
+
}
|
|
93
|
+
this.logger.info('Watchdog stopped');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getStats(): RecoveryStats {
|
|
97
|
+
return { ...this.stats };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getCooldowns(): Record<string, number> {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const result: Record<string, number> = {};
|
|
103
|
+
for (const [key, expiry] of this.cooldowns) {
|
|
104
|
+
const remaining = expiry - now;
|
|
105
|
+
if (remaining > 0) {
|
|
106
|
+
result[key] = remaining;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async runDiskCleanup(): Promise<{ freedBytes: number; deletedFiles: number }> {
|
|
113
|
+
let freedBytes = 0;
|
|
114
|
+
let deletedFiles = 0;
|
|
115
|
+
|
|
116
|
+
const cleanDirs = ['/tmp'];
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const maxAgeMs = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
119
|
+
|
|
120
|
+
for (const dir of cleanDirs) {
|
|
121
|
+
try {
|
|
122
|
+
const files = readdirSync(dir);
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
if (!file.startsWith('lightman-')) continue;
|
|
125
|
+
try {
|
|
126
|
+
const filePath = join(dir, file);
|
|
127
|
+
const stat = statSync(filePath);
|
|
128
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
129
|
+
freedBytes += stat.size;
|
|
130
|
+
deletedFiles += 1;
|
|
131
|
+
unlinkSync(filePath);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Skip files we can't access
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Skip dirs we can't read
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.logger.info(`Disk cleanup: freed ${freedBytes} bytes, deleted ${deletedFiles} files`);
|
|
143
|
+
return { freedBytes, deletedFiles };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async sendCrashReport(processName: string, exitCode: number | null, signal: string | null): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const health = await this.healthMonitor.collect();
|
|
149
|
+
const report: CrashReport = {
|
|
150
|
+
process: processName,
|
|
151
|
+
exitCode,
|
|
152
|
+
signal,
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
system: {
|
|
155
|
+
memPercent: health.memPercent,
|
|
156
|
+
diskPercent: health.diskPercent,
|
|
157
|
+
cpuUsage: health.cpuUsage,
|
|
158
|
+
uptime: health.uptime,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const url = `${this.serverUrl}/api/devices/${this.identity.deviceId}/crash-report`;
|
|
163
|
+
await fetch(url, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
'Authorization': `Bearer ${this.identity.apiKey}`,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify(report),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
this.logger.info(`Crash report sent for ${processName}`);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.logger.error('Failed to send crash report:', err instanceof Error ? err.message : String(err));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Private ---
|
|
179
|
+
|
|
180
|
+
private async check(): Promise<void> {
|
|
181
|
+
const health = await this.healthMonitor.collect();
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
|
|
184
|
+
// Track WS disconnection duration
|
|
185
|
+
if (!this.wsClient.isConnected()) {
|
|
186
|
+
if (this.wsDisconnectedSince === null) {
|
|
187
|
+
this.wsDisconnectedSince = now;
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
this.wsDisconnectedSince = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rule: Kiosk crash recovery
|
|
194
|
+
// Skip if multi-screen kiosk is active (it manages its own Chrome instances)
|
|
195
|
+
// In shell mode, the shell BAT handles Chrome restarts — we only monitor and report
|
|
196
|
+
if (this._multiScreenActive) {
|
|
197
|
+
// Multi-screen mode — watchdog does not touch Chrome
|
|
198
|
+
} else {
|
|
199
|
+
const kioskStatus = this.kioskManager.getStatus();
|
|
200
|
+
if (!kioskStatus.running && !kioskStatus.crashLoopDetected) {
|
|
201
|
+
if (this.shellMode) {
|
|
202
|
+
// Shell mode: just report, don't try to launch (shell handles it)
|
|
203
|
+
if (this.canAct('kiosk_crash', this.config.kioskCrashCooldownMs)) {
|
|
204
|
+
this.logger.warn('Watchdog: Chrome not running (shell mode — shell should relaunch)');
|
|
205
|
+
this.setCooldown('kiosk_crash', this.config.kioskCrashCooldownMs);
|
|
206
|
+
this.sendCrashReport('kiosk', null, null).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
} else if (this.canAct('kiosk_crash', this.config.kioskCrashCooldownMs)) {
|
|
209
|
+
this.logger.warn('Watchdog: kiosk not running, attempting restart');
|
|
210
|
+
this.stats = { ...this.stats, kioskRestarts: this.stats.kioskRestarts + 1 };
|
|
211
|
+
this.setCooldown('kiosk_crash', this.config.kioskCrashCooldownMs);
|
|
212
|
+
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
this.kioskManager.launch().catch((err) => {
|
|
215
|
+
this.logger.error('Watchdog kiosk restart failed:', err instanceof Error ? err.message : String(err));
|
|
216
|
+
});
|
|
217
|
+
}, 3000);
|
|
218
|
+
|
|
219
|
+
this.sendCrashReport('kiosk', null, null).catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} // end else (not multi-screen)
|
|
223
|
+
|
|
224
|
+
// Rule: High memory — restart agent (systemd will restart)
|
|
225
|
+
const heapMb = process.memoryUsage().heapUsed / (1024 * 1024);
|
|
226
|
+
if (!this.shuttingDown && health.memPercent > 80 && heapMb > this.config.highMemoryThresholdMb) {
|
|
227
|
+
if (this.canAct('high_memory', this.config.highMemoryCooldownMs)) {
|
|
228
|
+
this.logger.warn(`Watchdog: high memory (heap: ${Math.round(heapMb)}MB, system: ${health.memPercent}%), restarting agent`);
|
|
229
|
+
this.stats = { ...this.stats, memoryRestarts: this.stats.memoryRestarts + 1 };
|
|
230
|
+
this.setCooldown('high_memory', this.config.highMemoryCooldownMs);
|
|
231
|
+
this.shuttingDown = true;
|
|
232
|
+
setTimeout(() => process.exit(0), 1000);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Rule: High disk usage — cleanup
|
|
238
|
+
if (health.diskPercent > this.config.highDiskThresholdPercent) {
|
|
239
|
+
if (this.canAct('high_disk', this.config.highDiskCooldownMs)) {
|
|
240
|
+
this.logger.warn(`Watchdog: high disk usage (${health.diskPercent}%), running cleanup`);
|
|
241
|
+
this.stats = { ...this.stats, diskCleanups: this.stats.diskCleanups + 1 };
|
|
242
|
+
this.setCooldown('high_disk', this.config.highDiskCooldownMs);
|
|
243
|
+
await this.runDiskCleanup();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Rule: WS disconnected too long — restart agent
|
|
248
|
+
if (
|
|
249
|
+
!this.shuttingDown &&
|
|
250
|
+
this.wsDisconnectedSince !== null &&
|
|
251
|
+
now - this.wsDisconnectedSince > this.config.wsDisconnectedThresholdMs
|
|
252
|
+
) {
|
|
253
|
+
if (this.canAct('ws_disconnected', this.config.wsDisconnectedCooldownMs)) {
|
|
254
|
+
this.logger.warn(`Watchdog: WS disconnected for ${Math.round((now - this.wsDisconnectedSince) / 1000)}s, restarting agent`);
|
|
255
|
+
this.stats = { ...this.stats, wsRestarts: this.stats.wsRestarts + 1 };
|
|
256
|
+
this.setCooldown('ws_disconnected', this.config.wsDisconnectedCooldownMs);
|
|
257
|
+
this.shuttingDown = true;
|
|
258
|
+
setTimeout(() => process.exit(0), 1000);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Rule: Daily Chrome cache cleanup (prevents disk fill over months)
|
|
264
|
+
if (now - this.lastChromeCacheCleanup > CHROME_CACHE_CLEANUP_INTERVAL_MS) {
|
|
265
|
+
this.lastChromeCacheCleanup = now;
|
|
266
|
+
this.cleanChromeCacheDir().catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async cleanChromeCacheDir(): Promise<void> {
|
|
271
|
+
try {
|
|
272
|
+
const cacheDir = CHROME_CACHE_DIR;
|
|
273
|
+
const files = readdirSync(cacheDir);
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const maxAgeMs = 3 * 24 * 60 * 60 * 1000; // 3 days
|
|
276
|
+
let cleaned = 0;
|
|
277
|
+
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
try {
|
|
280
|
+
const filePath = join(cacheDir, file);
|
|
281
|
+
const stat = statSync(filePath);
|
|
282
|
+
if (stat.isFile() && now - stat.mtimeMs > maxAgeMs) {
|
|
283
|
+
unlinkSync(filePath);
|
|
284
|
+
cleaned++;
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// Skip files we can't access
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (cleaned > 0) {
|
|
292
|
+
this.logger.info(`Chrome cache cleanup: removed ${cleaned} stale files`);
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// Cache dir may not exist yet, that's fine
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private canAct(action: string, _cooldownMs: number): boolean {
|
|
300
|
+
const expiry = this.cooldowns.get(action);
|
|
301
|
+
if (expiry && Date.now() < expiry) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private setCooldown(action: string, cooldownMs: number): void {
|
|
308
|
+
this.cooldowns = new Map([...this.cooldowns, [action, Date.now() + cooldownMs]]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import type { WsMessage, Identity } from '../lib/types.js';
|
|
3
|
+
import type { Logger } from '../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
interface WsClientOptions {
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
identity: Identity;
|
|
8
|
+
logger: Logger;
|
|
9
|
+
onMessage: (msg: WsMessage) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class WsClient {
|
|
13
|
+
private ws: WebSocket | null = null;
|
|
14
|
+
private serverUrl: string;
|
|
15
|
+
private identity: Identity;
|
|
16
|
+
private logger: Logger;
|
|
17
|
+
private onMessage: (msg: WsMessage) => void;
|
|
18
|
+
|
|
19
|
+
private reconnectAttempts = 0;
|
|
20
|
+
private maxReconnectDelay = 60_000;
|
|
21
|
+
private baseDelay = 1_000;
|
|
22
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
23
|
+
private closed = false;
|
|
24
|
+
private messageQueue: WsMessage[] = [];
|
|
25
|
+
|
|
26
|
+
constructor(options: WsClientOptions) {
|
|
27
|
+
this.serverUrl = options.serverUrl;
|
|
28
|
+
this.identity = options.identity;
|
|
29
|
+
this.logger = options.logger;
|
|
30
|
+
this.onMessage = options.onMessage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connect(): void {
|
|
34
|
+
if (this.closed) return;
|
|
35
|
+
|
|
36
|
+
const wsUrl = this.serverUrl.replace(/^http/, 'ws') +
|
|
37
|
+
'/ws/agent?apiKey=' + encodeURIComponent(this.identity.apiKey);
|
|
38
|
+
|
|
39
|
+
this.logger.debug('Connecting to', wsUrl.replace(/apiKey=.*/, 'apiKey=***'));
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
this.ws = new WebSocket(wsUrl);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
this.logger.error('Failed to create WebSocket:', err);
|
|
45
|
+
this.scheduleReconnect();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.ws.on('open', () => {
|
|
50
|
+
this.logger.info('WebSocket connected');
|
|
51
|
+
this.reconnectAttempts = 0;
|
|
52
|
+
this.flushQueue();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.ws.on('message', (data) => {
|
|
56
|
+
try {
|
|
57
|
+
const msg: WsMessage = JSON.parse(data.toString());
|
|
58
|
+
this.onMessage(msg);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
this.logger.error('Failed to parse WS message:', err);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.ws.on('close', (code, reason) => {
|
|
65
|
+
this.logger.warn(`WebSocket closed: ${code} ${reason.toString()}`);
|
|
66
|
+
this.ws = null;
|
|
67
|
+
if (!this.closed) {
|
|
68
|
+
this.scheduleReconnect();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.ws.on('error', (err) => {
|
|
73
|
+
this.logger.error('WebSocket error:', err);
|
|
74
|
+
// 'close' event will fire after this, triggering reconnect
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.ws.on('ping', () => {
|
|
78
|
+
// ws library auto-responds with pong
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
send(msg: WsMessage): void {
|
|
83
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
84
|
+
this.ws.send(JSON.stringify(msg));
|
|
85
|
+
} else {
|
|
86
|
+
// Queue message for when connection is restored
|
|
87
|
+
this.messageQueue.push(msg);
|
|
88
|
+
// Keep queue bounded
|
|
89
|
+
if (this.messageQueue.length > 100) {
|
|
90
|
+
this.messageQueue.shift();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
close(): void {
|
|
96
|
+
this.closed = true;
|
|
97
|
+
if (this.reconnectTimer) {
|
|
98
|
+
clearTimeout(this.reconnectTimer);
|
|
99
|
+
this.reconnectTimer = null;
|
|
100
|
+
}
|
|
101
|
+
if (this.ws) {
|
|
102
|
+
this.ws.close(1000, 'Agent shutting down');
|
|
103
|
+
this.ws = null;
|
|
104
|
+
}
|
|
105
|
+
this.messageQueue = [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isConnected(): boolean {
|
|
109
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Calculate delay with exponential backoff + jitter */
|
|
113
|
+
getReconnectDelay(): number {
|
|
114
|
+
const exponential = this.baseDelay * Math.pow(2, this.reconnectAttempts);
|
|
115
|
+
const capped = Math.min(exponential, this.maxReconnectDelay);
|
|
116
|
+
// Add jitter: 0.5x to 1.5x
|
|
117
|
+
const jitter = capped * (0.5 + Math.random());
|
|
118
|
+
return Math.round(jitter);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private scheduleReconnect(): void {
|
|
122
|
+
if (this.closed) return;
|
|
123
|
+
|
|
124
|
+
const delay = this.getReconnectDelay();
|
|
125
|
+
this.reconnectAttempts++;
|
|
126
|
+
|
|
127
|
+
this.logger.info(
|
|
128
|
+
`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
this.reconnectTimer = setTimeout(() => {
|
|
132
|
+
this.reconnectTimer = null;
|
|
133
|
+
this.connect();
|
|
134
|
+
}, delay);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private flushQueue(): void {
|
|
138
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
139
|
+
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const MAX_AGE = 300_000; // 5 minutes
|
|
142
|
+
|
|
143
|
+
while (this.messageQueue.length > 0) {
|
|
144
|
+
const msg = this.messageQueue.shift()!;
|
|
145
|
+
if (now - msg.timestamp > MAX_AGE) {
|
|
146
|
+
this.logger.warn(`Discarding stale queued message: ${msg.type}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
this.ws.send(JSON.stringify(msg));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"noUnusedLocals": false,
|
|
17
|
+
"noUnusedParameters": false,
|
|
18
|
+
"noFallthroughCasesInSwitch": true,
|
|
19
|
+
"isolatedModules": true,
|
|
20
|
+
"paths": {
|
|
21
|
+
"@/*": ["./src/*"]
|
|
22
|
+
},
|
|
23
|
+
"baseUrl": ".",
|
|
24
|
+
"types": ["node"]
|
|
25
|
+
},
|
|
26
|
+
"include": ["src/**/*"],
|
|
27
|
+
"exclude": ["node_modules", "dist", "src/__tests__"]
|
|
28
|
+
}
|