@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/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +102 -5
- package/dist/constants.d.ts +16 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +19 -1
- package/dist/types.d.ts +93 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/ws.d.ts +19 -0
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +133 -1
- package/package.json +1 -1
- package/src/client.ts +130 -6
- package/src/constants.ts +24 -1
- package/src/types.ts +118 -22
- package/src/ws.ts +131 -1
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
|
|
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) {
|