@love-moon/conductor-sdk 0.2.16 → 0.2.18

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/dist/ws/client.js CHANGED
@@ -16,10 +16,12 @@ export class ConductorWebSocketClient {
16
16
  connectImpl;
17
17
  onConnected;
18
18
  onDisconnected;
19
+ onPong;
19
20
  onReconnected;
20
21
  handlers = [];
21
22
  extraHeaders;
22
23
  conn = null;
24
+ runtime = null;
23
25
  stop = false;
24
26
  listenTask = null;
25
27
  heartbeatTask = null;
@@ -38,6 +40,7 @@ export class ConductorWebSocketClient {
38
40
  this.connectImpl = options.connectImpl ?? defaultConnectImpl;
39
41
  this.onConnected = options.onConnected;
40
42
  this.onDisconnected = options.onDisconnected;
43
+ this.onPong = options.onPong;
41
44
  this.onReconnected = options.onReconnected;
42
45
  }
43
46
  registerHandler(handler) {
@@ -60,9 +63,20 @@ export class ConductorWebSocketClient {
60
63
  this.heartbeatTask = null;
61
64
  }
62
65
  if (this.conn && !this.isConnectionClosed(this.conn)) {
63
- await this.conn.close();
66
+ this.markDisconnectReason(this.conn, 'manual_disconnect');
67
+ await this.closeConnection(this.conn);
64
68
  }
65
69
  this.conn = null;
70
+ this.runtime = null;
71
+ }
72
+ async forceReconnect(reason = 'manual_reconnect') {
73
+ const conn = this.conn;
74
+ if (!conn || this.isConnectionClosed(conn)) {
75
+ await this.openConnection(true);
76
+ return;
77
+ }
78
+ this.markDisconnectReason(conn, reason);
79
+ await this.terminateConnection(conn);
66
80
  }
67
81
  async sendJson(payload) {
68
82
  await this.ensureConnection();
@@ -83,15 +97,19 @@ export class ConductorWebSocketClient {
83
97
  while (!this.stop) {
84
98
  try {
85
99
  const headers = { Authorization: `Bearer ${this.token}`, ...this.extraHeaders };
86
- this.conn = await this.connectImpl(this.url, { headers });
100
+ const conn = await this.connectImpl(this.url, { headers });
101
+ const runtime = this.createRuntime(conn);
102
+ this.attachConnectionObservers(runtime);
103
+ this.conn = conn;
104
+ this.runtime = runtime;
87
105
  const isReconnect = this.hasConnectedAtLeastOnce;
88
106
  this.hasConnectedAtLeastOnce = true;
89
- this.notifyConnected(isReconnect);
107
+ this.notifyConnected({ isReconnect, connectedAt: runtime.connectedAt });
90
108
  if (isReconnect) {
91
109
  this.notifyReconnected();
92
110
  }
93
- this.listenTask = this.listenLoop(this.conn);
94
- this.heartbeatTask = this.heartbeatLoop(this.conn);
111
+ this.listenTask = this.listenLoop(conn);
112
+ this.heartbeatTask = this.heartbeatLoop(conn);
95
113
  return;
96
114
  }
97
115
  catch (error) {
@@ -124,10 +142,26 @@ export class ConductorWebSocketClient {
124
142
  try {
125
143
  while (!this.stop && !this.isConnectionClosed(conn)) {
126
144
  await wait(this.heartbeatInterval, this.waitController.signal);
145
+ if (this.stop || conn !== this.conn) {
146
+ return;
147
+ }
148
+ const runtime = this.getRuntime(conn);
149
+ if (!runtime) {
150
+ return;
151
+ }
152
+ if (runtime.supportsPongTracking && runtime.waitingForPong) {
153
+ runtime.missedPongs += 1;
154
+ runtime.disconnectReason = 'pong_timeout';
155
+ await this.terminateConnection(conn);
156
+ break;
157
+ }
158
+ runtime.lastPingAt = Date.now();
159
+ runtime.waitingForPong = runtime.supportsPongTracking;
127
160
  try {
128
161
  await conn.ping();
129
162
  }
130
163
  catch {
164
+ this.markDisconnectReason(conn, 'ping_failed');
131
165
  break;
132
166
  }
133
167
  }
@@ -140,11 +174,17 @@ export class ConductorWebSocketClient {
140
174
  if (this.stop || conn !== this.conn) {
141
175
  return;
142
176
  }
177
+ const runtime = this.getRuntime(conn);
143
178
  this.conn = null;
144
- this.notifyDisconnected();
179
+ this.runtime = null;
180
+ this.notifyDisconnected(this.buildDisconnectEvent(runtime));
145
181
  await this.openConnection(true);
146
182
  }
147
183
  async dispatch(message) {
184
+ const now = Date.now();
185
+ if (this.runtime) {
186
+ this.runtime.lastMessageAt = now;
187
+ }
148
188
  let payload;
149
189
  try {
150
190
  payload = JSON.parse(message);
@@ -159,12 +199,12 @@ export class ConductorWebSocketClient {
159
199
  }
160
200
  }
161
201
  }
162
- notifyConnected(isReconnect) {
202
+ notifyConnected(event) {
163
203
  if (!this.onConnected) {
164
204
  return;
165
205
  }
166
206
  try {
167
- this.onConnected({ isReconnect });
207
+ this.onConnected(event);
168
208
  }
169
209
  catch {
170
210
  // Swallow callback errors to avoid impacting reconnect behavior.
@@ -181,12 +221,12 @@ export class ConductorWebSocketClient {
181
221
  // Swallow callback errors to avoid impacting reconnect behavior.
182
222
  }
183
223
  }
184
- notifyDisconnected() {
224
+ notifyDisconnected(event) {
185
225
  if (!this.onDisconnected) {
186
226
  return;
187
227
  }
188
228
  try {
189
- this.onDisconnected();
229
+ this.onDisconnected(event);
190
230
  }
191
231
  catch {
192
232
  // Swallow callback errors to avoid impacting reconnect behavior.
@@ -222,8 +262,10 @@ export class ConductorWebSocketClient {
222
262
  }
223
263
  attemptedReconnect = true;
224
264
  if (this.conn === conn) {
265
+ this.markDisconnectReason(conn, 'send_reconnect');
225
266
  this.conn = null;
226
- this.notifyDisconnected();
267
+ this.notifyDisconnected(this.buildDisconnectEvent(this.getRuntime(conn)));
268
+ this.runtime = null;
227
269
  }
228
270
  await this.openConnection(true);
229
271
  }
@@ -235,6 +277,100 @@ export class ConductorWebSocketClient {
235
277
  const message = error instanceof Error ? error.message : String(error);
236
278
  return message.toLowerCase().includes('websocket is not open');
237
279
  }
280
+ createRuntime(conn) {
281
+ const connectedAt = Date.now();
282
+ return {
283
+ conn,
284
+ connectedAt,
285
+ supportsPongTracking: typeof conn.onPong === 'function',
286
+ lastMessageAt: null,
287
+ lastPingAt: null,
288
+ lastPongAt: connectedAt,
289
+ waitingForPong: false,
290
+ missedPongs: 0,
291
+ disconnectReason: null,
292
+ closeCode: null,
293
+ closeReason: null,
294
+ socketError: null,
295
+ };
296
+ }
297
+ attachConnectionObservers(runtime) {
298
+ runtime.conn.onPong?.(() => {
299
+ const activeRuntime = this.getRuntime(runtime.conn);
300
+ if (!activeRuntime) {
301
+ return;
302
+ }
303
+ const at = Date.now();
304
+ const latencyMs = activeRuntime.lastPingAt ? at - activeRuntime.lastPingAt : null;
305
+ activeRuntime.lastPongAt = at;
306
+ activeRuntime.waitingForPong = false;
307
+ activeRuntime.missedPongs = 0;
308
+ if (this.onPong) {
309
+ try {
310
+ this.onPong({ at, latencyMs });
311
+ }
312
+ catch {
313
+ // Swallow callback errors to avoid impacting reconnect behavior.
314
+ }
315
+ }
316
+ });
317
+ runtime.conn.onCloseInfo?.((info) => {
318
+ const activeRuntime = this.getRuntime(runtime.conn);
319
+ if (!activeRuntime) {
320
+ return;
321
+ }
322
+ activeRuntime.closeCode = typeof info.code === 'number' ? info.code : null;
323
+ activeRuntime.closeReason = normalizeOptionalString(info.reason);
324
+ });
325
+ runtime.conn.onErrorInfo?.((error) => {
326
+ const activeRuntime = this.getRuntime(runtime.conn);
327
+ if (!activeRuntime) {
328
+ return;
329
+ }
330
+ activeRuntime.socketError = error instanceof Error ? error.message : String(error);
331
+ if (!activeRuntime.disconnectReason) {
332
+ activeRuntime.disconnectReason = 'socket_error';
333
+ }
334
+ });
335
+ }
336
+ getRuntime(conn) {
337
+ if (!this.runtime || this.runtime.conn !== conn) {
338
+ return null;
339
+ }
340
+ return this.runtime;
341
+ }
342
+ markDisconnectReason(conn, reason) {
343
+ const runtime = this.getRuntime(conn);
344
+ if (runtime && !runtime.disconnectReason) {
345
+ runtime.disconnectReason = reason;
346
+ }
347
+ }
348
+ buildDisconnectEvent(runtime) {
349
+ return {
350
+ reason: runtime?.disconnectReason || 'connection_lost',
351
+ disconnectedAt: Date.now(),
352
+ connectedAt: runtime?.connectedAt ?? null,
353
+ closeCode: runtime?.closeCode ?? null,
354
+ closeReason: runtime?.closeReason ?? null,
355
+ socketError: runtime?.socketError ?? null,
356
+ missedPongs: runtime?.missedPongs ?? 0,
357
+ lastPingAt: runtime?.lastPingAt ?? null,
358
+ lastPongAt: runtime?.lastPongAt ?? null,
359
+ lastMessageAt: runtime?.lastMessageAt ?? null,
360
+ };
361
+ }
362
+ async closeConnection(conn) {
363
+ if (typeof conn.close === 'function') {
364
+ await conn.close();
365
+ }
366
+ }
367
+ async terminateConnection(conn) {
368
+ if (typeof conn.terminate === 'function') {
369
+ await conn.terminate();
370
+ return;
371
+ }
372
+ await this.closeConnection(conn);
373
+ }
238
374
  }
239
375
  async function wait(ms, signal) {
240
376
  if (!signal) {
@@ -276,16 +412,34 @@ class WsAdapter {
276
412
  ws;
277
413
  queue = [];
278
414
  waiters = [];
415
+ pongHandlers = new Set();
416
+ closeInfoHandlers = new Set();
417
+ errorHandlers = new Set();
279
418
  closed = false;
280
419
  constructor(ws) {
281
420
  this.ws = ws;
282
421
  ws.on('message', (data) => this.enqueue(data.toString()));
283
- ws.on('close', () => {
422
+ ws.on('pong', () => {
423
+ for (const handler of this.pongHandlers) {
424
+ handler();
425
+ }
426
+ });
427
+ ws.on('close', (code, reason) => {
284
428
  this.closed = true;
429
+ const closeInfo = {
430
+ code,
431
+ reason: normalizeOptionalString(reason?.toString()),
432
+ };
433
+ for (const handler of this.closeInfoHandlers) {
434
+ handler(closeInfo);
435
+ }
285
436
  this.enqueue(null);
286
437
  });
287
- ws.on('error', () => {
438
+ ws.on('error', (error) => {
288
439
  this.closed = true;
440
+ for (const handler of this.errorHandlers) {
441
+ handler(error);
442
+ }
289
443
  this.enqueue(null);
290
444
  });
291
445
  }
@@ -323,6 +477,21 @@ class WsAdapter {
323
477
  this.ws.close();
324
478
  });
325
479
  }
480
+ terminate() {
481
+ if (this.closed) {
482
+ return;
483
+ }
484
+ this.ws.terminate();
485
+ }
486
+ onPong(handler) {
487
+ this.pongHandlers.add(handler);
488
+ }
489
+ onCloseInfo(handler) {
490
+ this.closeInfoHandlers.add(handler);
491
+ }
492
+ onErrorInfo(handler) {
493
+ this.errorHandlers.add(handler);
494
+ }
326
495
  async *[Symbol.asyncIterator]() {
327
496
  while (true) {
328
497
  const value = await this.nextValue();
@@ -351,3 +520,10 @@ class WsAdapter {
351
520
  }
352
521
  }
353
522
  }
523
+ function normalizeOptionalString(value) {
524
+ if (typeof value !== 'string') {
525
+ return null;
526
+ }
527
+ const trimmed = value.trim();
528
+ return trimmed || null;
529
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -27,5 +27,5 @@
27
27
  "typescript": "^5.6.3",
28
28
  "vitest": "^2.1.4"
29
29
  },
30
- "gitCommitId": "13b4d6c"
30
+ "gitCommitId": "942418b"
31
31
  }