@pnds/sdk 1.0.1 → 1.2.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/src/ws.ts CHANGED
@@ -37,6 +37,10 @@ export class PondWs {
37
37
  private lastSeq = 0;
38
38
  private _state: WsConnectionState = 'disconnected';
39
39
  private intentionalClose = false;
40
+ private lastReceivedAt = 0;
41
+ private heartbeatIntervalMs = 0;
42
+ private probeTimer: ReturnType<typeof setTimeout> | null = null;
43
+ private browserListenersActive = false;
40
44
 
41
45
  constructor(options: PondWsOptions) {
42
46
  this.options = {
@@ -54,6 +58,7 @@ export class PondWs {
54
58
 
55
59
  async connect() {
56
60
  this.intentionalClose = false;
61
+ this.setupBrowserListeners();
57
62
  this.setState('connecting');
58
63
 
59
64
  let ticket: WsTicketResponse;
@@ -106,6 +111,7 @@ export class PondWs {
106
111
  this.ws.onopen = () => {
107
112
  clearTimeout(connectTimeout);
108
113
  this.reconnectAttempts = 0;
114
+ this.lastReceivedAt = Date.now();
109
115
  this.setState('connected');
110
116
  };
111
117
 
@@ -121,6 +127,7 @@ export class PondWs {
121
127
  this.ws.onclose = () => {
122
128
  clearTimeout(connectTimeout);
123
129
  this.stopHeartbeat();
130
+ this.clearProbe();
124
131
  if (this.intentionalClose) {
125
132
  this.setState('disconnected');
126
133
  return;
@@ -141,6 +148,8 @@ export class PondWs {
141
148
  this.intentionalClose = true;
142
149
  this.stopHeartbeat();
143
150
  this.clearReconnect();
151
+ this.clearProbe();
152
+ this.teardownBrowserListeners();
144
153
  if (this.ws) {
145
154
  this.ws.close();
146
155
  this.ws = null;
@@ -216,6 +225,9 @@ export class PondWs {
216
225
  }
217
226
 
218
227
  private handleFrame(frame: WsFrame) {
228
+ this.lastReceivedAt = Date.now();
229
+ this.clearProbe();
230
+
219
231
  // Track seq for reconnection
220
232
  if (frame.seq !== undefined) {
221
233
  this.lastSeq = frame.seq;
@@ -238,7 +250,21 @@ export class PondWs {
238
250
 
239
251
  private startHeartbeat(intervalSec: number) {
240
252
  this.stopHeartbeat();
253
+ if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
254
+ console.error('Invalid heartbeat interval from server:', intervalSec);
255
+ return;
256
+ }
257
+ this.heartbeatIntervalMs = intervalSec * 1000;
241
258
  this.heartbeatTimer = setInterval(() => {
259
+ // If no data received for >1.5× heartbeat interval, the connection may
260
+ // be stale — or the timer may have been delayed by browser throttling /
261
+ // event-loop stalls. Probe with a 5s timeout rather than force-
262
+ // reconnecting, so healthy connections that were merely timer-starved
263
+ // survive while genuinely dead sockets are caught quickly.
264
+ if (this.lastReceivedAt > 0 && Date.now() - this.lastReceivedAt > this.heartbeatIntervalMs * 1.5) {
265
+ this.probeConnection();
266
+ return;
267
+ }
242
268
  this.send({ type: WS_EVENTS.PING, data: { ts: Date.now() } });
243
269
  }, intervalSec * 1000);
244
270
  }
@@ -254,10 +280,12 @@ export class PondWs {
254
280
  this.setState('reconnecting');
255
281
  this.clearReconnect();
256
282
 
257
- const delay = Math.min(
283
+ const base = Math.min(
258
284
  this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts),
259
285
  this.options.maxReconnectInterval,
260
286
  );
287
+ // Jitter: 50-100% of base delay to prevent thundering herd
288
+ const delay = base * (0.5 + Math.random() * 0.5);
261
289
  this.reconnectAttempts++;
262
290
 
263
291
  this.reconnectTimer = setTimeout(() => {
@@ -272,6 +300,108 @@ export class PondWs {
272
300
  }
273
301
  }
274
302
 
303
+ /** Force-close a dead/stale connection and trigger reconnect. */
304
+ private forceReconnect() {
305
+ this.stopHeartbeat();
306
+ this.clearReconnect();
307
+ this.clearProbe();
308
+ if (this.ws) {
309
+ this.ws.onclose = null;
310
+ this.ws.onopen = null;
311
+ this.ws.onerror = null;
312
+ this.ws.onmessage = null;
313
+ try { this.ws.close(); } catch { /* ignore */ }
314
+ this.ws = null;
315
+ }
316
+ if (this.options.reconnect && !this.intentionalClose) {
317
+ this.scheduleReconnect();
318
+ } else {
319
+ this.setState('disconnected');
320
+ }
321
+ }
322
+
323
+ // ---- Browser event listeners for proactive reconnection ----
324
+
325
+ private setupBrowserListeners() {
326
+ if (this.browserListenersActive) return;
327
+ this.browserListenersActive = true;
328
+ if (typeof document !== 'undefined') {
329
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
330
+ }
331
+ if (typeof window !== 'undefined') {
332
+ window.addEventListener('online', this.handleOnline);
333
+ }
334
+ }
335
+
336
+ private teardownBrowserListeners() {
337
+ if (!this.browserListenersActive) return;
338
+ this.browserListenersActive = false;
339
+ if (typeof document !== 'undefined') {
340
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
341
+ }
342
+ if (typeof window !== 'undefined') {
343
+ window.removeEventListener('online', this.handleOnline);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Send a ping and arm a short timeout. If no frame arrives within 5s the
349
+ * connection is assumed dead and force-reconnected. Any received frame
350
+ * (including the pong) cancels the timer via clearProbe() in handleFrame.
351
+ */
352
+ private probeConnection() {
353
+ if (this.probeTimer) return; // don't re-arm an active probe
354
+ this.send({ type: WS_EVENTS.PING, data: { ts: Date.now() } });
355
+ this.probeTimer = setTimeout(() => {
356
+ this.forceReconnect();
357
+ }, 5_000);
358
+ }
359
+
360
+ private clearProbe() {
361
+ if (this.probeTimer) {
362
+ clearTimeout(this.probeTimer);
363
+ this.probeTimer = null;
364
+ }
365
+ }
366
+
367
+ /** Tab returned to foreground — verify connection or accelerate reconnect. */
368
+ private handleVisibilityChange = () => {
369
+ if (typeof document !== 'undefined' && document.hidden) return;
370
+
371
+ if (this._state === 'connected') {
372
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
373
+ this.forceReconnect();
374
+ } else {
375
+ // Probe liveness — do NOT reset lastReceivedAt here; a zombie socket
376
+ // may still report OPEN. The 5s probe timeout will catch it quickly
377
+ // instead of deferring to the next heartbeat cycle (~60s).
378
+ this.probeConnection();
379
+ }
380
+ } else if (this._state === 'reconnecting') {
381
+ // Skip remaining backoff — try immediately
382
+ this.clearReconnect();
383
+ this.reconnectAttempts = 0;
384
+ this.connect();
385
+ }
386
+ };
387
+
388
+ /** Network restored — accelerate reconnection. */
389
+ private handleOnline = () => {
390
+ if (this.intentionalClose) return;
391
+
392
+ if (this._state === 'connected') {
393
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
394
+ this.forceReconnect();
395
+ } else {
396
+ this.probeConnection();
397
+ }
398
+ } else if (this._state === 'reconnecting') {
399
+ this.clearReconnect();
400
+ this.reconnectAttempts = 0;
401
+ this.connect();
402
+ }
403
+ };
404
+
275
405
  private setState(state: WsConnectionState) {
276
406
  this._state = state;
277
407
  for (const listener of this.stateListeners) {