@gowelle/stint-agent 1.2.20 → 1.2.22

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.
@@ -0,0 +1,648 @@
1
+ import {
2
+ apiService
3
+ } from "./chunk-KMYPMLEH.js";
4
+ import {
5
+ gitService,
6
+ projectService
7
+ } from "./chunk-TQHWJWBZ.js";
8
+ import {
9
+ authService,
10
+ config,
11
+ logger
12
+ } from "./chunk-7H6J2KCA.js";
13
+
14
+ // src/utils/notify.ts
15
+ import notifier from "node-notifier";
16
+ import path from "path";
17
+ import { fileURLToPath } from "url";
18
+ var __filename = fileURLToPath(import.meta.url);
19
+ var __dirname = path.dirname(__filename);
20
+ var DEFAULT_ICON = path.resolve(__dirname, "../assets/logo.png");
21
+ function notify(options) {
22
+ if (!config.areNotificationsEnabled()) {
23
+ logger.debug("notify", "Notifications disabled, skipping notification");
24
+ return;
25
+ }
26
+ try {
27
+ notifier.notify({
28
+ title: options.title,
29
+ message: options.message,
30
+ open: options.open,
31
+ icon: options.icon || DEFAULT_ICON,
32
+ sound: true,
33
+ wait: false,
34
+ appID: "Stint Agent"
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ }, (error) => {
37
+ if (error) {
38
+ logger.error("notify", "Failed to send notification", error);
39
+ }
40
+ });
41
+ } catch (error) {
42
+ logger.error("notify", "Failed to send notification", error);
43
+ }
44
+ }
45
+
46
+ // src/services/hook.ts
47
+ import { exec } from "child_process";
48
+ var HookService = class {
49
+ /**
50
+ * Execute a list of hooks sequentially
51
+ * @param hooks List of hooks to execute
52
+ * @param cwd Current working directory (project root)
53
+ * @returns List of results
54
+ */
55
+ async executeHooks(hooks, cwd) {
56
+ const results = [];
57
+ const enabledHooks = hooks.filter((h) => h.enabled);
58
+ if (enabledHooks.length === 0) {
59
+ return results;
60
+ }
61
+ logger.info("hooks", `Running ${enabledHooks.length} pre-commit hooks...`);
62
+ for (const hook of enabledHooks) {
63
+ try {
64
+ const result = await this.executeSingleHook(hook, cwd);
65
+ results.push(result);
66
+ if (!result.success && hook.blockOnFailure) {
67
+ logger.warn("hooks", `Hook "${hook.name}" failed and is blocking. Stopping execution.`);
68
+ break;
69
+ }
70
+ } catch (error) {
71
+ const errorMessage = error instanceof Error ? error.message : String(error);
72
+ logger.error("hooks", `Unexpected error running hook "${hook.name}"`, error);
73
+ results.push({
74
+ hookId: hook.id,
75
+ hookName: hook.name,
76
+ success: false,
77
+ output: "",
78
+ error: `Internal error: ${errorMessage}`,
79
+ duration: 0
80
+ });
81
+ if (hook.blockOnFailure) {
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return results;
87
+ }
88
+ /**
89
+ * Execute a single hook with timeout
90
+ */
91
+ executeSingleHook(hook, cwd) {
92
+ return new Promise((resolve) => {
93
+ const startTime = Date.now();
94
+ logger.debug("hooks", `Executing hook: ${hook.name} (${hook.command})`);
95
+ const timeout = hook.timeout || 3e4;
96
+ exec(hook.command, {
97
+ cwd,
98
+ timeout,
99
+ maxBuffer: 10 * 1024 * 1024
100
+ // 10MB output buffer
101
+ }, (error, stdout, stderr) => {
102
+ const duration = Date.now() - startTime;
103
+ const combinedOutput = (stdout + "\n" + stderr).trim();
104
+ if (error) {
105
+ if (error.signal === "SIGTERM" && duration >= timeout) {
106
+ resolve({
107
+ hookId: hook.id,
108
+ hookName: hook.name,
109
+ success: false,
110
+ output: combinedOutput,
111
+ error: `Timed out after ${timeout}ms`,
112
+ duration
113
+ });
114
+ return;
115
+ }
116
+ resolve({
117
+ hookId: hook.id,
118
+ hookName: hook.name,
119
+ success: false,
120
+ output: combinedOutput,
121
+ error: error.message,
122
+ duration
123
+ });
124
+ } else {
125
+ resolve({
126
+ hookId: hook.id,
127
+ hookName: hook.name,
128
+ success: true,
129
+ output: combinedOutput,
130
+ duration
131
+ });
132
+ }
133
+ });
134
+ });
135
+ }
136
+ };
137
+ var hookService = new HookService();
138
+
139
+ // src/daemon/queue.ts
140
+ var CommitQueueProcessor = class {
141
+ queue = [];
142
+ isProcessing = false;
143
+ /**
144
+ * Add commit to processing queue
145
+ */
146
+ addToQueue(commit, project) {
147
+ this.queue.push({ commit, project });
148
+ logger.info("queue", `Added commit ${commit.id} to queue (position: ${this.queue.length})`);
149
+ if (!this.isProcessing) {
150
+ this.processQueue();
151
+ }
152
+ }
153
+ /**
154
+ * Process commits sequentially
155
+ */
156
+ async processQueue() {
157
+ if (this.isProcessing) {
158
+ return;
159
+ }
160
+ this.isProcessing = true;
161
+ while (this.queue.length > 0) {
162
+ const item = this.queue.shift();
163
+ if (!item) break;
164
+ try {
165
+ await this.executeCommit(item.commit, item.project);
166
+ } catch (error) {
167
+ logger.error("queue", `Failed to execute commit ${item.commit.id}`, error);
168
+ }
169
+ }
170
+ this.isProcessing = false;
171
+ }
172
+ /**
173
+ * Execute a single commit
174
+ */
175
+ async executeCommit(commit, project, onProgress, options) {
176
+ logger.info("queue", `Processing commit: ${commit.id} - ${commit.message}`);
177
+ try {
178
+ onProgress?.("Finding project directory...");
179
+ const projectPath = this.findProjectPath(project.id);
180
+ if (!projectPath) {
181
+ throw new Error(`Project ${project.id} is not linked to any local directory`);
182
+ }
183
+ logger.info("queue", `Executing in directory: ${projectPath}`);
184
+ onProgress?.("Validating repository...");
185
+ const isRepo = await gitService.isRepo(projectPath);
186
+ if (!isRepo) {
187
+ throw new Error(`Directory ${projectPath} is not a git repository`);
188
+ }
189
+ if (project.commitSettings?.hooks?.length) {
190
+ onProgress?.("Running pre-commit hooks...");
191
+ logger.info("queue", "Executing pre-commit hooks...");
192
+ const hookResults = await hookService.executeHooks(project.commitSettings.hooks, projectPath);
193
+ const failedBlockingHook = hookResults.find(
194
+ (r) => !r.success && project.commitSettings?.hooks.find((h) => h.id === r.hookId)?.blockOnFailure
195
+ );
196
+ if (failedBlockingHook) {
197
+ const errorMsg = failedBlockingHook.error || "Unknown error";
198
+ logger.error("queue", `Blocking hook "${failedBlockingHook.hookName}" failed: ${errorMsg}`);
199
+ if (failedBlockingHook.output) {
200
+ logger.warn("queue", `Hook output:
201
+ ${failedBlockingHook.output}`);
202
+ }
203
+ throw new Error(`Pre-commit hook "${failedBlockingHook.hookName}" failed: ${errorMsg}`);
204
+ }
205
+ const failedNonBlockingHooks = hookResults.filter((r) => !r.success);
206
+ if (failedNonBlockingHooks.length > 0) {
207
+ logger.warn("queue", `${failedNonBlockingHooks.length} hooks failed but were not blocking: ${failedNonBlockingHooks.map((h) => h.hookName).join(", ")}`);
208
+ }
209
+ }
210
+ onProgress?.("Checking repository status...");
211
+ let status = await gitService.getStatus(projectPath);
212
+ if (commit.files && commit.files.length > 0) {
213
+ onProgress?.(`Staging ${commit.files.length} specified files...`);
214
+ await gitService.stageFiles(projectPath, commit.files);
215
+ status = await gitService.getStatus(projectPath);
216
+ logger.info("queue", `Auto-staged files: ${commit.files.join(", ")}`);
217
+ } else if (status.staged.length === 0) {
218
+ const hasChanges = status.unstaged.length > 0 || status.untracked.length > 0;
219
+ if (hasChanges) {
220
+ onProgress?.("Staging all changes...");
221
+ await gitService.stageAll(projectPath);
222
+ status = await gitService.getStatus(projectPath);
223
+ logger.info("queue", `Auto-staged all changes`);
224
+ }
225
+ }
226
+ if (status.staged.length === 0) {
227
+ throw new Error("No changes to commit. The working directory is clean.");
228
+ }
229
+ logger.info("queue", `Committing ${status.staged.length} staged files.`);
230
+ onProgress?.("Creating commit...");
231
+ logger.info("queue", `Creating commit with message: "${commit.message}"`);
232
+ const sha = await gitService.commit(projectPath, commit.message);
233
+ logger.success("queue", `Commit created successfully: ${sha}`);
234
+ let pushed = false;
235
+ let pushError;
236
+ const shouldPush = options?.push !== false;
237
+ if (shouldPush) {
238
+ try {
239
+ onProgress?.("Pushing to remote...");
240
+ await gitService.push(projectPath);
241
+ pushed = true;
242
+ logger.success("queue", `Pushed commit ${sha} to remote`);
243
+ notify({
244
+ title: `Commit Pushed - ${project.name}`,
245
+ message: `Commit "${commit.message}" successfully pushed.`
246
+ });
247
+ } catch (error) {
248
+ pushError = error.message;
249
+ const isConflict = pushError.includes("rejected") || pushError.includes("non-fast-forward") || pushError.includes("failed to push") || pushError.includes("Updates were rejected");
250
+ if (isConflict) {
251
+ logger.warn("queue", `Push failed due to remote conflict: ${pushError}`);
252
+ notify({
253
+ title: `Push Conflict - ${project.name}`,
254
+ message: `Commit "${commit.message}" created but push failed.
255
+ Run "git pull --rebase" to resolve.`
256
+ });
257
+ } else {
258
+ logger.error("queue", `Push failed: ${pushError}`);
259
+ notify({
260
+ title: `Push Failed - ${project.name}`,
261
+ message: `Commit created but push failed: ${pushError}`
262
+ });
263
+ }
264
+ }
265
+ }
266
+ onProgress?.("Reporting to server...");
267
+ await this.reportSuccess(commit.id, sha, pushed, pushError);
268
+ if (!pushed && !pushError) {
269
+ notify({
270
+ title: `Commit Created - ${project.name}`,
271
+ message: `Commit "${commit.message}" created locally.`
272
+ });
273
+ }
274
+ return sha;
275
+ } catch (error) {
276
+ const msg = error.message;
277
+ logger.error("queue", `Commit execution failed: ${msg}`);
278
+ notify({
279
+ title: `Commit Failed - ${project.name}`,
280
+ message: `Failed to execute commit "${commit.message}": ${msg}`
281
+ });
282
+ await this.reportFailure(commit.id, msg);
283
+ throw error;
284
+ }
285
+ }
286
+ /**
287
+ * Report successful execution to API
288
+ * @param commitId - Commit ID
289
+ * @param sha - Git commit SHA
290
+ * @param pushed - Whether the commit was pushed to remote
291
+ * @param pushError - Error message if push failed
292
+ */
293
+ async reportSuccess(commitId, sha, pushed = true, pushError) {
294
+ try {
295
+ await apiService.markCommitExecuted(commitId, sha, pushed, pushError);
296
+ const status = pushed ? "executed" : "committed (push failed)";
297
+ logger.success("queue", `Reported commit ${status} to API: ${commitId} -> ${sha}`);
298
+ } catch (error) {
299
+ logger.error("queue", "Failed to report commit success to API", error);
300
+ }
301
+ }
302
+ /**
303
+ * Report failed execution to API
304
+ */
305
+ async reportFailure(commitId, error) {
306
+ try {
307
+ await apiService.markCommitFailed(commitId, error);
308
+ logger.info("queue", `Reported commit failure to API: ${commitId}`);
309
+ } catch (apiError) {
310
+ logger.error("queue", "Failed to report commit failure to API", apiError);
311
+ }
312
+ }
313
+ /**
314
+ * Find local path for a project ID
315
+ */
316
+ findProjectPath(projectId) {
317
+ const allProjects = projectService.getAllLinkedProjects();
318
+ for (const [path3, linkedProject] of Object.entries(allProjects)) {
319
+ if (linkedProject.projectId === projectId) {
320
+ return path3;
321
+ }
322
+ }
323
+ return null;
324
+ }
325
+ /**
326
+ * Check if queue is currently processing
327
+ */
328
+ isCurrentlyProcessing() {
329
+ return this.isProcessing;
330
+ }
331
+ /**
332
+ * Get queue length
333
+ */
334
+ getQueueLength() {
335
+ return this.queue.length;
336
+ }
337
+ };
338
+ var commitQueue = new CommitQueueProcessor();
339
+
340
+ // src/services/websocket.ts
341
+ import Echo from "laravel-echo";
342
+ import Pusher from "pusher-js";
343
+ import fs from "fs";
344
+ import os from "os";
345
+ import path2 from "path";
346
+ var STATUS_FILE_PATH = path2.join(os.homedir(), ".config", "stint", "daemon.status.json");
347
+ function writeStatus(update) {
348
+ try {
349
+ const dir = path2.dirname(STATUS_FILE_PATH);
350
+ if (!fs.existsSync(dir)) {
351
+ fs.mkdirSync(dir, { recursive: true });
352
+ }
353
+ let status = { websocket: { connected: false } };
354
+ if (fs.existsSync(STATUS_FILE_PATH)) {
355
+ try {
356
+ status = JSON.parse(fs.readFileSync(STATUS_FILE_PATH, "utf8"));
357
+ } catch {
358
+ }
359
+ }
360
+ status.websocket = { ...status.websocket, ...update };
361
+ fs.writeFileSync(STATUS_FILE_PATH, JSON.stringify(status, null, 2));
362
+ } catch {
363
+ }
364
+ }
365
+ var WebSocketServiceImpl = class {
366
+ echo = null;
367
+ userId = null;
368
+ reconnectAttempts = 0;
369
+ maxReconnectAttempts = 10;
370
+ reconnectTimer = null;
371
+ isManualDisconnect = false;
372
+ // Event handlers
373
+ commitApprovedHandlers = [];
374
+ commitPendingHandlers = [];
375
+ suggestionCreatedHandlers = [];
376
+ projectUpdatedHandlers = [];
377
+ disconnectHandlers = [];
378
+ agentDisconnectedHandlers = [];
379
+ syncRequestedHandlers = [];
380
+ /**
381
+ * Connect to the WebSocket server using Laravel Echo
382
+ * @throws Error if connection fails or no auth token available
383
+ */
384
+ async connect() {
385
+ try {
386
+ const token = await authService.getToken();
387
+ if (!token) {
388
+ throw new Error("No authentication token available");
389
+ }
390
+ const reverbAppKey = config.getReverbAppKey();
391
+ if (!reverbAppKey) {
392
+ throw new Error("Reverb app key not configured");
393
+ }
394
+ const apiUrl = config.getApiUrl();
395
+ const environment = config.getEnvironment();
396
+ let wsHost;
397
+ let wsPort;
398
+ let forceTLS;
399
+ if (environment === "development") {
400
+ wsHost = "localhost";
401
+ wsPort = 8080;
402
+ forceTLS = false;
403
+ } else {
404
+ wsHost = "stint.codes";
405
+ wsPort = 443;
406
+ forceTLS = true;
407
+ }
408
+ logger.info("websocket", `Connecting to ${wsHost}:${wsPort} with key ${reverbAppKey}...`);
409
+ const pusherClient = new Pusher(reverbAppKey, {
410
+ wsHost,
411
+ wsPort,
412
+ forceTLS,
413
+ enabledTransports: ["ws", "wss"],
414
+ disableStats: true,
415
+ cluster: "",
416
+ // Required but unused for Reverb
417
+ authorizer: (channel) => ({
418
+ authorize: async (socketId, callback) => {
419
+ try {
420
+ const response = await fetch(`${apiUrl}/api/broadcasting/auth`, {
421
+ method: "POST",
422
+ headers: {
423
+ "Authorization": `Bearer ${token}`,
424
+ "Accept": "application/json",
425
+ "Content-Type": "application/json"
426
+ },
427
+ body: JSON.stringify({
428
+ socket_id: socketId,
429
+ channel_name: channel.name
430
+ })
431
+ });
432
+ if (!response.ok) {
433
+ const errorText = await response.text();
434
+ logger.error("websocket", `Auth failed (${response.status}): ${errorText}`);
435
+ callback(new Error(`Auth failed: ${response.status}`));
436
+ return;
437
+ }
438
+ const data = await response.json();
439
+ callback(null, data);
440
+ } catch (error) {
441
+ logger.error("websocket", "Channel auth error", error);
442
+ callback(error);
443
+ }
444
+ }
445
+ })
446
+ });
447
+ this.echo = new Echo({
448
+ broadcaster: "reverb",
449
+ key: reverbAppKey,
450
+ wsHost,
451
+ wsPort,
452
+ forceTLS,
453
+ disableStats: true,
454
+ enabledTransports: ["ws", "wss"],
455
+ authEndpoint: `${apiUrl}/api/broadcasting/auth`,
456
+ auth: {
457
+ headers: {
458
+ Authorization: `Bearer ${token}`,
459
+ Accept: "application/json"
460
+ }
461
+ },
462
+ client: pusherClient
463
+ });
464
+ logger.info("websocket", "Echo instance created, setting up connection handlers...");
465
+ return new Promise((resolve, reject) => {
466
+ if (!this.echo) {
467
+ reject(new Error("Echo not initialized"));
468
+ return;
469
+ }
470
+ const connectionTimeout = setTimeout(() => {
471
+ const state = this.echo?.connector.pusher.connection.state || "unknown";
472
+ logger.error("websocket", `Connection timeout after 15s (state: ${state})`);
473
+ reject(new Error(`Connection timeout - stuck in state: ${state}`));
474
+ }, 15e3);
475
+ this.echo.connector.pusher.connection.bind("state_change", (states) => {
476
+ logger.info("websocket", `Connection state: ${states.previous} -> ${states.current}`);
477
+ });
478
+ this.echo.connector.pusher.connection.bind("connected", () => {
479
+ clearTimeout(connectionTimeout);
480
+ logger.success("websocket", "\u2705 Connected to Broadcaster via Sanctum");
481
+ writeStatus({ connected: true });
482
+ this.reconnectAttempts = 0;
483
+ this.isManualDisconnect = false;
484
+ resolve();
485
+ });
486
+ this.echo.connector.pusher.connection.bind("error", (error) => {
487
+ clearTimeout(connectionTimeout);
488
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error) || "Unknown connection error";
489
+ logger.error("websocket", `WebSocket error: ${errorMessage}`);
490
+ reject(new Error(errorMessage));
491
+ });
492
+ this.echo.connector.pusher.connection.bind("disconnected", () => {
493
+ logger.warn("websocket", "WebSocket disconnected");
494
+ writeStatus({ connected: false });
495
+ this.handleDisconnect();
496
+ });
497
+ this.echo.connector.pusher.connection.bind("failed", () => {
498
+ clearTimeout(connectionTimeout);
499
+ logger.error("websocket", "WebSocket connection failed");
500
+ reject(new Error("WebSocket connection failed"));
501
+ });
502
+ });
503
+ } catch (error) {
504
+ logger.error("websocket", "Failed to connect", error);
505
+ throw error;
506
+ }
507
+ }
508
+ /**
509
+ * Disconnect from the WebSocket server
510
+ * Prevents automatic reconnection
511
+ */
512
+ disconnect() {
513
+ this.isManualDisconnect = true;
514
+ if (this.reconnectTimer) {
515
+ clearTimeout(this.reconnectTimer);
516
+ this.reconnectTimer = null;
517
+ }
518
+ if (this.echo) {
519
+ if (this.userId) {
520
+ this.echo.leave(`user.${this.userId}`);
521
+ }
522
+ this.echo.disconnect();
523
+ this.echo = null;
524
+ logger.info("websocket", "WebSocket disconnected");
525
+ }
526
+ }
527
+ /**
528
+ * Check if WebSocket is currently connected
529
+ * @returns True if connected and ready
530
+ */
531
+ isConnected() {
532
+ return this.echo !== null && this.echo.connector.pusher.connection.state === "connected";
533
+ }
534
+ /**
535
+ * Subscribe to user-specific private channel for real-time updates
536
+ * @param userId - User ID to subscribe to
537
+ */
538
+ async subscribeToUserChannel(userId) {
539
+ this.userId = userId;
540
+ if (!this.echo) {
541
+ logger.warn("websocket", "Cannot subscribe: not connected");
542
+ return;
543
+ }
544
+ if (!this.isConnected()) {
545
+ logger.warn("websocket", "Cannot subscribe: not connected");
546
+ return;
547
+ }
548
+ const channel = `user.${userId}`;
549
+ logger.info("websocket", `Subscribing to private channel: ${channel}`);
550
+ const privateChannel = this.echo.private(channel);
551
+ writeStatus({ channel });
552
+ privateChannel.listen(".commit.approved", (data) => {
553
+ logger.info("websocket", `Commit approved: ${data.pendingCommit.id}`);
554
+ writeStatus({ lastEvent: "commit.approved", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
555
+ this.commitApprovedHandlers.forEach(
556
+ (handler) => handler(data.pendingCommit, data.pendingCommit.project)
557
+ );
558
+ }).listen(".commit.pending", (data) => {
559
+ logger.info("websocket", `Commit pending: ${data.pendingCommit.id}`);
560
+ writeStatus({ lastEvent: "commit.pending", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
561
+ this.commitPendingHandlers.forEach((handler) => handler(data.pendingCommit));
562
+ }).listen(".suggestion.created", (data) => {
563
+ logger.info("websocket", `Suggestion created: ${data.suggestion.id}`);
564
+ writeStatus({ lastEvent: "suggestion.created", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
565
+ this.suggestionCreatedHandlers.forEach((handler) => handler(data.suggestion));
566
+ }).listen(".project.updated", (data) => {
567
+ logger.info("websocket", `Project updated: ${data.project.id}`);
568
+ writeStatus({ lastEvent: "project.updated", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
569
+ this.projectUpdatedHandlers.forEach((handler) => handler(data.project));
570
+ }).listen(".sync.requested", (data) => {
571
+ logger.info("websocket", `Sync requested for project: ${data.project.id}`);
572
+ writeStatus({ lastEvent: "sync.requested", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
573
+ this.syncRequestedHandlers.forEach((handler) => handler(data.project.id));
574
+ }).listen(".agent.disconnected", (data) => {
575
+ const reason = data.reason ?? "Server requested disconnect";
576
+ logger.warn("websocket", `Agent disconnected by server: ${reason}`);
577
+ writeStatus({ lastEvent: "agent.disconnected", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
578
+ this.agentDisconnectedHandlers.forEach((handler) => handler(reason));
579
+ });
580
+ logger.success("websocket", `Subscribed to private channel: ${channel}`);
581
+ }
582
+ /**
583
+ * Register handler for commit approved events
584
+ * @param handler - Callback function
585
+ */
586
+ onCommitApproved(handler) {
587
+ this.commitApprovedHandlers.push(handler);
588
+ }
589
+ onCommitPending(handler) {
590
+ this.commitPendingHandlers.push(handler);
591
+ }
592
+ onSuggestionCreated(handler) {
593
+ this.suggestionCreatedHandlers.push(handler);
594
+ }
595
+ onProjectUpdated(handler) {
596
+ this.projectUpdatedHandlers.push(handler);
597
+ }
598
+ onDisconnect(handler) {
599
+ this.disconnectHandlers.push(handler);
600
+ }
601
+ onAgentDisconnected(handler) {
602
+ this.agentDisconnectedHandlers.push(handler);
603
+ }
604
+ onSyncRequested(handler) {
605
+ this.syncRequestedHandlers.push(handler);
606
+ }
607
+ handleDisconnect() {
608
+ this.disconnectHandlers.forEach((handler) => handler());
609
+ if (this.isManualDisconnect) {
610
+ return;
611
+ }
612
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
613
+ const delay = this.getReconnectDelay();
614
+ this.reconnectAttempts++;
615
+ logger.info("websocket", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
616
+ this.reconnectTimer = setTimeout(async () => {
617
+ try {
618
+ await this.connect();
619
+ if (this.userId) {
620
+ await this.subscribeToUserChannel(this.userId);
621
+ }
622
+ } catch (error) {
623
+ logger.error("websocket", "Reconnection failed", error);
624
+ }
625
+ }, delay);
626
+ } else {
627
+ logger.error("websocket", "Max reconnection attempts reached");
628
+ }
629
+ }
630
+ /**
631
+ * Get reconnect delay with exponential backoff and jitter
632
+ * Jitter prevents thundering herd problem when many clients reconnect simultaneously
633
+ */
634
+ getReconnectDelay() {
635
+ const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
636
+ const index = Math.min(this.reconnectAttempts, delays.length - 1);
637
+ const baseDelay = delays[index];
638
+ const jitter = baseDelay * (Math.random() * 0.3);
639
+ return Math.floor(baseDelay + jitter);
640
+ }
641
+ };
642
+ var websocketService = new WebSocketServiceImpl();
643
+
644
+ export {
645
+ notify,
646
+ commitQueue,
647
+ websocketService
648
+ };