@droponair/sdk-js 0.26.0 → 0.28.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/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.28.0
4
+
5
+ ### Fixed
6
+
7
+ - **A push token offered before the client connected was thrown away.** The push
8
+ service hands an app its device token at launch, always before a connection
9
+ exists, so `registerPushToken` refusing it there lost the token exactly when
10
+ every app first offers it, and push simply never arrived. The token is now held
11
+ and registered as soon as a connection exists. It is also re-registered on every
12
+ connect, because the relay ties a token to a session: after a reconnect or a
13
+ relay restart the device would otherwise be silently unreachable.
14
+
15
+ ### Notes
16
+
17
+ - `registerPushToken` no longer rejects a call made before connecting. Existing
18
+ code that catches that error still compiles and simply stops seeing it.
19
+ - `unregisterPushToken` drops the held token, so it is not re-registered later.
20
+
21
+ ## 0.27.0
22
+
23
+ ### Added
24
+
25
+ - **Group messages sent while you were offline are now delivered.** On connect the SDK
26
+ drains anything it missed and hands each message to `onGroupMessage` exactly as if it
27
+ had arrived live, so a message sent to a closed or disconnected app is no longer lost.
28
+ Nothing to call: it happens on every connect, beside the one-to-one drain.
29
+ - **Delivery receipts on group messages name the member they are about.** The group
30
+ acknowledgement now carries `memberUserId`. A group message has many recipients, so
31
+ delivery is a set rather than a flag, and the platform hands you the set rather than
32
+ collapsing it: show a single tick once everyone has it, or a read-by list, whichever
33
+ your app wants. `memberUserId` is empty on `SERVER_RECEIVED`, which is about the relay
34
+ rather than any member.
35
+
36
+ ### Notes
37
+
38
+ - Group messages were always stored for absent members but nothing ever returned them,
39
+ so they were silently lost and `DELIVERED` could never arrive. Both are fixed.
40
+ - Additive and backward compatible. An older SDK simply never asks for the missed
41
+ messages and behaves exactly as it does today; a relay that predates this answers
42
+ 404 and the SDK treats that as nothing to catch up on.
43
+
3
44
  ## 0.26.0
4
45
 
5
46
  ### Added
