@vincentwei1021/synapse-openclaw-plugin 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.
- package/README.md +95 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +37 -0
- package/src/commands.ts +151 -0
- package/src/config.ts +56 -0
- package/src/event-router.test.ts +265 -0
- package/src/event-router.ts +360 -0
- package/src/index.ts +152 -0
- package/src/mcp-client.test.ts +130 -0
- package/src/mcp-client.ts +144 -0
- package/src/sse-listener.test.ts +150 -0
- package/src/sse-listener.ts +184 -0
- package/src/tools/common-tool-definitions.ts +681 -0
- package/src/tools/common-tools.ts +8 -0
- package/src/tools/tool-registry.ts +68 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
|
|
4
|
+
export type McpClientStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
|
|
5
|
+
|
|
6
|
+
export interface SynapseMcpClientOptions {
|
|
7
|
+
synapseUrl: string;
|
|
8
|
+
apiKey: string;
|
|
9
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wraps @modelcontextprotocol/sdk Client with:
|
|
14
|
+
* - Lazy connection (connect on first callTool)
|
|
15
|
+
* - Auto-reconnect on session expiry (404)
|
|
16
|
+
* - Status tracking
|
|
17
|
+
* - Graceful disconnect
|
|
18
|
+
*/
|
|
19
|
+
export class SynapseMcpClient {
|
|
20
|
+
private client: Client | null = null;
|
|
21
|
+
private transport: StreamableHTTPClientTransport | null = null;
|
|
22
|
+
private _status: McpClientStatus = "disconnected";
|
|
23
|
+
private readonly opts: SynapseMcpClientOptions;
|
|
24
|
+
|
|
25
|
+
constructor(opts: SynapseMcpClientOptions) {
|
|
26
|
+
this.opts = opts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get status(): McpClientStatus {
|
|
30
|
+
return this._status;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Establish MCP connection. Called lazily on first callTool. */
|
|
34
|
+
async connect(): Promise<void> {
|
|
35
|
+
if (this._status === "connected" && this.client) return;
|
|
36
|
+
|
|
37
|
+
this._status = "connecting";
|
|
38
|
+
try {
|
|
39
|
+
this.transport = new StreamableHTTPClientTransport(
|
|
40
|
+
new URL("/api/mcp", this.opts.synapseUrl),
|
|
41
|
+
{
|
|
42
|
+
requestInit: {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
this.client = new Client({
|
|
51
|
+
name: "openclaw-synapse",
|
|
52
|
+
version: "0.1.0",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await this.client.connect(this.transport);
|
|
56
|
+
this._status = "connected";
|
|
57
|
+
this.opts.logger.info("MCP connection established");
|
|
58
|
+
} catch (err) {
|
|
59
|
+
this._status = "disconnected";
|
|
60
|
+
await this.cleanupConnection();
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Call a Synapse MCP tool. Handles lazy connection and auto-reconnect.
|
|
67
|
+
* Returns parsed JSON from the first text content block.
|
|
68
|
+
*/
|
|
69
|
+
async callTool(name: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
70
|
+
// Lazy connect
|
|
71
|
+
if (!this.client || this._status !== "connected") {
|
|
72
|
+
await this.connect();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return await this._doCallTool(name, args);
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
// Session expired (404) or connection lost — reconnect and retry once
|
|
79
|
+
if (this.isSessionExpiredError(err)) {
|
|
80
|
+
this.opts.logger.warn("MCP session expired, reconnecting...");
|
|
81
|
+
this._status = "reconnecting";
|
|
82
|
+
await this.cleanupConnection();
|
|
83
|
+
await this.connect();
|
|
84
|
+
return await this._doCallTool(name, args);
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Graceful disconnect. */
|
|
91
|
+
async disconnect(): Promise<void> {
|
|
92
|
+
await this.cleanupConnection();
|
|
93
|
+
this._status = "disconnected";
|
|
94
|
+
this.opts.logger.info("MCP connection closed");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async cleanupConnection(): Promise<void> {
|
|
98
|
+
if (this.client) {
|
|
99
|
+
try {
|
|
100
|
+
await this.client.close();
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore close errors during cleanup
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.client = null;
|
|
107
|
+
this.transport = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async _doCallTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
111
|
+
if (!this.client) throw new Error("MCP client not connected");
|
|
112
|
+
|
|
113
|
+
const result = await this.client.callTool({ name, arguments: args });
|
|
114
|
+
|
|
115
|
+
if (result.isError) {
|
|
116
|
+
const errorText = (result.content as Array<{ type: string; text?: string }>)
|
|
117
|
+
.filter((c) => c.type === "text")
|
|
118
|
+
.map((c) => c.text)
|
|
119
|
+
.join("\n");
|
|
120
|
+
throw new Error(`Synapse MCP tool error (${name}): ${errorText}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Parse first text content block as JSON
|
|
124
|
+
const textContent = (result.content as Array<{ type: string; text?: string }>).find(
|
|
125
|
+
(c) => c.type === "text"
|
|
126
|
+
);
|
|
127
|
+
if (!textContent?.text) return null;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(textContent.text);
|
|
131
|
+
} catch {
|
|
132
|
+
// Return raw text if not valid JSON
|
|
133
|
+
return textContent.text;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private isSessionExpiredError(err: unknown): boolean {
|
|
138
|
+
if (err instanceof Error) {
|
|
139
|
+
const msg = err.message.toLowerCase();
|
|
140
|
+
return msg.includes("404") || msg.includes("session expired") || msg.includes("session not found");
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { SynapseSseListener } from "./sse-listener.js";
|
|
3
|
+
|
|
4
|
+
function createLogger() {
|
|
5
|
+
return {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createSseResponse(chunks: string[]) {
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const body = new ReadableStream<Uint8Array>({
|
|
15
|
+
start(controller) {
|
|
16
|
+
for (const chunk of chunks) {
|
|
17
|
+
controller.enqueue(encoder.encode(chunk));
|
|
18
|
+
}
|
|
19
|
+
controller.close();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return new Response(body, {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { "content-type": "text/event-stream" },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function flushMicrotasks() {
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
await Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("SynapseSseListener", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
vi.useFakeTimers();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
vi.useRealTimers();
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses SSE data events and forwards them to onEvent", async () => {
|
|
46
|
+
const logger = createLogger();
|
|
47
|
+
const onEvent = vi.fn();
|
|
48
|
+
const onReconnect = vi.fn().mockResolvedValue(undefined);
|
|
49
|
+
|
|
50
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
51
|
+
createSseResponse([
|
|
52
|
+
": heartbeat\n\n",
|
|
53
|
+
"data: {\"type\":\"new_notification\",\"notificationUuid\":\"notification-1\"}\n\n",
|
|
54
|
+
]),
|
|
55
|
+
);
|
|
56
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
57
|
+
|
|
58
|
+
const listener = new SynapseSseListener({
|
|
59
|
+
synapseUrl: "https://synapse.example.com/",
|
|
60
|
+
apiKey: "syn_test_key",
|
|
61
|
+
onEvent,
|
|
62
|
+
onReconnect,
|
|
63
|
+
logger,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await listener.connect();
|
|
67
|
+
await flushMicrotasks();
|
|
68
|
+
|
|
69
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
70
|
+
"https://synapse.example.com/api/events/notifications",
|
|
71
|
+
expect.objectContaining({
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: "Bearer syn_test_key",
|
|
74
|
+
Accept: "text/event-stream",
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
79
|
+
type: "new_notification",
|
|
80
|
+
notificationUuid: "notification-1",
|
|
81
|
+
});
|
|
82
|
+
expect(listener.status).toBe("reconnecting");
|
|
83
|
+
expect(logger.info).toHaveBeenCalledWith("[Synapse] SSE connection established");
|
|
84
|
+
|
|
85
|
+
listener.disconnect();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reconnects after an initial fetch failure and runs onReconnect once connected", async () => {
|
|
89
|
+
const logger = createLogger();
|
|
90
|
+
const onEvent = vi.fn();
|
|
91
|
+
const onReconnect = vi.fn().mockResolvedValue(undefined);
|
|
92
|
+
|
|
93
|
+
const fetchMock = vi.fn()
|
|
94
|
+
.mockRejectedValueOnce(new Error("network down"))
|
|
95
|
+
.mockResolvedValueOnce(createSseResponse(["data: {\"type\":\"count_update\",\"unreadCount\":2}\n\n"]));
|
|
96
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
97
|
+
|
|
98
|
+
const listener = new SynapseSseListener({
|
|
99
|
+
synapseUrl: "https://synapse.example.com",
|
|
100
|
+
apiKey: "syn_test_key",
|
|
101
|
+
onEvent,
|
|
102
|
+
onReconnect,
|
|
103
|
+
logger,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await listener.connect();
|
|
107
|
+
expect(listener.status).toBe("reconnecting");
|
|
108
|
+
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("SSE connection failed"));
|
|
109
|
+
expect(logger.info).toHaveBeenCalledWith("SSE reconnecting in 1000ms");
|
|
110
|
+
|
|
111
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
112
|
+
await flushMicrotasks();
|
|
113
|
+
|
|
114
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
115
|
+
expect(onReconnect).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
117
|
+
type: "count_update",
|
|
118
|
+
unreadCount: 2,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
listener.disconnect();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("warns on malformed JSON payloads without crashing the listener", async () => {
|
|
125
|
+
const logger = createLogger();
|
|
126
|
+
const onEvent = vi.fn();
|
|
127
|
+
const onReconnect = vi.fn().mockResolvedValue(undefined);
|
|
128
|
+
|
|
129
|
+
vi.stubGlobal(
|
|
130
|
+
"fetch",
|
|
131
|
+
vi.fn().mockResolvedValue(createSseResponse(["data: not-json\n\n"])),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const listener = new SynapseSseListener({
|
|
135
|
+
synapseUrl: "https://synapse.example.com",
|
|
136
|
+
apiKey: "syn_test_key",
|
|
137
|
+
onEvent,
|
|
138
|
+
onReconnect,
|
|
139
|
+
logger,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await listener.connect();
|
|
143
|
+
await flushMicrotasks();
|
|
144
|
+
|
|
145
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
146
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("SSE JSON parse error"));
|
|
147
|
+
|
|
148
|
+
listener.disconnect();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
export type SseListenerStatus = "connected" | "disconnected" | "reconnecting";
|
|
2
|
+
|
|
3
|
+
export interface SseNotificationEvent {
|
|
4
|
+
type: string; // "new_notification"
|
|
5
|
+
notificationUuid?: string;
|
|
6
|
+
notificationType?: string; // "task_assigned", "mentioned", etc.
|
|
7
|
+
unreadCount?: number;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SynapseSseListenerOptions {
|
|
12
|
+
synapseUrl: string;
|
|
13
|
+
apiKey: string;
|
|
14
|
+
onEvent: (event: SseNotificationEvent) => void;
|
|
15
|
+
onReconnect: () => Promise<void>;
|
|
16
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const INITIAL_DELAY_MS = 1_000;
|
|
20
|
+
const MAX_DELAY_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export class SynapseSseListener {
|
|
23
|
+
private readonly opts: SynapseSseListenerOptions;
|
|
24
|
+
private _status: SseListenerStatus = "disconnected";
|
|
25
|
+
private abortController: AbortController | null = null;
|
|
26
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
private reconnectDelay = INITIAL_DELAY_MS;
|
|
28
|
+
|
|
29
|
+
constructor(opts: SynapseSseListenerOptions) {
|
|
30
|
+
this.opts = opts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get status(): SseListenerStatus {
|
|
34
|
+
return this._status;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Start the SSE connection. Resolves once the first bytes arrive (or rejects on immediate failure). */
|
|
38
|
+
async connect(): Promise<void> {
|
|
39
|
+
this.clearReconnectTimer();
|
|
40
|
+
|
|
41
|
+
const abortController = new AbortController();
|
|
42
|
+
this.abortController = abortController;
|
|
43
|
+
|
|
44
|
+
const url = `${this.opts.synapseUrl.replace(/\/$/, "")}/api/events/notifications`;
|
|
45
|
+
|
|
46
|
+
let response: Response;
|
|
47
|
+
try {
|
|
48
|
+
response = await fetch(url, {
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
51
|
+
Accept: "text/event-stream",
|
|
52
|
+
},
|
|
53
|
+
signal: abortController.signal,
|
|
54
|
+
});
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (abortController.signal.aborted) return; // intentional disconnect
|
|
57
|
+
this.opts.logger.error(`SSE connection failed: ${err}`);
|
|
58
|
+
this.scheduleReconnect();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
this.opts.logger.error(`SSE endpoint returned ${response.status}`);
|
|
64
|
+
this.scheduleReconnect();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!response.body) {
|
|
69
|
+
this.opts.logger.error("SSE response has no body");
|
|
70
|
+
this.scheduleReconnect();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Connection succeeded — reset backoff
|
|
75
|
+
const isReconnect = this._status === "reconnecting";
|
|
76
|
+
this._status = "connected";
|
|
77
|
+
this.reconnectDelay = INITIAL_DELAY_MS;
|
|
78
|
+
this.opts.logger.info("[Synapse] SSE connection established");
|
|
79
|
+
|
|
80
|
+
if (isReconnect) {
|
|
81
|
+
// Fire onReconnect callback so the caller can back-fill missed notifications
|
|
82
|
+
try {
|
|
83
|
+
await this.opts.onReconnect();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
this.opts.logger.warn(`onReconnect callback error: ${err}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Read the stream
|
|
90
|
+
this.consumeStream(response.body, abortController.signal);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Gracefully close the SSE connection. */
|
|
94
|
+
disconnect(): void {
|
|
95
|
+
this.clearReconnectTimer();
|
|
96
|
+
if (this.abortController) {
|
|
97
|
+
this.abortController.abort();
|
|
98
|
+
this.abortController = null;
|
|
99
|
+
}
|
|
100
|
+
this._status = "disconnected";
|
|
101
|
+
this.opts.logger.info("SSE connection closed");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Internal
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
private async consumeStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
|
|
109
|
+
const decoder = new TextDecoder();
|
|
110
|
+
const reader = body.getReader();
|
|
111
|
+
let buffer = "";
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
while (true) {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
if (done || signal.aborted) break;
|
|
117
|
+
|
|
118
|
+
buffer += decoder.decode(value, { stream: true });
|
|
119
|
+
|
|
120
|
+
// SSE messages are delimited by double newlines
|
|
121
|
+
let boundary: number;
|
|
122
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
123
|
+
const raw = buffer.slice(0, boundary);
|
|
124
|
+
buffer = buffer.slice(boundary + 2);
|
|
125
|
+
this.processMessage(raw);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (signal.aborted) return; // intentional disconnect
|
|
130
|
+
this.opts.logger.warn(`SSE stream error: ${err}`);
|
|
131
|
+
} finally {
|
|
132
|
+
try {
|
|
133
|
+
reader.releaseLock();
|
|
134
|
+
} catch {
|
|
135
|
+
// already released
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Stream ended unexpectedly — reconnect unless we were intentionally disconnected
|
|
140
|
+
if (!signal.aborted) {
|
|
141
|
+
this.opts.logger.warn("SSE stream ended, scheduling reconnect");
|
|
142
|
+
this.scheduleReconnect();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private processMessage(raw: string): void {
|
|
147
|
+
for (const line of raw.split("\n")) {
|
|
148
|
+
// Comment lines (heartbeats) — ignore
|
|
149
|
+
if (line.startsWith(":")) continue;
|
|
150
|
+
|
|
151
|
+
// Data lines
|
|
152
|
+
if (line.startsWith("data: ")) {
|
|
153
|
+
const jsonStr = line.slice(6);
|
|
154
|
+
try {
|
|
155
|
+
const event: SseNotificationEvent = JSON.parse(jsonStr);
|
|
156
|
+
this.opts.onEvent(event);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
this.opts.logger.warn(`SSE JSON parse error: ${err} — raw: ${jsonStr}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private scheduleReconnect(): void {
|
|
165
|
+
this.clearReconnectTimer();
|
|
166
|
+
this._status = "reconnecting";
|
|
167
|
+
|
|
168
|
+
this.opts.logger.info(`SSE reconnecting in ${this.reconnectDelay}ms`);
|
|
169
|
+
this.reconnectTimer = setTimeout(() => {
|
|
170
|
+
this.reconnectTimer = null;
|
|
171
|
+
this.connect();
|
|
172
|
+
}, this.reconnectDelay);
|
|
173
|
+
|
|
174
|
+
// Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s
|
|
175
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_DELAY_MS);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private clearReconnectTimer(): void {
|
|
179
|
+
if (this.reconnectTimer !== null) {
|
|
180
|
+
clearTimeout(this.reconnectTimer);
|
|
181
|
+
this.reconnectTimer = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|