@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 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
- const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempt), 60000);
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
- return delay;
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.transport.send(this.codec.encodeEnvelope(envelope));
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.transport.send(this.codec.encodeEnvelope(envelope));
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
- if (this.shouldReconnect) {
1699
- const delay = this.reconnectDelayMs();
1700
- this.log('ws_reconnect_scheduled', { attempt: this.reconnectAttempt, delayMs: delay });
1701
- this.emitEvent({ type: 'RECONNECTING' });
1702
- this.reconnectTimer = setTimeout(() => {
1703
- this.connectWebSocket().then(() => {
1704
- this.reconnectAttempt = 0;
1705
- }).catch(() => {
1706
- this.emitEvent({ type: 'ERROR', reason: 'RECONNECT_FAILED' });
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.0";
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.0';
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "DropOnAir SDK for end-to-end encrypted messaging",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",