package/README.md CHANGED
@@ -207,6 +207,10 @@ client.onEvent(e => {
207
207
 
208
208
  Available since SDK `0.9.0`. Customers configure their own APNs / FCM / VAPID credentials in the dashboard. The platform delivers a push only when the sender attaches a `pushPayload` to a message and the recipient device has no live WebSocket session. See the per-product availability and limits on your dashboard's Subscription page.
209
209
 
210
+
211
+ Register the token as soon as you have it. The SDK holds it until a connection
212
+ exists and registers it then, and re-registers on every connect, so there is no
213
+ need to wait for the client to connect or to retry yourself.
210
214
  ```typescript
211
215
  // iOS, after didRegisterForRemoteNotificationsWithDeviceToken
212
216
  await client.registerPushToken({ platform: 'APNS', token: deviceTokenHex });
@@ -359,6 +363,17 @@ client.onMessage(async (msg) => {
359
363
  | `sendCleartextGroupMessage(groupId, plaintext)` | `Promise<{ messageId }>` | Send a plaintext (non-encrypted) group message; server fans out to every other member. |
360
364
  | `onGroupMessage(callback)` | `() => void` | Listen for group messages |
361
365
 
366
+ **Group delivery is per member.** A group message has many recipients, so the platform
367
+ reports delivery as a set rather than a single flag and leaves the presentation to you.
368
+ `DELIVERED` and `SEEN` arrive as events whose `metadata` carries `messageId`, `groupId`
369
+ and `memberUserId`, the member the acknowledgement is about. Show one tick once the set
370
+ covers the group, or a read-by list, whichever suits your app. `memberUserId` is absent
371
+ on `SERVER_RECEIVED`, which is about the relay rather than any member.
372
+
373
+ **Group messages missed while offline arrive on connect.** Anything sent while the app
374
+ was closed or disconnected is drained automatically when it reconnects and delivered
375
+ through `onGroupMessage` just like a live message. There is nothing to call.
376
+
362
377
  ### 1-to-1 Calls
363
378
 
364
379
  | Method | Returns | Description |
@@ -22,6 +22,11 @@ export declare class MessagingClient implements DropOnAirClient {
22
22
  private dropOnAirJwt;
23
23
  private currentUserId;
24
24
  private deviceId;
25
+ /**
26
+ * The push token this device last offered, held until a connection exists and
27
+ * re-sent on every connect. Cleared by `unregisterPushToken`.
28
+ */
29
+ private pendingPushRegistration;
25
30
  private rateLimited;
26
31
  private reconnectAttempt;
27
32
  private connectGeneration;
@@ -169,6 +174,8 @@ export declare class MessagingClient implements DropOnAirClient {
169
174
  token: string;
170
175
  voipToken?: string;
171
176
  }): Promise<void>;
177
+ /** Registers the held token, if there is one. Called on every connect. */
178
+ private sendPushRegistration;
172
179
  /**
173
180
  * Unregister this device's push notification token (e.g. on logout). Future
174
181
  * push fan-out for this (appId, userId, deviceId, platform) tuple is dropped.
@@ -374,6 +381,14 @@ export declare class MessagingClient implements DropOnAirClient {
374
381
  */
375
382
  private fetchMyOtherDeviceKeys;
376
383
  private ensureIdentityPublished;
384
+ /**
385
+ * Group messages that arrived while this device had no connection.
386
+ *
387
+ * Without this a group message is delivered only to sessions that happen to be
388
+ * live at that instant and is unreachable afterwards, so anyone closed loses it.
389
+ * Fetching is also what tells the sender the message reached this member.
390
+ */
391
+ private fetchAndProcessOfflineGroupMessages;
377
392
  private fetchAndProcessOfflineMessages;
378
393
  private getValidDropOnAirJwt;
379
394
  private extractSubject;
@@ -246,6 +246,11 @@ class MessagingClient {
246
246
  this.dropOnAirJwt = null;
247
247
  this.currentUserId = null;
248
248
  this.deviceId = null;
249
+ /**
250
+ * The push token this device last offered, held until a connection exists and
251
+ * re-sent on every connect. Cleared by `unregisterPushToken`.
252
+ */
253
+ this.pendingPushRegistration = null;
249
254
  this.rateLimited = false;
250
255
  this.reconnectAttempt = 0;
251
256
  // Monotonic id for each connect attempt. Transport callbacks (onOpen/onError/
@@ -650,18 +655,36 @@ class MessagingClient {
650
655
  * stored token and refreshes its lastSeenAt timestamp.
651
656
  */
652
657
  async registerPushToken(opts) {
653
- if (!this.transport?.isOpen()) {
654
- throw new Error('DropOnAir websocket is not connected');
655
- }
656
658
  if (!opts.token || opts.token.trim().length === 0) {
657
659
  throw new Error('Push token must not be empty');
658
660
  }
659
- const deviceId = this.deviceId ?? await this.getOrCreateDeviceId();
660
- const frame = {
661
- type: 'PUSH_REGISTER',
661
+ // The push service hands an app its token at startup, usually before a
662
+ // connection exists, so refusing the registration here would lose it exactly
663
+ // when every app first offers it. Hold it instead and send it on connect, and
664
+ // keep holding it: the relay ties a token to a session, so a reconnect or a
665
+ // relay restart has to re-register or the device goes quiet.
666
+ this.pendingPushRegistration = {
662
667
  platform: opts.platform,
663
668
  token: opts.token,
664
669
  voipToken: opts.voipToken ?? '',
670
+ };
671
+ if (!this.transport?.isOpen()) {
672
+ return;
673
+ }
674
+ await this.sendPushRegistration();
675
+ }
676
+ /** Registers the held token, if there is one. Called on every connect. */
677
+ async sendPushRegistration() {
678
+ const pending = this.pendingPushRegistration;
679
+ if (!pending || !this.transport?.isOpen()) {
680
+ return;
681
+ }
682
+ const deviceId = this.deviceId ?? await this.getOrCreateDeviceId();
683
+ const frame = {
684
+ type: 'PUSH_REGISTER',
685
+ platform: pending.platform,
686
+ token: pending.token,
687
+ voipToken: pending.voipToken,
665
688
  deviceId,
666
689
  };
667
690
  this.transport.send(this.codec.encodePushRegistrationFrame(frame));
@@ -671,6 +694,7 @@ class MessagingClient {
671
694
  * push fan-out for this (appId, userId, deviceId, platform) tuple is dropped.
672
695
  */
673
696
  async unregisterPushToken(opts) {
697
+ this.pendingPushRegistration = null;
674
698
  if (!this.transport?.isOpen()) {
675
699
  throw new Error('DropOnAir websocket is not connected');
676
700
  }
@@ -1779,6 +1803,12 @@ class MessagingClient {
1779
1803
  if (this.dropOnAirJwt) {
1780
1804
  this.scheduleProactiveTokenRefresh(this.dropOnAirJwt);
1781
1805
  }
1806
+ this.sendPushRegistration().catch((err) => {
1807
+ this.logError('push_registration_failed', { error: String(err) });
1808
+ });
1809
+ this.fetchAndProcessOfflineGroupMessages().catch((err) => {
1810
+ this.logError('group_offline_fetch_failed', { error: String(err) });
1811
+ });
1782
1812
  this.fetchAndProcessOfflineMessages().catch((err) => {
1783
1813
  this.logError('offline_fetch_failed', { error: String(err?.message ?? err) });
1784
1814
  this.emitEvent({ type: 'ERROR', reason: 'OFFLINE_FETCH_FAILED' });
@@ -1887,7 +1917,14 @@ class MessagingClient {
1887
1917
  if (frame.kind === 'groupAck') {
1888
1918
  this.emitEvent({
1889
1919
  type: frame.data.type,
1890
- metadata: JSON.stringify({ messageId: frame.data.messageId, groupId: frame.data.groupId }),
1920
+ // memberUserId says which member this concerns. A group message has
1921
+ // many recipients, so an acknowledgement without it cannot be turned
1922
+ // into anything more useful than a single tick.
1923
+ metadata: JSON.stringify({
1924
+ messageId: frame.data.messageId,
1925
+ groupId: frame.data.groupId,
1926
+ ...(frame.data.memberUserId ? { memberUserId: frame.data.memberUserId } : {}),
1927
+ }),
1891
1928
  });
1892
1929
  return;
1893
1930
  }
@@ -2233,6 +2270,58 @@ class MessagingClient {
2233
2270
  }
2234
2271
  this.log('key_publish_ok', { url, status: response.status, deviceId: myDeviceId });
2235
2272
  }
2273
+ /**
2274
+ * Group messages that arrived while this device had no connection.
2275
+ *
2276
+ * Without this a group message is delivered only to sessions that happen to be
2277
+ * live at that instant and is unreachable afterwards, so anyone closed loses it.
2278
+ * Fetching is also what tells the sender the message reached this member.
2279
+ */
2280
+ async fetchAndProcessOfflineGroupMessages() {
2281
+ const jwt = await this.getValidDropOnAirJwt(false);
2282
+ let page = 0;
2283
+ let totalPages = 1;
2284
+ let totalProcessed = 0;
2285
+ this.log('group_offline_fetch_started', { httpUrl: this.httpUrl, pageSize: 100 });
2286
+ while (page < totalPages) {
2287
+ const response = await this.fetchFn(`${this.httpUrl}/v1/groups/messages/offline?page=${page}&size=100`, {
2288
+ method: 'GET',
2289
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
2290
+ });
2291
+ if (!response.ok) {
2292
+ // A relay that predates this endpoint answers 404. That is not an error
2293
+ // worth surfacing: the SDK simply has nothing to catch up on there.
2294
+ if (response.status !== 404) {
2295
+ this.logError('group_offline_fetch_page_failed', { page, status: response.status });
2296
+ }
2297
+ return;
2298
+ }
2299
+ const body = await response.json();
2300
+ totalPages = body.totalPages ?? 0;
2301
+ for (const offline of body.messages) {
2302
+ const notification = {
2303
+ messageId: offline.messageId,
2304
+ groupId: offline.groupId,
2305
+ fromUserId: offline.fromUserId,
2306
+ timestamp: new Date(offline.createdAt).getTime(),
2307
+ encryptionType: offline.encryptionType === 'CLEARTEXT' ? 1 : 0,
2308
+ plaintextPayload: offline.plaintextPayload,
2309
+ senderDeviceId: offline.senderDeviceId,
2310
+ devicePayloads: (offline.devicePayloads ?? []).map(dp => ({
2311
+ deviceId: dp.deviceId,
2312
+ encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
2313
+ senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64),
2314
+ })),
2315
+ };
2316
+ // The same path a live message takes, so decryption, de-duplication and
2317
+ // the callback behave identically.
2318
+ await this.handleIncomingGroupMessage(notification);
2319
+ totalProcessed += 1;
2320
+ }
2321
+ page += 1;
2322
+ }
2323
+ this.log('group_offline_fetch_done', { totalProcessed });
2324
+ }
2236
2325
  async fetchAndProcessOfflineMessages() {
2237
2326
  const jwt = await this.getValidDropOnAirJwt(false);
2238
2327
  let page = 0;
@@ -133,6 +133,12 @@ export interface WireGroupAck {
133
133
  messageId: string;
134
134
  groupId: string;
135
135
  type: string;
136
+ /**
137
+ * Which member the acknowledgement is about. A group message has many
138
+ * recipients, so the useful fact is which of them has it. Empty on
139
+ * SERVER_RECEIVED, which concerns the relay rather than any member.
140
+ */
141
+ memberUserId?: string;
136
142
  }
137
143
  export interface WireGroupCallFrame {
138
144
  type: string;
@@ -193,7 +193,8 @@ const GroupMessageNotificationType = new protobuf.Type('GroupMessageNotification
193
193
  const GroupAckType = new protobuf.Type('GroupAck')
194
194
  .add(new protobuf.Field('messageId', 1, 'string'))
195
195
  .add(new protobuf.Field('groupId', 2, 'string'))
196
- .add(new protobuf.Field('type', 3, 'string'));
196
+ .add(new protobuf.Field('type', 3, 'string'))
197
+ .add(new protobuf.Field('memberUserId', 4, 'string'));
197
198
  const GroupCallFrameType = new protobuf.Type('GroupCallFrame')
198
199
  .add(new protobuf.Field('type', 1, 'string'))
199
200
  .add(new protobuf.Field('callId', 2, 'string'))
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.26.0";
10
+ export declare const SDK_VERSION = "0.28.0";
11
11
  /**
12
12
  * Binary encrypted-payload format version.
13
13
  * Included as the first byte of every encrypted payload so receivers can
@@ -19,4 +19,4 @@ export declare const PAYLOAD_FORMAT_VERSION = 1;
19
19
  * Increment when adding required proto fields or changing frame semantics.
20
20
  * The server advertises its supported range via GET /api/info.
21
21
  */
22
- export declare const PROTOCOL_VERSION = 6;
22
+ export declare const PROTOCOL_VERSION = 7;
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.26.0';
13
+ exports.SDK_VERSION = '0.28.0';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
@@ -22,4 +22,4 @@ exports.PAYLOAD_FORMAT_VERSION = 1;
22
22
  * Increment when adding required proto fields or changing frame semantics.
23
23
  * The server advertises its supported range via GET /api/info.
24
24
  */
25
- exports.PROTOCOL_VERSION = 6;
25
+ exports.PROTOCOL_VERSION = 7;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "End-to-end encrypted messaging, voice and video calling SDK. The relay never sees your keys or message content.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",