@droponair/sdk-js 0.23.0 → 0.23.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/CHANGELOG.md +9 -0
- package/dist/core/messaging-client.d.ts +11 -0
- package/dist/core/messaging-client.js +86 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.23.1], 2026-06-01
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **WebSocket reconnect storm / eviction loop.** Reconnect is now single-flight: each connect attempt claims a generation, and a superseded socket's `onOpen`/`onClose`/`onError` callbacks are ignored. A stale socket closing (notably the old socket the server evicts with close code `4000 REPLACED_BY_SAME_DEVICE` right after our own reconnect) no longer nulls the live transport or schedules another reconnect, which was causing rapid connect/disconnect flapping. The client also no longer reconnects on close code `4000` (another connection is authoritative), reconnect timers never stack, and backoff gained jitter.
|
|
14
|
+
- **`Cannot read properties of null (reading 'send')` on send.** `sendMessage`'s open-check happens before a long async encryption phase during which the socket can close. Sends now go through a guarded `sendFrame` that re-checks the captured transport and throws a clean, retryable "not connected" error instead of dereferencing null.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
9
18
|
## [0.23.0], 2026-06-01
|
|
10
19
|
|
|
11
20
|
### Added
|
|
@@ -23,6 +23,7 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
23
23
|
private deviceId;
|
|
24
24
|
private rateLimited;
|
|
25
25
|
private reconnectAttempt;
|
|
26
|
+
private connectGeneration;
|
|
26
27
|
private handlingJwtExpiry;
|
|
27
28
|
private proactiveRefreshTimer;
|
|
28
29
|
private visibilityChangeHandler;
|
|
@@ -41,6 +42,8 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
41
42
|
/** Pending joinRoom resolver (Feature 3.1), keyed by roomId. */
|
|
42
43
|
private readonly pendingRoomJoins;
|
|
43
44
|
private reconnectDelayMs;
|
|
45
|
+
/** Schedule a single reconnect, replacing any pending one (never stack timers). */
|
|
46
|
+
private scheduleReconnect;
|
|
44
47
|
/**
|
|
45
48
|
* Register a document visibilitychange listener so that when the iOS app
|
|
46
49
|
* returns from background we can immediately assess the JWT state.
|
|
@@ -105,6 +108,14 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
105
108
|
}): Promise<{
|
|
106
109
|
messageId: string;
|
|
107
110
|
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Send raw frame bytes with a fresh open-check on a captured transport ref.
|
|
113
|
+
* sendMessage's open-check happens before a long async encryption phase; the
|
|
114
|
+
* socket can close during it. Re-checking here (and letting the transport's own
|
|
115
|
+
* guard throw cleanly) avoids `Cannot read properties of null (reading 'send')`
|
|
116
|
+
* and instead throws a retryable "not connected" the app can handle.
|
|
117
|
+
*/
|
|
118
|
+
private sendFrame;
|
|
108
119
|
ack(messageId: string): Promise<void>;
|
|
109
120
|
onMessage(callback: MessageCallback): () => void;
|
|
110
121
|
onEvent(callback: EventCallback): () => void;
|
|
@@ -12,10 +12,28 @@ const attachment_client_1 = require("../attachment/attachment-client");
|
|
|
12
12
|
const STORAGE_DEVICE_ID = 'droponair.device.id.v1';
|
|
13
13
|
class MessagingClient {
|
|
14
14
|
reconnectDelayMs() {
|
|
15
|
-
// Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s (capped)
|
|
16
|
-
|
|
15
|
+
// Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, 60s (capped), +/-20%.
|
|
16
|
+
// Jitter avoids many clients reconnecting in lockstep after a server blip.
|
|
17
|
+
const base = Math.min(2000 * Math.pow(2, this.reconnectAttempt), 60000);
|
|
17
18
|
this.reconnectAttempt++;
|
|
18
|
-
|
|
19
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
20
|
+
return Math.max(1000, Math.round(base + jitter));
|
|
21
|
+
}
|
|
22
|
+
/** Schedule a single reconnect, replacing any pending one (never stack timers). */
|
|
23
|
+
scheduleReconnect() {
|
|
24
|
+
if (this.reconnectTimer) {
|
|
25
|
+
clearTimeout(this.reconnectTimer);
|
|
26
|
+
this.reconnectTimer = null;
|
|
27
|
+
}
|
|
28
|
+
const delay = this.reconnectDelayMs();
|
|
29
|
+
this.log('ws_reconnect_scheduled', { attempt: this.reconnectAttempt, delayMs: delay });
|
|
30
|
+
this.emitEvent({ type: 'RECONNECTING' });
|
|
31
|
+
this.reconnectTimer = setTimeout(() => {
|
|
32
|
+
this.reconnectTimer = null;
|
|
33
|
+
this.connectWebSocket().catch(() => {
|
|
34
|
+
this.emitEvent({ type: 'ERROR', reason: 'RECONNECT_FAILED' });
|
|
35
|
+
});
|
|
36
|
+
}, delay);
|
|
19
37
|
}
|
|
20
38
|
/**
|
|
21
39
|
* Register a document visibilitychange listener so that when the iOS app
|
|
@@ -206,6 +224,13 @@ class MessagingClient {
|
|
|
206
224
|
this.deviceId = null;
|
|
207
225
|
this.rateLimited = false;
|
|
208
226
|
this.reconnectAttempt = 0;
|
|
227
|
+
// Monotonic id for each connect attempt. Transport callbacks (onOpen/onError/
|
|
228
|
+
// onClose) check this against the live generation and ignore themselves if a
|
|
229
|
+
// newer attempt has superseded them. This makes connect single-flight and
|
|
230
|
+
// prevents a stale socket's close (e.g. the old socket evicted with code 4000
|
|
231
|
+
// after our own reconnect) from nulling the live transport and scheduling
|
|
232
|
+
// another reconnect, which was the eviction/flap loop.
|
|
233
|
+
this.connectGeneration = 0;
|
|
209
234
|
this.handlingJwtExpiry = false;
|
|
210
235
|
this.proactiveRefreshTimer = null;
|
|
211
236
|
this.visibilityChangeHandler = null;
|
|
@@ -385,7 +410,7 @@ class MessagingClient {
|
|
|
385
410
|
if (options?.attachments && options.attachments.length > 0) {
|
|
386
411
|
envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
|
|
387
412
|
}
|
|
388
|
-
this.
|
|
413
|
+
this.sendFrame(this.codec.encodeEnvelope(envelope));
|
|
389
414
|
}
|
|
390
415
|
else {
|
|
391
416
|
// Legacy fallback: peer has no device keys (old client)
|
|
@@ -408,10 +433,24 @@ class MessagingClient {
|
|
|
408
433
|
if (options?.attachments && options.attachments.length > 0) {
|
|
409
434
|
envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
|
|
410
435
|
}
|
|
411
|
-
this.
|
|
436
|
+
this.sendFrame(this.codec.encodeEnvelope(envelope));
|
|
412
437
|
}
|
|
413
438
|
return { messageId };
|
|
414
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Send raw frame bytes with a fresh open-check on a captured transport ref.
|
|
442
|
+
* sendMessage's open-check happens before a long async encryption phase; the
|
|
443
|
+
* socket can close during it. Re-checking here (and letting the transport's own
|
|
444
|
+
* guard throw cleanly) avoids `Cannot read properties of null (reading 'send')`
|
|
445
|
+
* and instead throws a retryable "not connected" the app can handle.
|
|
446
|
+
*/
|
|
447
|
+
sendFrame(bytes) {
|
|
448
|
+
const t = this.transport;
|
|
449
|
+
if (!t || !t.isOpen()) {
|
|
450
|
+
throw new Error('DropOnAir websocket is not connected');
|
|
451
|
+
}
|
|
452
|
+
t.send(bytes);
|
|
453
|
+
}
|
|
415
454
|
async ack(messageId) {
|
|
416
455
|
if (!this.transport?.isOpen()) {
|
|
417
456
|
return;
|
|
@@ -1653,7 +1692,20 @@ class MessagingClient {
|
|
|
1653
1692
|
this.logError('ws_connect_jwt_expired', this.jwtSummary(this.dropOnAirJwt));
|
|
1654
1693
|
throw new Error('DropOnAir JWT expired before websocket connect');
|
|
1655
1694
|
}
|
|
1695
|
+
// Single-flight: claim a generation for this attempt. Any socket/callbacks
|
|
1696
|
+
// from an older attempt are now stale and must be ignored.
|
|
1697
|
+
const myGen = ++this.connectGeneration;
|
|
1656
1698
|
const transport = await this.resolveTransport(this.dropOnAirJwt);
|
|
1699
|
+
// A newer connect attempt started while we were exchanging the token; abandon
|
|
1700
|
+
// this one so we never run two live sockets for the same device.
|
|
1701
|
+
if (myGen !== this.connectGeneration) {
|
|
1702
|
+
this.log('ws_connect_superseded_pre_open', { myGen, current: this.connectGeneration });
|
|
1703
|
+
try {
|
|
1704
|
+
transport.close();
|
|
1705
|
+
}
|
|
1706
|
+
catch { /* ignore */ }
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1657
1709
|
this.log('ws_connect_start', {
|
|
1658
1710
|
transport: this.resolvedTransportName,
|
|
1659
1711
|
wsUrl: this.wsUrl,
|
|
@@ -1663,9 +1715,19 @@ class MessagingClient {
|
|
|
1663
1715
|
await new Promise((resolve, reject) => {
|
|
1664
1716
|
let opened = false;
|
|
1665
1717
|
transport.onOpen(() => {
|
|
1718
|
+
// If superseded between resolve and open, drop this stale socket.
|
|
1719
|
+
if (myGen !== this.connectGeneration) {
|
|
1720
|
+
try {
|
|
1721
|
+
transport.close();
|
|
1722
|
+
}
|
|
1723
|
+
catch { /* ignore */ }
|
|
1724
|
+
resolve();
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1666
1727
|
this.transport = transport;
|
|
1667
1728
|
opened = true;
|
|
1668
1729
|
this.rateLimited = false;
|
|
1730
|
+
this.reconnectAttempt = 0; // healthy connection resets backoff
|
|
1669
1731
|
this.log('ws_connected', { transport: this.resolvedTransportName, wsUrl: this.wsUrl });
|
|
1670
1732
|
this.emitEvent({ type: 'CONNECTED' });
|
|
1671
1733
|
if (this.dropOnAirJwt) {
|
|
@@ -1678,12 +1740,22 @@ class MessagingClient {
|
|
|
1678
1740
|
resolve();
|
|
1679
1741
|
});
|
|
1680
1742
|
transport.onError((err) => {
|
|
1743
|
+
if (myGen !== this.connectGeneration) {
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1681
1746
|
this.logError('ws_error', { wsUrl: this.wsUrl, error: err.message });
|
|
1682
1747
|
if (!opened) {
|
|
1683
1748
|
reject(new Error('Failed to connect DropOnAir websocket'));
|
|
1684
1749
|
}
|
|
1685
1750
|
});
|
|
1686
1751
|
transport.onClose((info) => {
|
|
1752
|
+
// Stale close from a superseded socket (e.g. the old socket evicted with
|
|
1753
|
+
// code 4000 after our own reconnect). Ignore it: do NOT null the live
|
|
1754
|
+
// transport and do NOT schedule a reconnect, that was the flap loop.
|
|
1755
|
+
if (myGen !== this.connectGeneration) {
|
|
1756
|
+
this.log('ws_stale_close_ignored', { code: info.code, myGen, current: this.connectGeneration });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1687
1759
|
this.transport = null;
|
|
1688
1760
|
this.handlingJwtExpiry = false;
|
|
1689
1761
|
if (this.proactiveRefreshTimer) {
|
|
@@ -1695,17 +1767,15 @@ class MessagingClient {
|
|
|
1695
1767
|
this.emitEvent({ type: 'ERROR', reason: 'JWT_EXPIRED' });
|
|
1696
1768
|
}
|
|
1697
1769
|
this.emitEvent({ type: 'DISCONNECTED' });
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
});
|
|
1708
|
-
}, delay);
|
|
1770
|
+
// 4000 = the server superseded this session (REPLACED_BY_SAME_DEVICE /
|
|
1771
|
+
// MAX_SESSIONS_EXCEEDED). Another connection is authoritative; do not
|
|
1772
|
+
// reconnect or we would fight it in an eviction loop.
|
|
1773
|
+
const supersededByOtherSession = info.code === 4000;
|
|
1774
|
+
if (this.shouldReconnect && !supersededByOtherSession) {
|
|
1775
|
+
this.scheduleReconnect();
|
|
1776
|
+
}
|
|
1777
|
+
else if (supersededByOtherSession) {
|
|
1778
|
+
this.log('ws_superseded_no_reconnect', { code: info.code, reason: info.reason });
|
|
1709
1779
|
}
|
|
1710
1780
|
});
|
|
1711
1781
|
transport.onFrame(async (bytes) => {
|
package/dist/version.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* MINOR, additive feature (e.g. multi-device payloads, new call event type)
|
|
8
8
|
* PATCH, bug-fix / perf improvement with no wire or API change
|
|
9
9
|
*/
|
|
10
|
-
export declare const SDK_VERSION = "0.23.
|
|
10
|
+
export declare const SDK_VERSION = "0.23.1";
|
|
11
11
|
/**
|
|
12
12
|
* Binary encrypted-payload format version.
|
|
13
13
|
* Included as the first byte of every encrypted payload so receivers can
|
package/dist/version.js
CHANGED
|
@@ -10,7 +10,7 @@ exports.PROTOCOL_VERSION = exports.PAYLOAD_FORMAT_VERSION = exports.SDK_VERSION
|
|
|
10
10
|
* MINOR, additive feature (e.g. multi-device payloads, new call event type)
|
|
11
11
|
* PATCH, bug-fix / perf improvement with no wire or API change
|
|
12
12
|
*/
|
|
13
|
-
exports.SDK_VERSION = '0.23.
|
|
13
|
+
exports.SDK_VERSION = '0.23.1';
|
|
14
14
|
/**
|
|
15
15
|
* Binary encrypted-payload format version.
|
|
16
16
|
* Included as the first byte of every encrypted payload so receivers can
|