@droponair/sdk-js 0.22.1 → 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,23 @@ 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
+
18
+ ## [0.23.0], 2026-06-01
19
+
20
+ ### Added
21
+
22
+ - **Idempotent retries via `sendMessage(..., { clientMessageId })`.** Pass a stable id to reuse across retries of the same logical message. The relay now dedups by `(appId, fromUserId, messageId)`, so re-sending after a lost ack or a reconnect delivers the message at most once instead of creating duplicates. Omit it for new messages and a fresh id is generated (unchanged default behavior). Requires relay support shipped alongside this release; wire format and `PROTOCOL_VERSION` are unchanged (the `clientMessageId` field already existed).
23
+
24
+ ---
25
+
9
26
  ## [0.22.1], 2026-05-31
10
27
 
11
28
  ### Fixed
@@ -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.
@@ -101,9 +104,18 @@ export declare class MessagingClient implements DropOnAirClient {
101
104
  disconnect(): void;
102
105
  sendMessage(toUserId: string, plaintextMessage: string, options?: {
103
106
  attachments?: AttachmentRef[];
107
+ clientMessageId?: string;
104
108
  }): Promise<{
105
109
  messageId: string;
106
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;
107
119
  ack(messageId: string): Promise<void>;
108
120
  onMessage(callback: MessageCallback): () => void;
109
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;
@@ -337,7 +362,10 @@ class MessagingClient {
337
362
  throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
338
363
  }
339
364
  const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
340
- const messageId = crypto.randomUUID();
365
+ // Reuse a caller-supplied id for idempotent retries: the relay dedups by
366
+ // (appId, fromUserId, messageId), so re-sending with the same id is a no-op
367
+ // delivery instead of a duplicate. Defaults to a fresh id for new messages.
368
+ const messageId = options?.clientMessageId ?? crypto.randomUUID();
341
369
  const timestamp = Date.now();
342
370
  const myIdentity = await this.cryptoService.getOrCreateIdentity();
343
371
  const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
@@ -382,7 +410,7 @@ class MessagingClient {
382
410
  if (options?.attachments && options.attachments.length > 0) {
383
411
  envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
384
412
  }
385
- this.transport.send(this.codec.encodeEnvelope(envelope));
413
+ this.sendFrame(this.codec.encodeEnvelope(envelope));
386
414
  }
387
415
  else {
388
416
  // Legacy fallback: peer has no device keys (old client)
@@ -405,10 +433,24 @@ class MessagingClient {
405
433
  if (options?.attachments && options.attachments.length > 0) {
406
434
  envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
407
435
  }
408
- this.transport.send(this.codec.encodeEnvelope(envelope));
436
+ this.sendFrame(this.codec.encodeEnvelope(envelope));
409
437
  }
410
438
  return { messageId };
411
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
+ }
412
454
  async ack(messageId) {
413
455
  if (!this.transport?.isOpen()) {
414
456
  return;
@@ -1650,7 +1692,20 @@ class MessagingClient {
1650
1692
  this.logError('ws_connect_jwt_expired', this.jwtSummary(this.dropOnAirJwt));
1651
1693
  throw new Error('DropOnAir JWT expired before websocket connect');
1652
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;
1653
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
+ }
1654
1709
  this.log('ws_connect_start', {
1655
1710
  transport: this.resolvedTransportName,
1656
1711
  wsUrl: this.wsUrl,
@@ -1660,9 +1715,19 @@ class MessagingClient {
1660
1715
  await new Promise((resolve, reject) => {
1661
1716
  let opened = false;
1662
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
+ }
1663
1727
  this.transport = transport;
1664
1728
  opened = true;
1665
1729
  this.rateLimited = false;
1730
+ this.reconnectAttempt = 0; // healthy connection resets backoff
1666
1731
  this.log('ws_connected', { transport: this.resolvedTransportName, wsUrl: this.wsUrl });
1667
1732
  this.emitEvent({ type: 'CONNECTED' });
1668
1733
  if (this.dropOnAirJwt) {
@@ -1675,12 +1740,22 @@ class MessagingClient {
1675
1740
  resolve();
1676
1741
  });
1677
1742
  transport.onError((err) => {
1743
+ if (myGen !== this.connectGeneration) {
1744
+ return;
1745
+ }
1678
1746
  this.logError('ws_error', { wsUrl: this.wsUrl, error: err.message });
1679
1747
  if (!opened) {
1680
1748
  reject(new Error('Failed to connect DropOnAir websocket'));
1681
1749
  }
1682
1750
  });
1683
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
+ }
1684
1759
  this.transport = null;
1685
1760
  this.handlingJwtExpiry = false;
1686
1761
  if (this.proactiveRefreshTimer) {
@@ -1692,17 +1767,15 @@ class MessagingClient {
1692
1767
  this.emitEvent({ type: 'ERROR', reason: 'JWT_EXPIRED' });
1693
1768
  }
1694
1769
  this.emitEvent({ type: 'DISCONNECTED' });
1695
- if (this.shouldReconnect) {
1696
- const delay = this.reconnectDelayMs();
1697
- this.log('ws_reconnect_scheduled', { attempt: this.reconnectAttempt, delayMs: delay });
1698
- this.emitEvent({ type: 'RECONNECTING' });
1699
- this.reconnectTimer = setTimeout(() => {
1700
- this.connectWebSocket().then(() => {
1701
- this.reconnectAttempt = 0;
1702
- }).catch(() => {
1703
- this.emitEvent({ type: 'ERROR', reason: 'RECONNECT_FAILED' });
1704
- });
1705
- }, 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 });
1706
1779
  }
1707
1780
  });
1708
1781
  transport.onFrame(async (bytes) => {
@@ -304,9 +304,16 @@ export interface DropOnAirClient {
304
304
  /**
305
305
  * Send a 1:1 E2EE message. Optionally attach one or more attachments
306
306
  * prepared via {@link prepareAttachmentAndUpload} or {@link createUploadSession}.
307
+ *
308
+ * Pass `options.clientMessageId` to reuse a stable id across retries: the relay
309
+ * dedups by `(appId, fromUserId, messageId)`, so re-sending a message with the
310
+ * same id delivers it at most once instead of creating a duplicate. Omit it for
311
+ * new messages and a fresh id is generated. The chosen id is returned as
312
+ * `messageId`.
307
313
  */
308
314
  sendMessage(toUserId: string, plaintextMessage: string, options?: {
309
315
  attachments?: import('../attachment/attachment-types').AttachmentRef[];
316
+ clientMessageId?: string;
310
317
  }): Promise<{
311
318
  messageId: string;
312
319
  }>;
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.22.1";
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.22.1';
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.22.1",
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",