@droponair/sdk-js 0.19.0 → 0.22.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.
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MessagingClient = void 0;
4
4
  const bytes_1 = require("./bytes");
5
5
  const protobuf_codec_1 = require("../transport/protobuf-codec");
6
+ const auto_select_1 = require("../transport/auto-select");
7
+ const ws_messaging_transport_1 = require("../transport/ws-messaging-transport");
8
+ const sse_messaging_transport_1 = require("../transport/sse-messaging-transport");
9
+ const wt_messaging_transport_1 = require("../transport/wt-messaging-transport");
6
10
  const version_1 = require("../version");
7
11
  const attachment_client_1 = require("../attachment/attachment-client");
8
12
  const STORAGE_DEVICE_ID = 'droponair.device.id.v1';
@@ -56,7 +60,7 @@ class MessagingClient {
56
60
  */
57
61
  onAppForeground() {
58
62
  this.log('app_foreground_detected', {
59
- wsState: this.ws?.readyState,
63
+ wsState: (this.transport?.isOpen() ? 'OPEN' : 'CLOSED'),
60
64
  shouldReconnect: this.shouldReconnect,
61
65
  ...this.jwtSummary(this.dropOnAirJwt),
62
66
  });
@@ -65,21 +69,21 @@ class MessagingClient {
65
69
  }
66
70
  // If the WebSocket is already gone (background disconnect), the existing
67
71
  // onclose handler will already be scheduling a reconnect, nothing to do.
68
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
72
+ if (!this.transport?.isOpen()) {
69
73
  return;
70
74
  }
71
75
  const jwt = this.dropOnAirJwt;
72
76
  if (!jwt) {
73
77
  // No token at all, close and let the reconnect loop fetch a new one.
74
78
  this.log('foreground_reconnect_no_jwt');
75
- this.ws.close(1000, 'FOREGROUND_NO_JWT');
79
+ this.transport.close(1000, 'FOREGROUND_NO_JWT');
76
80
  return;
77
81
  }
78
82
  if ((0, bytes_1.isJwtExpired)(jwt)) {
79
83
  // Token already expired while we were in background, close NOW so the
80
84
  // reconnect fetches a fresh token before the user tries to send anything.
81
85
  this.log('foreground_reconnect_jwt_expired', this.jwtSummary(jwt));
82
- this.ws.close(1000, 'FOREGROUND_JWT_EXPIRED');
86
+ this.transport.close(1000, 'FOREGROUND_JWT_EXPIRED');
83
87
  return;
84
88
  }
85
89
  // Token is still valid, reschedule the proactive timer based on actual
@@ -113,7 +117,7 @@ class MessagingClient {
113
117
  this.log('proactive_refresh_scheduled', { exp, secondsUntilExpiry, rotateInSeconds });
114
118
  this.proactiveRefreshTimer = setTimeout(async () => {
115
119
  this.proactiveRefreshTimer = null;
116
- if (!this.shouldReconnect || !this.ws) {
120
+ if (!this.shouldReconnect || !this.transport) {
117
121
  return;
118
122
  }
119
123
  this.log('proactive_refresh_triggered', { exp });
@@ -125,8 +129,8 @@ class MessagingClient {
125
129
  this.logError('proactive_refresh_token_failed', { error: String(err?.message ?? err) });
126
130
  }
127
131
  // Gracefully close the current connection; onclose will schedule reconnect.
128
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
129
- this.ws.close(1000, 'PROACTIVE_TOKEN_REFRESH');
132
+ if (this.transport?.isOpen()) {
133
+ this.transport.close(1000, 'PROACTIVE_TOKEN_REFRESH');
130
134
  }
131
135
  }, rotateInSeconds * 1000);
132
136
  }
@@ -193,7 +197,8 @@ class MessagingClient {
193
197
  this.sessionManager = sessionManager;
194
198
  this.storage = storage;
195
199
  this.codec = new protobuf_codec_1.ProtobufCodec();
196
- this.ws = null;
200
+ this.transport = null;
201
+ this.resolvedTransportName = null;
197
202
  this.shouldReconnect = true;
198
203
  this.reconnectTimer = null;
199
204
  this.dropOnAirJwt = null;
@@ -308,14 +313,14 @@ class MessagingClient {
308
313
  clearTimeout(this.proactiveRefreshTimer);
309
314
  this.proactiveRefreshTimer = null;
310
315
  }
311
- if (this.ws) {
312
- this.ws.close();
313
- this.ws = null;
316
+ if (this.transport) {
317
+ this.transport.close();
318
+ this.transport = null;
314
319
  }
315
320
  this.emitEvent({ type: 'DISCONNECTED' });
316
321
  }
317
322
  async sendMessage(toUserId, plaintextMessage, options) {
318
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
323
+ if (!this.transport?.isOpen()) {
319
324
  throw new Error('DropOnAir websocket is not connected');
320
325
  }
321
326
  if (!this.currentUserId) {
@@ -377,7 +382,7 @@ class MessagingClient {
377
382
  if (options?.attachments && options.attachments.length > 0) {
378
383
  envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
379
384
  }
380
- this.ws.send(this.codec.encodeEnvelope(envelope));
385
+ this.transport.send(this.codec.encodeEnvelope(envelope));
381
386
  }
382
387
  else {
383
388
  // Legacy fallback: peer has no device keys (old client)
@@ -400,15 +405,15 @@ class MessagingClient {
400
405
  if (options?.attachments && options.attachments.length > 0) {
401
406
  envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
402
407
  }
403
- this.ws.send(this.codec.encodeEnvelope(envelope));
408
+ this.transport.send(this.codec.encodeEnvelope(envelope));
404
409
  }
405
410
  return { messageId };
406
411
  }
407
412
  async ack(messageId) {
408
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
413
+ if (!this.transport?.isOpen()) {
409
414
  return;
410
415
  }
411
- this.ws.send(this.codec.encodeAck({ messageId, type: 'PROCESSED' }));
416
+ this.transport.send(this.codec.encodeAck({ messageId, type: 'PROCESSED' }));
412
417
  }
413
418
  onMessage(callback) {
414
419
  this.messageListeners.add(callback);
@@ -439,7 +444,7 @@ class MessagingClient {
439
444
  // Message edit and delete (PROTOCOL_VERSION 3+)
440
445
  // ---------------------------------------------------------------------------
441
446
  async editMessage(originalMessageId, toUserId, newPlaintext) {
442
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
447
+ if (!this.transport?.isOpen()) {
443
448
  throw new Error('DropOnAir websocket is not connected');
444
449
  }
445
450
  if (!this.currentUserId) {
@@ -496,11 +501,11 @@ class MessagingClient {
496
501
  senderDeviceId: myDeviceId,
497
502
  clientEditId: editId,
498
503
  };
499
- this.ws.send(this.codec.encodeMessageEditFrame(frame));
504
+ this.transport.send(this.codec.encodeMessageEditFrame(frame));
500
505
  return { editId };
501
506
  }
502
507
  async editCleartextMessage(originalMessageId, toUserId, newPlaintext) {
503
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
508
+ if (!this.transport?.isOpen()) {
504
509
  throw new Error('DropOnAir websocket is not connected');
505
510
  }
506
511
  if (!this.currentUserId) {
@@ -522,11 +527,11 @@ class MessagingClient {
522
527
  plaintextPayload: newPlaintext,
523
528
  clientEditId: editId,
524
529
  };
525
- this.ws.send(this.codec.encodeMessageEditFrame(frame));
530
+ this.transport.send(this.codec.encodeMessageEditFrame(frame));
526
531
  return { editId };
527
532
  }
528
533
  async deleteMessage(originalMessageId, toUserId, scope) {
529
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
534
+ if (!this.transport?.isOpen()) {
530
535
  throw new Error('DropOnAir websocket is not connected');
531
536
  }
532
537
  if (!this.currentUserId) {
@@ -547,7 +552,7 @@ class MessagingClient {
547
552
  scope,
548
553
  clientDeleteId: deleteId,
549
554
  };
550
- this.ws.send(this.codec.encodeMessageDeleteFrame(frame));
555
+ this.transport.send(this.codec.encodeMessageDeleteFrame(frame));
551
556
  return { deleteId };
552
557
  }
553
558
  // ---------------------------------------------------------------------------
@@ -567,7 +572,7 @@ class MessagingClient {
567
572
  * stored token and refreshes its lastSeenAt timestamp.
568
573
  */
569
574
  async registerPushToken(opts) {
570
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
575
+ if (!this.transport?.isOpen()) {
571
576
  throw new Error('DropOnAir websocket is not connected');
572
577
  }
573
578
  if (!opts.token || opts.token.trim().length === 0) {
@@ -581,14 +586,14 @@ class MessagingClient {
581
586
  voipToken: opts.voipToken ?? '',
582
587
  deviceId,
583
588
  };
584
- this.ws.send(this.codec.encodePushRegistrationFrame(frame));
589
+ this.transport.send(this.codec.encodePushRegistrationFrame(frame));
585
590
  }
586
591
  /**
587
592
  * Unregister this device's push notification token (e.g. on logout). Future
588
593
  * push fan-out for this (appId, userId, deviceId, platform) tuple is dropped.
589
594
  */
590
595
  async unregisterPushToken(opts) {
591
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
596
+ if (!this.transport?.isOpen()) {
592
597
  throw new Error('DropOnAir websocket is not connected');
593
598
  }
594
599
  const deviceId = this.deviceId ?? await this.getOrCreateDeviceId();
@@ -599,7 +604,7 @@ class MessagingClient {
599
604
  voipToken: '',
600
605
  deviceId,
601
606
  };
602
- this.ws.send(this.codec.encodePushRegistrationFrame(frame));
607
+ this.transport.send(this.codec.encodePushRegistrationFrame(frame));
603
608
  }
604
609
  // ---------------------------------------------------------------------------
605
610
  // Device trust (PROTOCOL_VERSION 5+ / Phase 2b)
@@ -650,8 +655,8 @@ class MessagingClient {
650
655
  clearTimeout(this.reconnectTimer);
651
656
  this.reconnectTimer = null;
652
657
  }
653
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
654
- this.ws.close(4001, 'DEVICE_REVOKED');
658
+ if (this.transport?.isOpen()) {
659
+ this.transport.close(4001, 'DEVICE_REVOKED');
655
660
  }
656
661
  }
657
662
  // ---------------------------------------------------------------------------
@@ -671,7 +676,7 @@ class MessagingClient {
671
676
  * other devices so they can bucket the receipt
672
677
  */
673
678
  markRead(messageId, conversationId) {
674
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
679
+ if (!this.transport?.isOpen()) {
675
680
  throw new Error('DropOnAir websocket is not connected');
676
681
  }
677
682
  const frame = {
@@ -680,7 +685,7 @@ class MessagingClient {
680
685
  conversationId: conversationId ?? '',
681
686
  timestamp: Date.now(),
682
687
  };
683
- this.ws.send(this.codec.encodeSyncFrame(frame));
688
+ this.transport.send(this.codec.encodeSyncFrame(frame));
684
689
  }
685
690
  /**
686
691
  * Tell this user's other devices that the notification(s) for a
@@ -689,7 +694,7 @@ class MessagingClient {
689
694
  * the matching badge. The relay never decides what "dismissed" means.
690
695
  */
691
696
  clearNotification(conversationId) {
692
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
697
+ if (!this.transport?.isOpen()) {
693
698
  throw new Error('DropOnAir websocket is not connected');
694
699
  }
695
700
  const frame = {
@@ -698,7 +703,7 @@ class MessagingClient {
698
703
  conversationId,
699
704
  timestamp: Date.now(),
700
705
  };
701
- this.ws.send(this.codec.encodeSyncFrame(frame));
706
+ this.transport.send(this.codec.encodeSyncFrame(frame));
702
707
  }
703
708
  /**
704
709
  * Push the current draft text for a conversation to this user's other
@@ -708,7 +713,7 @@ class MessagingClient {
708
713
  * feature is disabled the server silently drops the frame.
709
714
  */
710
715
  syncDraft(conversationId, draftText) {
711
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
716
+ if (!this.transport?.isOpen()) {
712
717
  throw new Error('DropOnAir websocket is not connected');
713
718
  }
714
719
  const frame = {
@@ -718,7 +723,7 @@ class MessagingClient {
718
723
  timestamp: Date.now(),
719
724
  payload: draftText,
720
725
  };
721
- this.ws.send(this.codec.encodeSyncFrame(frame));
726
+ this.transport.send(this.codec.encodeSyncFrame(frame));
722
727
  }
723
728
  /** Register a listener for notification-clear syncs from the user's other devices. */
724
729
  onNotificationCleared(callback) {
@@ -775,7 +780,7 @@ class MessagingClient {
775
780
  // Cleartext messaging (no E2EE key exchange required)
776
781
  // ---------------------------------------------------------------------------
777
782
  async sendCleartextMessage(toUserId, plaintext) {
778
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
783
+ if (!this.transport?.isOpen()) {
779
784
  throw new Error('DropOnAir websocket is not connected');
780
785
  }
781
786
  if (!this.currentUserId) {
@@ -799,7 +804,7 @@ class MessagingClient {
799
804
  encryptionType: 1, // CLEARTEXT
800
805
  plaintextPayload: plaintext,
801
806
  };
802
- this.ws.send(this.codec.encodeEnvelope(envelope));
807
+ this.transport.send(this.codec.encodeEnvelope(envelope));
803
808
  return { messageId };
804
809
  }
805
810
  // ---------------------------------------------------------------------------
@@ -826,7 +831,7 @@ class MessagingClient {
826
831
  }
827
832
  }
828
833
  async publishBroadcast(channelId, plaintext) {
829
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
834
+ if (!this.transport?.isOpen()) {
830
835
  throw new Error('DropOnAir websocket is not connected');
831
836
  }
832
837
  if (!this.currentUserId) {
@@ -847,7 +852,7 @@ class MessagingClient {
847
852
  plaintextPayload: plaintext,
848
853
  sequenceNumber: 0,
849
854
  };
850
- this.ws.send(this.codec.encodeBroadcastFrame(frame));
855
+ this.transport.send(this.codec.encodeBroadcastFrame(frame));
851
856
  return { broadcastId };
852
857
  }
853
858
  onBroadcast(callback) {
@@ -1060,12 +1065,12 @@ class MessagingClient {
1060
1065
  * call joinRoom again).
1061
1066
  */
1062
1067
  joinRoom(roomId) {
1063
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1068
+ if (!this.transport?.isOpen()) {
1064
1069
  return Promise.reject(new Error('DropOnAir websocket is not connected'));
1065
1070
  }
1066
1071
  return new Promise((resolve, reject) => {
1067
1072
  this.pendingRoomJoins.set(roomId, { resolve, reject });
1068
- this.ws.send(this.codec.encodeGroupCallFrame({
1073
+ this.transport.send(this.codec.encodeGroupCallFrame({
1069
1074
  type: 'GROUP_CALL_JOIN',
1070
1075
  callId: '',
1071
1076
  groupId: '',
@@ -1092,7 +1097,7 @@ class MessagingClient {
1092
1097
  * {@link sendCleartextGroupMessage} instead.
1093
1098
  */
1094
1099
  async sendGroupMessage(groupId, plaintext, memberUserIds, options) {
1095
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1100
+ if (!this.transport?.isOpen()) {
1096
1101
  throw new Error('DropOnAir websocket is not connected');
1097
1102
  }
1098
1103
  if (!this.currentUserId) {
@@ -1170,7 +1175,7 @@ class MessagingClient {
1170
1175
  if (options?.attachments && options.attachments.length > 0) {
1171
1176
  frame.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
1172
1177
  }
1173
- this.ws.send(this.codec.encodeGroupEnvelope(frame));
1178
+ this.transport.send(this.codec.encodeGroupEnvelope(frame));
1174
1179
  return { messageId };
1175
1180
  }
1176
1181
  /**
@@ -1182,7 +1187,7 @@ class MessagingClient {
1182
1187
  * per-recipient before sending.
1183
1188
  */
1184
1189
  async sendCleartextGroupMessage(groupId, plaintext) {
1185
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1190
+ if (!this.transport?.isOpen()) {
1186
1191
  throw new Error('DropOnAir websocket is not connected');
1187
1192
  }
1188
1193
  if (!this.currentUserId) {
@@ -1209,7 +1214,7 @@ class MessagingClient {
1209
1214
  plaintextPayload: plaintext,
1210
1215
  memberPayloads: [],
1211
1216
  };
1212
- this.ws.send(this.codec.encodeGroupEnvelope(frame));
1217
+ this.transport.send(this.codec.encodeGroupEnvelope(frame));
1213
1218
  return { messageId };
1214
1219
  }
1215
1220
  onGroupMessage(callback) {
@@ -1220,14 +1225,14 @@ class MessagingClient {
1220
1225
  // Group call signaling
1221
1226
  // ---------------------------------------------------------------------------
1222
1227
  startGroupCall(groupId) {
1223
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1228
+ if (!this.transport?.isOpen()) {
1224
1229
  return Promise.reject(new Error('DropOnAir websocket is not connected'));
1225
1230
  }
1226
1231
  return new Promise((resolve, reject) => {
1227
1232
  this.pendingGroupInviteResolve = resolve;
1228
1233
  this.pendingGroupInviteReject = reject;
1229
1234
  const frame = { type: 'GROUP_CALL_INVITE', callId: '', groupId };
1230
- this.ws.send(this.codec.encodeGroupCallFrame(frame));
1235
+ this.transport.send(this.codec.encodeGroupCallFrame(frame));
1231
1236
  });
1232
1237
  }
1233
1238
  async joinGroupCall(callId, groupId) {
@@ -1382,10 +1387,10 @@ class MessagingClient {
1382
1387
  this.sendGroupCallFrame({ type: 'GROUP_CALL_RECORDING_AVAILABLE', callId, groupId: '', payload: location });
1383
1388
  }
1384
1389
  sendGroupCallFrame(frame) {
1385
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1390
+ if (!this.transport?.isOpen()) {
1386
1391
  throw new Error('DropOnAir websocket is not connected');
1387
1392
  }
1388
- this.ws.send(this.codec.encodeGroupCallFrame(frame));
1393
+ this.transport.send(this.codec.encodeGroupCallFrame(frame));
1389
1394
  }
1390
1395
  /**
1391
1396
  * Initiate an outgoing call.
@@ -1393,14 +1398,14 @@ class MessagingClient {
1393
1398
  * once the server echoes back CALL_RINGING (which contains the real callId).
1394
1399
  */
1395
1400
  startCall(targetUserId) {
1396
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1401
+ if (!this.transport?.isOpen()) {
1397
1402
  return Promise.reject(new Error('DropOnAir websocket is not connected'));
1398
1403
  }
1399
1404
  return new Promise((resolve, reject) => {
1400
1405
  this.pendingInviteResolve = resolve;
1401
1406
  this.pendingInviteReject = reject;
1402
1407
  const frame = { type: 'CALL_INVITE', targetUserId };
1403
- this.ws.send(this.codec.encodeCallFrame(frame));
1408
+ this.transport.send(this.codec.encodeCallFrame(frame));
1404
1409
  });
1405
1410
  }
1406
1411
  async acceptCall(callId) {
@@ -1437,10 +1442,10 @@ class MessagingClient {
1437
1442
  return response.json();
1438
1443
  }
1439
1444
  sendCallFrame(frame) {
1440
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1445
+ if (!this.transport?.isOpen()) {
1441
1446
  throw new Error('DropOnAir websocket is not connected');
1442
1447
  }
1443
- this.ws.send(this.codec.encodeCallFrame(frame));
1448
+ this.transport.send(this.codec.encodeCallFrame(frame));
1444
1449
  }
1445
1450
  emitCallEvent(wire) {
1446
1451
  // Resolve a pending startCall() promise when CALL_RINGING arrives.
@@ -1598,6 +1603,44 @@ class MessagingClient {
1598
1603
  listener(event);
1599
1604
  }
1600
1605
  }
1606
+ async resolveTransport(jwt) {
1607
+ const selection = this.options.transport ?? 'ws';
1608
+ let chosen = 'ws';
1609
+ if (selection === 'auto') {
1610
+ try {
1611
+ const lane = await (0, auto_select_1.selectTransport)({ httpUrl: this.httpUrl, fetchFn: this.fetchFn });
1612
+ chosen = lane === 'webtransport' ? 'wt' : lane === 'sse' ? 'sse' : 'ws';
1613
+ }
1614
+ catch (err) {
1615
+ this.logError('transport_auto_select_failed', { error: String(err?.message ?? err) });
1616
+ chosen = 'ws';
1617
+ }
1618
+ }
1619
+ else {
1620
+ chosen = selection;
1621
+ }
1622
+ this.resolvedTransportName = chosen;
1623
+ if (chosen === 'sse') {
1624
+ return new sse_messaging_transport_1.SseMessagingTransport({
1625
+ httpUrl: this.httpUrl,
1626
+ getJwt: async () => jwt,
1627
+ fetchFn: this.fetchFn,
1628
+ });
1629
+ }
1630
+ if (chosen === 'wt') {
1631
+ return new wt_messaging_transport_1.WtMessagingTransport({
1632
+ httpUrl: this.httpUrl,
1633
+ getJwt: async () => jwt,
1634
+ });
1635
+ }
1636
+ const myDeviceId = this.deviceId;
1637
+ let wsUrlWithParams = `${this.wsUrl}?token=${encodeURIComponent(jwt)}`;
1638
+ if (myDeviceId)
1639
+ wsUrlWithParams += `&deviceId=${encodeURIComponent(myDeviceId)}`;
1640
+ wsUrlWithParams += `&sdkVersion=${encodeURIComponent(version_1.SDK_VERSION)}`;
1641
+ wsUrlWithParams += `&protocolVersion=${version_1.PROTOCOL_VERSION}`;
1642
+ return new ws_messaging_transport_1.WebSocketMessagingTransport(wsUrlWithParams);
1643
+ }
1601
1644
  async connectWebSocket() {
1602
1645
  // Only exchange a fresh token when the cached one is absent or expired/near-expiry.
1603
1646
  // forceRefresh=true on every reconnect caused a token-exchange call every 2 s.
@@ -1607,28 +1650,21 @@ class MessagingClient {
1607
1650
  this.logError('ws_connect_jwt_expired', this.jwtSummary(this.dropOnAirJwt));
1608
1651
  throw new Error('DropOnAir JWT expired before websocket connect');
1609
1652
  }
1653
+ const transport = await this.resolveTransport(this.dropOnAirJwt);
1610
1654
  this.log('ws_connect_start', {
1655
+ transport: this.resolvedTransportName,
1611
1656
  wsUrl: this.wsUrl,
1612
1657
  currentUserId: this.currentUserId,
1613
1658
  ...this.jwtSummary(this.dropOnAirJwt),
1614
1659
  });
1615
1660
  await new Promise((resolve, reject) => {
1616
- const myDeviceId = this.deviceId;
1617
- let wsUrlWithParams = `${this.wsUrl}?token=${encodeURIComponent(this.dropOnAirJwt)}`;
1618
- if (myDeviceId) {
1619
- wsUrlWithParams += `&deviceId=${encodeURIComponent(myDeviceId)}`;
1620
- }
1621
- wsUrlWithParams += `&sdkVersion=${encodeURIComponent(version_1.SDK_VERSION)}`;
1622
- wsUrlWithParams += `&protocolVersion=${version_1.PROTOCOL_VERSION}`;
1623
- const ws = new WebSocket(wsUrlWithParams);
1624
- ws.binaryType = 'arraybuffer';
1625
- ws.onopen = () => {
1626
- this.ws = ws;
1661
+ let opened = false;
1662
+ transport.onOpen(() => {
1663
+ this.transport = transport;
1664
+ opened = true;
1627
1665
  this.rateLimited = false;
1628
- this.log('ws_connected', { wsUrl: this.wsUrl });
1666
+ this.log('ws_connected', { transport: this.resolvedTransportName, wsUrl: this.wsUrl });
1629
1667
  this.emitEvent({ type: 'CONNECTED' });
1630
- // Schedule proactive token rotation before this JWT expires so we never
1631
- // send a message on a token the server will immediately reject.
1632
1668
  if (this.dropOnAirJwt) {
1633
1669
  this.scheduleProactiveTokenRefresh(this.dropOnAirJwt);
1634
1670
  }
@@ -1637,24 +1673,22 @@ class MessagingClient {
1637
1673
  this.emitEvent({ type: 'ERROR', reason: 'OFFLINE_FETCH_FAILED' });
1638
1674
  });
1639
1675
  resolve();
1640
- };
1641
- ws.onerror = (event) => {
1642
- this.logError('ws_error', { wsUrl: this.wsUrl, eventType: event?.type });
1643
- reject(new Error('Failed to connect DropOnAir websocket'));
1644
- };
1645
- ws.onclose = (event) => {
1646
- this.ws = null;
1676
+ });
1677
+ transport.onError((err) => {
1678
+ this.logError('ws_error', { wsUrl: this.wsUrl, error: err.message });
1679
+ if (!opened) {
1680
+ reject(new Error('Failed to connect DropOnAir websocket'));
1681
+ }
1682
+ });
1683
+ transport.onClose((info) => {
1684
+ this.transport = null;
1647
1685
  this.handlingJwtExpiry = false;
1648
- // Cancel any pending proactive refresh, we are already disconnecting.
1649
1686
  if (this.proactiveRefreshTimer) {
1650
1687
  clearTimeout(this.proactiveRefreshTimer);
1651
1688
  this.proactiveRefreshTimer = null;
1652
1689
  }
1653
- // When the server closes with 1008 it means the JWT was expired at the
1654
- // time our frame arrived. Emit an ERROR event first so any message that
1655
- // was sent just before the disconnect (and is therefore stuck in PENDING)
1656
- // gets marked FAILED and shown with a retry button in the UI.
1657
- if (event.code === 1008) {
1690
+ // 1008 = server-side JWT expiry; surface so PENDING sends can FAIL.
1691
+ if (info.code === 1008) {
1658
1692
  this.emitEvent({ type: 'ERROR', reason: 'JWT_EXPIRED' });
1659
1693
  }
1660
1694
  this.emitEvent({ type: 'DISCONNECTED' });
@@ -1664,19 +1698,15 @@ class MessagingClient {
1664
1698
  this.emitEvent({ type: 'RECONNECTING' });
1665
1699
  this.reconnectTimer = setTimeout(() => {
1666
1700
  this.connectWebSocket().then(() => {
1667
- // Successful reconnect, reset backoff counter.
1668
1701
  this.reconnectAttempt = 0;
1669
1702
  }).catch(() => {
1670
1703
  this.emitEvent({ type: 'ERROR', reason: 'RECONNECT_FAILED' });
1671
1704
  });
1672
1705
  }, delay);
1673
1706
  }
1674
- };
1675
- ws.onmessage = async (event) => {
1676
- if (!(event.data instanceof ArrayBuffer)) {
1677
- return;
1678
- }
1679
- const frame = this.codec.decodeFrame(new Uint8Array(event.data));
1707
+ });
1708
+ transport.onFrame(async (bytes) => {
1709
+ const frame = this.codec.decodeFrame(bytes);
1680
1710
  if (frame.kind === 'event') {
1681
1711
  if (frame.data.type === 'LIMIT_REACHED') {
1682
1712
  this.rateLimited = true;
@@ -1693,10 +1723,10 @@ class MessagingClient {
1693
1723
  this.handlingJwtExpiry = true;
1694
1724
  this.log('ws_jwt_expired_received', {
1695
1725
  metadata: frame.data.metadata,
1696
- wsState: this.ws?.readyState,
1726
+ wsState: (this.transport?.isOpen() ? 'OPEN' : 'CLOSED'),
1697
1727
  });
1698
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1699
- this.ws.close(4001, 'JWT_EXPIRED');
1728
+ if (this.transport?.isOpen()) {
1729
+ this.transport.close(4001, 'JWT_EXPIRED');
1700
1730
  }
1701
1731
  }
1702
1732
  }
@@ -1752,7 +1782,13 @@ class MessagingClient {
1752
1782
  if (frame.kind === 'envelope') {
1753
1783
  await this.handleIncomingEnvelope(frame.data);
1754
1784
  }
1755
- };
1785
+ });
1786
+ transport.connect().catch((err) => {
1787
+ if (!opened) {
1788
+ this.logError('ws_error', { wsUrl: this.wsUrl, error: String(err?.message ?? err) });
1789
+ reject(err instanceof Error ? err : new Error(String(err)));
1790
+ }
1791
+ });
1756
1792
  });
1757
1793
  }
1758
1794
  async handleIncomingMessageEdit(frame) {
@@ -276,6 +276,14 @@ export interface InitializeOptions {
276
276
  * Set false to let app decide when a message is actually seen/read and call `ack(messageId)` manually.
277
277
  */
278
278
  autoAckIncomingMessages?: boolean;
279
+ /**
280
+ * Signaling transport lane. WebSocket (`'ws'`) is the default and works on
281
+ * every network. `'sse'` uses HTTP fallback for restrictive corporate
282
+ * networks. `'wt'` uses WebTransport (HTTP/3) where browsers support it.
283
+ * `'auto'` queries `GET /api/info.transports` and picks the best available
284
+ * lane from the runtime. The platform never enforces a choice.
285
+ */
286
+ transport?: 'ws' | 'sse' | 'wt' | 'auto';
279
287
  }
280
288
  /** A registered device as returned by {@link DropOnAirClient.listMyDevices}. */
281
289
  export interface DeviceInfo {
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare function initialize(options: InitializeOptions): Promise<DropOnAi
4
4
  export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, Room, RoomPolicy, CreateRoomOptions, UpdateRoomOptions, SfuToken, SfuRecording, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, DeviceInfo, ReadReceiptEvent, ReadReceiptCallback, NotificationClearEvent, NotificationClearCallback, DraftSyncEvent, DraftSyncCallback, } from './core/types';
5
5
  export type { AttachmentEncryptionType, AttachmentConversationType, AttachmentRef, DeviceWrappedKey, CreateUploadSessionOptions, UploadSession, PrepareAttachmentOptions, DownloadedAttachment, } from './attachment/attachment-types';
6
6
  export { SseTransport, type SseTransportOptions, type SseFrameHandler, type SseStateHandler, } from './transport/sse-transport';
7
+ export { WebTransportTransport, type WebTransportTransportOptions, type WTFrameHandler, type WTStateHandler, } from './transport/webtransport-transport';
8
+ export { selectTransport, type TransportLane, type SelectTransportOptions, } from './transport/auto-select';
7
9
  declare const _default: {
8
10
  initialize: typeof initialize;
9
11
  };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SseTransport = exports.PAYLOAD_FORMAT_VERSION = exports.PROTOCOL_VERSION = exports.SDK_VERSION = void 0;
3
+ exports.selectTransport = exports.WebTransportTransport = exports.SseTransport = exports.PAYLOAD_FORMAT_VERSION = exports.PROTOCOL_VERSION = exports.SDK_VERSION = void 0;
4
4
  exports.initialize = initialize;
5
5
  const messaging_client_1 = require("./core/messaging-client");
6
6
  const session_manager_1 = require("./core/session-manager");
@@ -29,4 +29,10 @@ async function initialize(options) {
29
29
  // Phase 4.2.1: HTTP fallback lane primitive for restrictive networks.
30
30
  var sse_transport_1 = require("./transport/sse-transport");
31
31
  Object.defineProperty(exports, "SseTransport", { enumerable: true, get: function () { return sse_transport_1.SseTransport; } });
32
+ // Phase 4.3: WebTransport (HTTP/3) lane primitive.
33
+ var webtransport_transport_1 = require("./transport/webtransport-transport");
34
+ Object.defineProperty(exports, "WebTransportTransport", { enumerable: true, get: function () { return webtransport_transport_1.WebTransportTransport; } });
35
+ // Phase 4.6: transport auto-select - intersects runtime + platform support.
36
+ var auto_select_1 = require("./transport/auto-select");
37
+ Object.defineProperty(exports, "selectTransport", { enumerable: true, get: function () { return auto_select_1.selectTransport; } });
32
38
  exports.default = { initialize };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Transport auto-select (Phase 4.6).
3
+ *
4
+ * Tiny helper that probes which transport lanes the runtime AND the platform
5
+ * BOTH support, and returns the best one for this client/host pair.
6
+ *
7
+ * Preference order (best -> fallback):
8
+ * 1. webtransport - multi-stream, lower head-of-line blocking
9
+ * 2. websocket - universal default
10
+ * 3. sse - HTTP-only fallback for restrictive networks
11
+ *
12
+ * The function fetches `<httpUrl>/api/info` once, intersects its
13
+ * `transports` array with the runtime's capabilities, and returns the
14
+ * highest-preference match. Caller then instantiates the corresponding
15
+ * primitive (`WebTransportTransport` / WebSocket / `SseTransport`) and
16
+ * uses it as their lane.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { selectTransport, WebTransportTransport, SseTransport } from '@droponair/sdk-js';
21
+ * const lane = await selectTransport({ httpUrl: 'https://sdk.droponair.com' });
22
+ * switch (lane) {
23
+ * case 'webtransport': // construct WebTransportTransport break;
24
+ * case 'sse': // construct SseTransport break;
25
+ * default: // fall through to WebSocket
26
+ * }
27
+ * ```
28
+ */
29
+ export type TransportLane = 'webtransport' | 'websocket' | 'sse';
30
+ export interface SelectTransportOptions {
31
+ /** Base URL of the platform's REST API (no trailing slash). */
32
+ httpUrl: string;
33
+ /**
34
+ * Override the default WT -> WS -> SSE preference order. Useful for tests
35
+ * or when a specific lane is preferred (e.g. force `sse` first on a known
36
+ * firewalled network).
37
+ */
38
+ preference?: TransportLane[];
39
+ /** Custom fetch impl, useful for Node polyfills + test doubles. */
40
+ fetchFn?: typeof fetch;
41
+ }
42
+ /**
43
+ * Returns the highest-preference transport lane available on BOTH the
44
+ * runtime and the platform. Throws if no lane is supported (extremely
45
+ * unusual; WebSocket is the universal floor).
46
+ */
47
+ export declare function selectTransport(options: SelectTransportOptions): Promise<TransportLane>;