@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 +17 -0
- package/dist/core/messaging-client.d.ts +12 -0
- package/dist/core/messaging-client.js +90 -17
- package/dist/core/types.d.ts +7 -0
- 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,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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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) => {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|