@clawroom/sdk 0.5.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawroom/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "ClawRoom SDK — polling client and protocol types for connecting any agent to ClawRoom",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,5 +19,7 @@
19
19
  "files": [
20
20
  "src"
21
21
  ],
22
- "dependencies": {}
22
+ "dependencies": {
23
+ "@clawroom/protocol": "^0.5.1"
24
+ }
23
25
  }
package/src/client.ts CHANGED
@@ -3,9 +3,9 @@ import type {
3
3
  ServerTask,
4
4
  ServerChatMessage,
5
5
  } from "./protocol.js";
6
+ import { WsTransport } from "./ws-transport.js";
6
7
 
7
8
  const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
8
-
9
9
  const HEARTBEAT_INTERVAL_MS = 30_000;
10
10
  const POLL_INTERVAL_MS = 10_000;
11
11
 
@@ -13,17 +13,12 @@ export type TaskCallback = (task: ServerTask) => void;
13
13
  export type ChatCallback = (messages: ServerChatMessage[]) => void;
14
14
 
15
15
  export type ClawroomClientOptions = {
16
- /** HTTP base URL. Defaults to https://clawroom.site9.ai/api/agents */
17
16
  endpoint?: string;
18
- /** Agent secret token */
19
17
  token: string;
20
- /** Device identifier */
21
18
  deviceId: string;
22
- /** Agent skills */
23
19
  skills: string[];
24
- /** Agent kind (e.g. "openclaw", "claude-code", "codex", "custom"). Defaults to "openclaw" */
25
20
  kind?: string;
26
- /** Optional logger */
21
+ wsUrl?: string;
27
22
  log?: {
28
23
  info?: (message: string, ...args: unknown[]) => void;
29
24
  warn?: (message: string, ...args: unknown[]) => void;
@@ -32,34 +27,8 @@ export type ClawroomClientOptions = {
32
27
  };
33
28
 
34
29
  /**
35
- * ClawRoom SDK client using HTTP polling.
36
- *
37
- * Agents register with /heartbeat and receive assigned tasks + chat via /poll.
38
- * All agent actions (complete, fail, progress, chat reply) use HTTP POST.
39
- *
40
- * Usage:
41
- * ```ts
42
- * import { ClawroomClient } from "@clawroom/sdk";
43
- *
44
- * const client = new ClawroomClient({
45
- * token: "your-agent-secret",
46
- * deviceId: "my-agent-1",
47
- * skills: ["translation", "coding"],
48
- * });
49
- *
50
- * client.onTask((task) => {
51
- * // task was assigned to this agent — execute it
52
- * client.send({ type: "agent.complete", taskId: task.taskId, output: "Done!" });
53
- * });
54
- *
55
- * client.onChatMessage((messages) => {
56
- * for (const msg of messages) {
57
- * client.send({ type: "agent.chat.reply", channelId: msg.channelId, content: "Hello!" });
58
- * }
59
- * });
60
- *
61
- * client.connect();
62
- * ```
30
+ * ClawRoom SDK client.
31
+ * WebSocket primary for real-time push, HTTP polling as fallback.
63
32
  */
64
33
  export class ClawroomClient {
65
34
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -67,27 +36,57 @@ export class ClawroomClient {
67
36
  protected stopped = false;
68
37
  protected readonly httpBase: string;
69
38
  protected readonly options: ClawroomClientOptions;
70
-
71
39
  protected taskCallbacks: TaskCallback[] = [];
72
40
  protected chatCallbacks: ChatCallback[] = [];
41
+ private wsTransport: WsTransport | null = null;
73
42
 
74
43
  constructor(options: ClawroomClientOptions) {
75
44
  this.options = options;
76
45
  this.httpBase = (options.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
77
46
  }
78
47
 
48
+ private get wsUrl(): string {
49
+ if (this.options.wsUrl) return this.options.wsUrl;
50
+ return this.httpBase.replace(/\/api\/agents$/, "").replace(/^http/, "ws") + "/api/ws";
51
+ }
52
+
79
53
  connect(): void {
80
54
  this.stopped = false;
81
55
  this.startHeartbeat();
82
- this.stopPolling();
83
- this.pollTimer = setInterval(() => void this.pollTick(), POLL_INTERVAL_MS);
84
56
  void this.register();
57
+
58
+ // WebSocket transport
59
+ this.wsTransport = new WsTransport({
60
+ url: this.wsUrl,
61
+ token: this.options.token,
62
+ log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
63
+ onConnected: () => { this.options.log?.info?.("[clawroom] WebSocket connected"); },
64
+ onDisconnected: () => { this.options.log?.info?.("[clawroom] WebSocket disconnected"); },
65
+ onMessage: (msg) => {
66
+ if (msg.type === "task" && msg.task) {
67
+ for (const cb of this.taskCallbacks) cb(msg.task);
68
+ }
69
+ if (msg.type === "chat" && Array.isArray(msg.messages)) {
70
+ for (const cb of this.chatCallbacks) cb(msg.messages);
71
+ }
72
+ },
73
+ });
74
+ this.wsTransport.connect();
75
+
76
+ // HTTP poll fallback — only when WS is down
77
+ this.stopPolling();
78
+ this.pollTimer = setInterval(() => {
79
+ if (this.wsTransport?.connected) return;
80
+ void this.pollTick();
81
+ }, POLL_INTERVAL_MS);
85
82
  }
86
83
 
87
84
  disconnect(): void {
88
85
  this.stopped = true;
89
86
  this.stopHeartbeat();
90
87
  this.stopPolling();
88
+ this.wsTransport?.disconnect();
89
+ this.wsTransport = null;
91
90
  }
92
91
 
93
92
  send(message: AgentMessage): void {
@@ -97,7 +96,7 @@ export class ClawroomClient {
97
96
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
98
97
  onChatMessage(cb: ChatCallback): void { this.chatCallbacks.push(cb); }
99
98
 
100
- // ── Heartbeat ───────────────────────────────────────────────────
99
+ // ── Heartbeat ─────────────────────────────────────────
101
100
 
102
101
  private startHeartbeat(): void {
103
102
  this.stopHeartbeat();
@@ -133,12 +132,10 @@ export class ClawroomClient {
133
132
  try {
134
133
  const res = await this.httpRequest("POST", "/poll", {});
135
134
  this.onPollSuccess(res?.agentId);
136
-
137
135
  if (res.task) {
138
136
  this.options.log?.info?.(`[clawroom] received task ${res.task.taskId}: ${res.task.title}`);
139
137
  for (const cb of this.taskCallbacks) cb(res.task);
140
138
  }
141
-
142
139
  if (res.chat && Array.isArray(res.chat) && res.chat.length > 0) {
143
140
  this.options.log?.info?.(`[clawroom] received ${res.chat.length} chat mention(s)`);
144
141
  for (const cb of this.chatCallbacks) cb(res.chat);
@@ -149,12 +146,10 @@ export class ClawroomClient {
149
146
  }
150
147
  }
151
148
 
152
- /** Override in subclass for lifecycle tracking */
153
149
  protected onPollSuccess(_agentId: string | undefined): void {}
154
- /** Override in subclass for lifecycle tracking */
155
150
  protected onPollError(_err: unknown): void {}
156
151
 
157
- // ── HTTP ────────────────────────────────────────────────────────
152
+ // ── HTTP ──────────────────────────────────────────────
158
153
 
159
154
  private async sendViaHttp(message: AgentMessage): Promise<void> {
160
155
  switch (message.type) {
@@ -181,6 +176,5 @@ export class ClawroomClient {
181
176
  return res.json();
182
177
  }
183
178
 
184
- /** Override in subclass for error handling (e.g. 401 auto-stop) */
185
179
  protected onHttpError(_status: number, _text: string): void {}
186
180
  }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export { ClawroomClient } from "./client.js";
2
2
  export type { ClawroomClientOptions } from "./client.js";
3
3
  export { ClawroomMachineClient } from "./machine-client.js";
4
4
  export type { ClawroomMachineClientOptions } from "./machine-client.js";
5
+ export { WsTransport } from "./ws-transport.js";
6
+ export type { WsTransportOptions } from "./ws-transport.js";
5
7
 
6
8
  export type {
7
9
  AgentMessage,
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * ClawRoom Machine Client — machine-level auth with multi-agent support.
3
+ * WebSocket primary for real-time push, HTTP polling as fallback.
3
4
  */
4
5
 
5
6
  import * as os from "node:os";
7
+ import { WsTransport } from "./ws-transport.js";
6
8
 
7
9
  export type ClawroomMachineClientOptions = {
8
10
  endpoint?: string;
9
11
  apiKey: string;
10
12
  hostname?: string;
11
13
  capabilities?: string[];
14
+ wsUrl?: string;
12
15
  log?: {
13
16
  info?: (message: string, ...args: unknown[]) => void;
14
17
  warn?: (message: string, ...args: unknown[]) => void;
@@ -23,6 +26,16 @@ type AgentWork = {
23
26
  chat: Array<{ messageId: string; channelId: string; content: string; isMention: boolean; context: unknown[] }> | null;
24
27
  };
25
28
 
29
+ type MachineHeartbeatResponse = {
30
+ machineId: string;
31
+ agents?: Array<{ id: string }>;
32
+ };
33
+
34
+ type MachinePollResponse = {
35
+ machineId: string;
36
+ agents: AgentWork[];
37
+ };
38
+
26
39
  export class ClawroomMachineClient {
27
40
  private options: ClawroomMachineClientOptions;
28
41
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -31,6 +44,9 @@ export class ClawroomMachineClient {
31
44
  private chatHandler: ((agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) | null = null;
32
45
  private _connected = false;
33
46
  private _stopped = false;
47
+ private wsTransport: WsTransport | null = null;
48
+ private subscribedAgentIds = new Set<string>();
49
+ private recentChatIds = new Map<string, number>();
34
50
 
35
51
  constructor(options: ClawroomMachineClientOptions) {
36
52
  this.options = options;
@@ -40,31 +56,27 @@ export class ClawroomMachineClient {
40
56
  return this.options.endpoint ?? "http://localhost:3000/api/machines";
41
57
  }
42
58
 
59
+ private get wsUrl(): string {
60
+ if (this.options.wsUrl) return this.options.wsUrl;
61
+ return this.baseUrl.replace(/\/api\/machines$/, "").replace(/^http/, "ws") + "/api/ws";
62
+ }
63
+
43
64
  private async httpRequest(method: string, path: string, body?: unknown): Promise<unknown> {
44
65
  const url = `${this.baseUrl}${path}`;
45
66
  const res = await fetch(url, {
46
67
  method,
47
- headers: {
48
- "Content-Type": "application/json",
49
- Authorization: `Bearer ${this.options.apiKey}`,
50
- },
68
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.options.apiKey}` },
51
69
  body: body ? JSON.stringify(body) : undefined,
52
70
  });
53
71
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
54
72
  return res.json();
55
73
  }
56
74
 
57
- onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) {
58
- this.taskHandler = handler;
59
- }
75
+ onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) { this.taskHandler = handler; }
76
+ onAgentChat(handler: (agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) { this.chatHandler = handler; }
60
77
 
61
- onAgentChat(handler: (agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) {
62
- this.chatHandler = handler;
63
- }
64
-
65
- async sendAgentComplete(agentId: string, taskId: string, output: string) {
66
- // Use agent-level API via machine auth proxy (or direct)
67
- await this.httpRequest("POST", "/complete", { agentId, taskId, output });
78
+ async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>) {
79
+ await this.httpRequest("POST", "/complete", { agentId, taskId, output, attachments });
68
80
  }
69
81
 
70
82
  async sendAgentFail(agentId: string, taskId: string, reason: string) {
@@ -82,40 +94,108 @@ export class ClawroomMachineClient {
82
94
  get connected() { return this._connected; }
83
95
  get stopped() { return this._stopped; }
84
96
 
97
+ private rememberChat(messageId: string): boolean {
98
+ if (!messageId) return true;
99
+ const now = Date.now();
100
+ for (const [id, seenAt] of this.recentChatIds) {
101
+ if (now - seenAt > 10 * 60_000) this.recentChatIds.delete(id);
102
+ }
103
+ if (this.recentChatIds.has(messageId)) return false;
104
+ this.recentChatIds.set(messageId, now);
105
+ return true;
106
+ }
107
+
108
+ private syncAgentSubscriptions(agentIds: string[]) {
109
+ if (agentIds.length === 0) return;
110
+ for (const agentId of agentIds) {
111
+ this.subscribedAgentIds.add(agentId);
112
+ this.wsTransport?.send({ type: "subscribe_agent", agentId });
113
+ }
114
+ }
115
+
116
+ private async pollOnce() {
117
+ if (!this._connected) return;
118
+ try {
119
+ const result = (await this.httpRequest("POST", "/poll", {})) as MachinePollResponse;
120
+ this.syncAgentSubscriptions(result.agents.map((agent) => agent.agentId));
121
+ for (const agent of result.agents) {
122
+ if (agent.task && this.taskHandler) this.taskHandler(agent.agentId, agent.task);
123
+ if (agent.chat && agent.chat.length > 0 && this.chatHandler) {
124
+ const freshMessages = agent.chat.filter((message) => this.rememberChat(message.messageId));
125
+ if (freshMessages.length > 0) this.chatHandler(agent.agentId, freshMessages);
126
+ }
127
+ }
128
+ } catch (err) {
129
+ this.options.log?.warn?.(`[machine] poll error: ${err}`);
130
+ }
131
+ }
132
+
85
133
  connect() {
86
134
  this._stopped = false;
87
135
  const hostname = this.options.hostname ?? os.hostname();
88
136
  const hbBody = { hostname, capabilities: this.options.capabilities };
89
137
 
90
- // Heartbeat every 30s
138
+ // Heartbeat every 30s (always HTTP)
91
139
  this.heartbeatTimer = setInterval(async () => {
92
140
  try {
93
- await this.httpRequest("POST", "/heartbeat", hbBody);
141
+ const result = (await this.httpRequest("POST", "/heartbeat", hbBody)) as MachineHeartbeatResponse;
142
+ this.syncAgentSubscriptions((result.agents ?? []).map((agent) => agent.id));
94
143
  if (!this._connected) { this._connected = true; this.options.log?.info?.("[machine] connected"); }
95
- } catch (err) {
144
+ } catch {
96
145
  if (this._connected) { this._connected = false; this.options.log?.warn?.("[machine] disconnected"); }
97
- this.options.log?.warn?.(`[machine] heartbeat error: ${err}`);
98
146
  }
99
147
  }, 30_000);
100
148
 
101
- // Initial heartbeat
102
149
  this.httpRequest("POST", "/heartbeat", hbBody)
103
- .then(() => { this._connected = true; this.options.log?.info?.("[machine] connected"); })
150
+ .then((result) => {
151
+ const data = result as MachineHeartbeatResponse;
152
+ this._connected = true;
153
+ this.syncAgentSubscriptions((data.agents ?? []).map((agent) => agent.id));
154
+ this.options.log?.info?.("[machine] connected");
155
+ })
104
156
  .catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
105
157
 
106
- // Poll every 10s
107
- this.pollTimer = setInterval(async () => {
108
- if (!this._connected) return;
109
- try {
110
- const result = (await this.httpRequest("POST", "/poll", {})) as { machineId: string; agents: AgentWork[] };
111
- for (const agent of result.agents) {
112
- if (agent.task && this.taskHandler) this.taskHandler(agent.agentId, agent.task);
113
- if (agent.chat && agent.chat.length > 0 && this.chatHandler) this.chatHandler(agent.agentId, agent.chat);
158
+ // WebSocket transport
159
+ this.wsTransport = new WsTransport({
160
+ url: this.wsUrl,
161
+ token: this.options.apiKey,
162
+ log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
163
+ onConnected: () => {
164
+ this.syncAgentSubscriptions([...this.subscribedAgentIds]);
165
+ this.options.log?.info?.("[machine] WebSocket connected");
166
+ },
167
+ onDisconnected: () => {
168
+ this.options.log?.info?.("[machine] WebSocket disconnected, HTTP polling active");
169
+ },
170
+ onMessage: (msg) => {
171
+ if (msg.type === "message") {
172
+ const m = msg.message;
173
+ const targetAgentId = msg.agentId ?? m?.agentId;
174
+ const messageId = m?.id ?? msg.messageId ?? "";
175
+ if (m && targetAgentId && this.chatHandler && this.rememberChat(messageId)) {
176
+ this.chatHandler(targetAgentId, [{
177
+ messageId,
178
+ channelId: m.channelId ?? "",
179
+ content: m.content ?? "",
180
+ isMention: msg.isMention ?? false,
181
+ context: msg.context ?? [],
182
+ }]);
183
+ }
114
184
  }
115
- } catch (err) {
116
- this.options.log?.warn?.(`[machine] poll error: ${err}`);
117
- }
185
+ if (msg.type === "task" && msg.agentId && this.taskHandler) {
186
+ this.taskHandler(msg.agentId, msg.task);
187
+ }
188
+ },
189
+ });
190
+ this.wsTransport.connect();
191
+
192
+ // Keep HTTP polling active even when WS is up.
193
+ // Machine WS chat delivery is best-effort; polling is the durable delivery path.
194
+ this.pollTimer = setInterval(async () => {
195
+ await this.pollOnce();
118
196
  }, 10_000);
197
+
198
+ void this.pollOnce();
119
199
  }
120
200
 
121
201
  disconnect() {
@@ -123,5 +203,7 @@ export class ClawroomMachineClient {
123
203
  this._connected = false;
124
204
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
125
205
  if (this.pollTimer) clearInterval(this.pollTimer);
206
+ this.wsTransport?.disconnect();
207
+ this.wsTransport = null;
126
208
  }
127
209
  }
package/src/protocol.ts CHANGED
@@ -1,83 +1,20 @@
1
- // ClawRoom protocol types.
2
- // These define the messages exchanged between agents and the server.
3
-
4
- // ---- Agent -> Server ----
5
-
6
- export interface AgentHeartbeat {
7
- type: "agent.heartbeat";
8
- }
9
-
10
- export interface AgentResultFile {
11
- filename: string;
12
- mimeType: string;
13
- data: string; // base64 encoded
14
- }
15
-
16
- export interface AgentComplete {
17
- type: "agent.complete";
18
- taskId: string;
19
- output: string;
20
- attachments?: AgentResultFile[];
21
- }
22
-
23
- export interface AgentProgress {
24
- type: "agent.progress";
25
- taskId: string;
26
- message: string;
27
- percent?: number;
28
- }
29
-
30
- export interface AgentFail {
31
- type: "agent.fail";
32
- taskId: string;
33
- reason: string;
34
- }
35
-
36
- export interface AgentChatReply {
37
- type: "agent.chat.reply";
38
- channelId: string;
39
- content: string;
40
- replyTo?: string;
41
- }
42
-
43
- export interface AgentTyping {
44
- type: "agent.typing";
45
- channelId: string;
46
- }
47
-
48
- export type AgentMessage =
49
- | AgentHeartbeat
50
- | AgentComplete
51
- | AgentProgress
52
- | AgentFail
53
- | AgentChatReply
54
- | AgentTyping;
55
-
56
- // ---- Server -> Agent ----
57
-
58
- export interface ServerTask {
59
- type: "server.task";
60
- taskId: string;
61
- title: string;
62
- description: string;
63
- input: string;
64
- skillTags: string[];
65
- }
66
-
67
- export interface ServerChatMessage {
68
- messageId: string;
69
- channelId: string;
70
- content: string;
71
- isMention: boolean;
72
- context: Array<{
73
- id: string;
74
- senderType: string;
75
- senderName: string;
76
- content: string;
77
- createdAt: number;
78
- }>;
79
- }
80
-
81
- export type ServerMessage =
82
- | ServerTask
83
- | ServerChatMessage;
1
+ export type {
2
+ AgentHeartbeat,
3
+ AgentResultFile,
4
+ AgentComplete,
5
+ AgentProgress,
6
+ AgentFail,
7
+ AgentChatReply,
8
+ AgentTyping,
9
+ AgentMessage,
10
+ ServerTask,
11
+ ServerChatMessage,
12
+ ServerMessage,
13
+ RuntimeDef,
14
+ } from "@clawroom/protocol";
15
+
16
+ export {
17
+ RUNTIMES,
18
+ BRIDGE_MANAGED_RUNTIME_IDS,
19
+ RUNTIME_MAP,
20
+ } from "@clawroom/protocol";
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared WebSocket transport with auto-reconnect and fallback support.
3
+ * Used by both ClawroomClient and ClawroomMachineClient.
4
+ */
5
+
6
+ export type WsTransportOptions = {
7
+ url: string;
8
+ token: string;
9
+ onMessage: (msg: any) => void;
10
+ onConnected: () => void;
11
+ onDisconnected: () => void;
12
+ log?: {
13
+ info?: (message: string) => void;
14
+ warn?: (message: string) => void;
15
+ };
16
+ };
17
+
18
+ export class WsTransport {
19
+ private ws: any = null;
20
+ private options: WsTransportOptions;
21
+ private _connected = false;
22
+ private stopped = false;
23
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
24
+ private reconnectDelay = 1000;
25
+ private failCount = 0;
26
+
27
+ static readonly MAX_RECONNECT_DELAY = 60_000;
28
+ static readonly MAX_RECONNECT_FAILS = 10;
29
+
30
+ constructor(options: WsTransportOptions) {
31
+ this.options = options;
32
+ }
33
+
34
+ get connected() { return this._connected; }
35
+
36
+ async connect() {
37
+ if (this.stopped) return;
38
+
39
+ let WebSocket: any;
40
+ try {
41
+ WebSocket = (await import("ws")).default;
42
+ } catch {
43
+ this.options.log?.info?.("ws module not available, skipping WebSocket");
44
+ return;
45
+ }
46
+
47
+ try {
48
+ this.ws = new WebSocket(this.options.url);
49
+ } catch {
50
+ this.scheduleReconnect();
51
+ return;
52
+ }
53
+
54
+ this.ws.on("open", () => {
55
+ this.reconnectDelay = 1000;
56
+ this.ws.send(JSON.stringify({ type: "auth", token: this.options.token }));
57
+ });
58
+
59
+ this.ws.on("message", (raw: any) => {
60
+ let msg: any;
61
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
62
+
63
+ if (msg.type === "auth_ok") {
64
+ this._connected = true;
65
+ this.failCount = 0;
66
+ this.options.onConnected();
67
+ return;
68
+ }
69
+
70
+ if (msg.type === "auth_error") {
71
+ this._connected = false;
72
+ this.options.log?.warn?.(`WS auth failed: ${msg.error ?? "unknown"}`);
73
+ this.ws?.close();
74
+ return;
75
+ }
76
+
77
+ if (msg.type === "pong") return;
78
+
79
+ this.options.onMessage(msg);
80
+ });
81
+
82
+ this.ws.on("close", () => {
83
+ const wasConnected = this._connected;
84
+ this._connected = false;
85
+ if (wasConnected) this.options.onDisconnected();
86
+ if (!this.stopped) this.scheduleReconnect();
87
+ });
88
+
89
+ this.ws.on("error", () => {
90
+ this._connected = false;
91
+ });
92
+ }
93
+
94
+ send(msg: any) {
95
+ if (this._connected && this.ws?.readyState === 1) {
96
+ this.ws.send(JSON.stringify(msg));
97
+ }
98
+ }
99
+
100
+ disconnect() {
101
+ this.stopped = true;
102
+ this._connected = false;
103
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
104
+ if (this.ws) { this.ws.close(); this.ws = null; }
105
+ }
106
+
107
+ private scheduleReconnect() {
108
+ if (this.stopped || this.reconnectTimer) return;
109
+
110
+ this.failCount++;
111
+ if (this.failCount > WsTransport.MAX_RECONNECT_FAILS) {
112
+ this.options.log?.warn?.(`WS reconnect failed ${this.failCount} times, giving up. HTTP polling active.`);
113
+ return;
114
+ }
115
+
116
+ const jitter = Math.random() * 3000;
117
+ this.reconnectTimer = setTimeout(() => {
118
+ this.reconnectTimer = null;
119
+ this.connect();
120
+ }, Math.min(this.reconnectDelay + jitter, WsTransport.MAX_RECONNECT_DELAY));
121
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, WsTransport.MAX_RECONNECT_DELAY);
122
+ }
123
+ }