@droponair/sdk-js 0.21.0 → 0.22.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,15 +6,36 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.22.1], 2026-05-31
10
+
11
+ ### Fixed
12
+
13
+ - **Message send/receive broken when attachments support is compiled in.** The protobuf reflection tree shared a single `AttachmentRef` type instance across `Envelope`, `GroupEnvelope`, and `GroupMessageNotification`. Because a protobufjs reflection object can have only one parent, adding it to a later message re-parented it and removed it from `Envelope`, so encoding any 1:1 message threw `no such Type or Enum 'AttachmentRef' in Type Envelope` before a frame was ever produced. Each message now builds its own `AttachmentRef` (and nested `DeviceWrappedKey` / `EncryptionType`) instance. Wire format is unchanged, this is purely a client-side construction fix. Also resolves the same latent failure on `GroupMessageNotification` decode.
14
+
15
+ ---
16
+
17
+ ## [0.22.0], 2026-05-25
18
+
19
+ ### Added
20
+
21
+ - **MessagingClient transport selection.** `initialize({ transport: 'ws' | 'sse' | 'wt' | 'auto' })` lets your app choose the signaling lane at init time. Default stays `'ws'`, so existing integrations are byte-identical. `'auto'` queries `GET /api/info.transports` and picks the best available lane from what the runtime supports. WebSocket close-code semantics (1008 JWT-expiry surfacing, 4001 JWT-refresh-on-event) are preserved on the WebSocket path; SSE/WT synthesize close events.
22
+ - **MQTT (IoT) broadcast lane.** Broadcasts now mirror to MQTT topic `droponair/v1/broadcast/{appId}/{channelId}` on `mqtts://mqtt.droponair.com:8883`. For IoT subscribers that consume your broadcasts without our full SDK. Subscribe-only, per-app credentials, TLS only. Visible via `GET /api/info` (`transports` includes `"mqtt"`, `features` includes `"transport_mqtt"`). No SDK API change, the lane is consumed directly by MQTT clients.
23
+
24
+ ### Changed
25
+
26
+ - `MessagingClient` now uses an internal `MessagingTransport` adapter rather than a direct `WebSocket` reference. WebSocket remains the default; no protocol change. Adapters: `WebSocketMessagingTransport` (default), `SseMessagingTransport`, `WtMessagingTransport`.
27
+
28
+ ---
29
+
9
30
  ## [0.21.0], 2026-05-24
10
31
 
11
32
  ### Added
12
33
 
13
- - **Transport auto-select helper (Phase 4.6).** New `selectTransport({ httpUrl })` reads `/api/info.transports`, intersects with the runtime's `WebTransport`/`WebSocket`/`EventSource` availability, and returns the best lane (preference: WebTransport -> WebSocket -> SSE; overridable via `preference`). Caller then constructs the corresponding primitive. Pluggable `fetchFn` for Node + tests.
34
+ - **Transport auto-select helper.** New `selectTransport({ httpUrl })` reads `/api/info.transports`, intersects with the runtime's `WebTransport`/`WebSocket`/`EventSource` availability, and returns the best lane (preference: WebTransport -> WebSocket -> SSE; overridable via `preference`). Caller then constructs the corresponding primitive. Pluggable `fetchFn` for Node + tests.
14
35
 
15
36
  ### Notes
16
37
 
17
- - Foundation for an `init({ transport: 'auto' })` shortcut once full `MessagingClient` integration lands (4.2.1b). Today it's a small standalone helper so existing customers can adopt the pattern without waiting.
38
+ - Foundation for an `init({ transport: 'auto' })` shortcut once full `MessagingClient` integration lands in a follow-up release. Today it's a small standalone helper so existing customers can adopt the pattern without waiting.
18
39
 
19
40
  ---
20
41
 
@@ -22,12 +43,12 @@ This project follows [Semantic Versioning](https://semver.org/).
22
43
 
23
44
  ### Added
24
45
 
25
- - **WebTransport (HTTP/3) transport primitive (Phase 4.3).** New `WebTransportTransport` class uses the browser-native `WebTransport` API to ride the platform's `/v1/transport/wt` HTTP/3 endpoint, terminated by the `droponair-webtransport` sidecar. Same shape as `SseTransport`: `onFrame`, `sendEnvelope`, `connect`, `close`. Caller brings own protobuf decoder. Browser-only for v1 (Chromium 97+, Firefox 125+, Safari TP-only); Node 22+ has experimental support behind flags.
46
+ - **WebTransport (HTTP/3) transport primitive.** New `WebTransportTransport` class uses the browser-native `WebTransport` API to ride the platform's `/v1/transport/wt` HTTP/3 endpoint,. Same shape as `SseTransport`: `onFrame`, `sendEnvelope`, `connect`, `close`. Caller brings own protobuf decoder. Browser-only for v1 (Chromium 97+, Firefox 125+, Safari TP-only); Node 22+ has experimental support behind flags.
26
47
 
27
48
  ### Notes
28
49
 
29
50
  - WebTransport gives multi-stream perf wins over WebSocket on flaky cellular and similar networks. WebSocket on `/ws` remains the default; this primitive is opt-in for environments that have native WebTransport.
30
- - The droponair.com endpoint only advertises `transports: ["webtransport"]` once the `droponair-webtransport` sidecar is up and `app.transport.webtransport-enabled` is flipped on sdk-be. Check `GET /api/info.transports` for the live list.
51
+ - The droponair.com endpoint only advertises `transports: ["webtransport"]` once the HTTP/3 lane is enabled and `app.transport.webtransport-enabled` is flipped on the platform. Check `GET /api/info.transports` for the live list.
31
52
 
32
53
  ---
33
54
 
@@ -35,11 +56,11 @@ This project follows [Semantic Versioning](https://semver.org/).
35
56
 
36
57
  ### Added
37
58
 
38
- - **SSE transport primitive (Phase 4.2.1).** New `SseTransport` class exposes the platform's HTTP-fallback lane (`/v1/transport/stream` + `/v1/transport/send`) as a small standalone client, for environments where WebSocket upgrades are blocked by corporate firewalls but HTTPS GET/POST work. EventSource-based receive (auto-reconnects), `fetch` POST for send. Bring your own protobuf decoder (use the codec already exported from the SDK).
59
+ - **SSE transport primitive.** New `SseTransport` class exposes the platform's HTTP-fallback lane (`/v1/transport/stream` + `/v1/transport/send`) as a small standalone client, for environments where WebSocket upgrades are blocked by corporate firewalls but HTTPS GET/POST work. EventSource-based receive (auto-reconnects), `fetch` POST for send. Bring your own protobuf decoder (use the codec already exported from the SDK).
39
60
 
40
61
  ### Notes
41
62
 
42
- - v1 is a primitive; full `init({ transport: 'sse' })` integration into `MessagingClient` lands in 4.2.1b. Customers who need the SSE lane today can use `SseTransport` directly alongside the existing WS client.
63
+ - v1 is a primitive; full `init({ transport: 'sse' })` integration into `MessagingClient` lands in a follow-up release. Customers who need the SSE lane today can use `SseTransport` directly alongside the existing WS client.
43
64
  - WebSocket on `/ws` remains the default for all SDK calls; this addition is purely opt-in. Older code paths and existing apps are unaffected.
44
65
 
45
66
  ---
@@ -48,7 +69,7 @@ This project follows [Semantic Versioning](https://semver.org/).
48
69
 
49
70
  ### Added
50
71
 
51
- - **SFU recording (Phase 3.7.5).** Server-side recording for SFU-mode rooms with `mediaEncryption: 'SFU'`. The platform's media server records the composite of every track in the room and uploads the finalized file directly to the customer's storage bucket - the platform never holds the recorded bytes. Storage destinations (S3 / GCS / Azure) are registered once in the panel; the SDK references each by an opaque `destinationId`.
72
+ - **SFU recording.** Server-side recording for SFU-mode rooms with `mediaEncryption: 'SFU'`. The platform's media server records the composite of every track in the room and uploads the finalized file directly to the customer's storage bucket - the platform never holds the recorded bytes. Storage destinations (S3 / GCS / Azure) are registered once in the panel; the SDK references each by an opaque `destinationId`.
52
73
  - Three new client methods:
53
74
  - `startSfuRecording(roomId, destinationId)` -> `SfuRecording`
54
75
  - `stopSfuRecording(roomId, recordingId)` -> `SfuRecording`
@@ -313,7 +334,6 @@ This project follows [Semantic Versioning](https://semver.org/).
313
334
  - Wire-level additive: legacy 0.4.x clients ignore the new `Envelope.attachments` field (proto3 forwards-compat). Existing apps continue to work unchanged.
314
335
  - v1 storage adapters supported by the server: S3-compatible (S3, R2, MinIO, B2, Wasabi), Google Cloud Storage, Azure Blob.
315
336
  - Plan limits: `maxAttachmentSizeMb` per file + `maxAttachmentsPerMonth`. FREE = attachments disabled. PRO = 25 MB / 1000 per month. GROWTH = 100 MB / 10000. PAYG = 250 MB / metered. ENTERPRISE = custom.
316
- - Requires sdk-be `0.5.0` server.
317
337
 
318
338
  ---
319
339
 
@@ -329,7 +349,6 @@ This project follows [Semantic Versioning](https://semver.org/).
329
349
  ### Notes
330
350
  - Each edit and each `FOR_EVERYONE` delete counts as **one** MESSAGE usage record (subject to plan quotas + rate limits).
331
351
  - Wire-level additive: legacy 0.3.x clients ignore unknown frame types (proto3 forwards-compat), so existing TwinFlame and CrowdSyncer apps continue to work unchanged.
332
- - Requires sdk-be `0.4.0` server.
333
352
 
334
353
  ---
335
354
 
package/README.md CHANGED
@@ -183,7 +183,7 @@ E2EE invariant: the push body is sender-supplied cleartext metadata only (e.g. "
183
183
 
184
184
  ### Message Edit & Delete
185
185
 
186
- Available since SDK `0.4.0` and requires sdk-be `0.4.0`.
186
+ Available since SDK `0.4.0`.
187
187
 
188
188
  ```typescript
189
189
  // Edit an encrypted message
@@ -213,7 +213,7 @@ client.onMessageDelete((del) => {
213
213
 
214
214
  ### Attachments (Customer-Managed Storage)
215
215
 
216
- Available since SDK `0.5.0` and requires sdk-be `0.5.0`. Configure your bucket once in the DropOnAir panel (S3-compatible, GCS, or Azure Blob). DropOnAir never holds file bytes - the SDK uploads/downloads directly against your bucket via short-lived presigned URLs.
216
+ Available since SDK `0.5.0`. Configure your bucket once in the DropOnAir panel (S3-compatible, GCS, or Azure Blob). DropOnAir never holds file bytes - the SDK uploads/downloads directly against your bucket via short-lived presigned URLs.
217
217
 
218
218
  ```typescript
219
219
  // Convenience: encrypt + upload + finalize in one call, then send with the message.
@@ -435,6 +435,23 @@ await wt.sendEnvelope(envelopeBytes);
435
435
 
436
436
  Uses the browser-native `WebTransport` API; opens a single bidirectional stream and rides raw bytes both ways. Browser support: Chromium 97+ (stable), Firefox 125+ (stable), Safari Technology Preview only, Node 22+ behind experimental flags. The `droponair.com` endpoint advertises `transports: ["webtransport"]` only when the `droponair-webtransport` sidecar is live; check `GET /api/info.transports` and `features.transport_webtransport` to detect availability. Bring your own protobuf decoder. WebSocket remains the default; this primitive is purely opt-in.
437
437
 
438
+ ### Transport auto-select
439
+
440
+ Available since SDK `0.21.0`. `selectTransport({ httpUrl })` queries `/api/info.transports`, intersects with what the runtime supports (`WebTransport` / `WebSocket` / `EventSource`), and returns the best lane for this client + platform pair. Default preference: WebTransport → WebSocket → SSE (overridable).
441
+
442
+ ```typescript
443
+ import { selectTransport, WebTransportTransport, SseTransport } from '@droponair/sdk-js';
444
+
445
+ const lane = await selectTransport({ httpUrl: 'https://sdk.droponair.com' });
446
+ switch (lane) {
447
+ case 'webtransport': /* new WebTransportTransport(...) */ break;
448
+ case 'sse': /* new SseTransport(...) */ break;
449
+ default: /* fall through to your WebSocket client */
450
+ }
451
+ ```
452
+
453
+ Foundation for an `init({ transport: 'auto' })` shortcut once full `MessagingClient` integration lands (a follow-up). Today it's a small standalone helper so existing customers can adopt the pattern without waiting.
454
+
438
455
  ### Call Recording
439
456
 
440
457
  Available since SDK `0.16.0`. The SDK **signals** recording state on a group or room call; your app does the actual media capture (`MediaRecorder`) and uploads the file to your own storage — the platform never holds the media. The recording signal is broadcast to every participant (including anyone who joins later); that transparency is enforced server-side.
@@ -14,7 +14,8 @@ export declare class MessagingClient implements DropOnAirClient {
14
14
  private readonly fetchFn;
15
15
  private readonly codec;
16
16
  private attachmentClient;
17
- private ws;
17
+ private transport;
18
+ private resolvedTransportName;
18
19
  private shouldReconnect;
19
20
  private reconnectTimer;
20
21
  private dropOnAirJwt;
@@ -324,6 +325,7 @@ export declare class MessagingClient implements DropOnAirClient {
324
325
  private emitBroadcast;
325
326
  private handleIncomingGroupMessage;
326
327
  private emitGroupCallEvent;
328
+ private resolveTransport;
327
329
  private connectWebSocket;
328
330
  private handleIncomingMessageEdit;
329
331
  private handleIncomingMessageDelete;
@@ -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 {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Internal transport interface used by `MessagingClient`. Abstracts the
3
+ * WebSocket, SSE-fallback, and WebTransport lanes behind a single
4
+ * lifecycle surface so the client can pick a lane at init time without
5
+ * the rest of the SDK caring which one is in use.
6
+ *
7
+ * Public consumers of the SDK continue to use the standalone primitives
8
+ * (`SseTransport`, `WebTransportTransport`); this interface is an internal
9
+ * adapter target only.
10
+ */
11
+ export interface MessagingTransportClose {
12
+ code: number;
13
+ reason: string;
14
+ }
15
+ export interface MessagingTransportEvents {
16
+ onOpen(handler: () => void): void;
17
+ onFrame(handler: (bytes: Uint8Array) => void): void;
18
+ onClose(handler: (info: MessagingTransportClose) => void): void;
19
+ onError(handler: (err: Error) => void): void;
20
+ }
21
+ export interface MessagingTransport extends MessagingTransportEvents {
22
+ /** Human-readable transport name, mirrors the `/api/info.transports` token. */
23
+ readonly name: 'ws' | 'sse' | 'wt';
24
+ /** Open the underlying stream. Resolves once frames can be sent and received. */
25
+ connect(): Promise<void>;
26
+ /** Send a raw protobuf frame (no framing of its own). */
27
+ send(bytes: Uint8Array): void;
28
+ /** True if the transport can accept `send()` right now. */
29
+ isOpen(): boolean;
30
+ /** Close the stream. Idempotent. `code`/`reason` are best-effort hints. */
31
+ close(code?: number, reason?: string): void;
32
+ }
33
+ export type MessagingTransportSelection = 'ws' | 'sse' | 'wt' | 'auto';
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -42,29 +42,38 @@ const DeviceEncryptedPayloadType = new protobuf.Type('DeviceEncryptedPayload')
42
42
  .add(new protobuf.Field('deviceId', 1, 'string'))
43
43
  .add(new protobuf.Field('encryptedPayload', 2, 'bytes'))
44
44
  .add(new protobuf.Field('senderPublicKey', 3, 'bytes'));
45
- // Per-device wrapped file key for E2EE attachments (PROTOCOL_VERSION 4+)
46
- const DeviceWrappedKeyType = new protobuf.Type('DeviceWrappedKey')
47
- .add(new protobuf.Field('deviceId', 1, 'string'))
48
- .add(new protobuf.Field('wrappedKey', 2, 'bytes'))
49
- .add(new protobuf.Field('senderPublicKey', 3, 'bytes'))
50
- .add(new protobuf.Field('nonce', 4, 'bytes'));
51
- // Reference to an out-of-band attachment in customer-managed storage.
52
- const AttachmentRefEncTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
53
- const AttachmentRefType = new protobuf.Type('AttachmentRef')
54
- .add(AttachmentRefEncTypeEnum)
55
- .add(DeviceWrappedKeyType)
56
- .add(new protobuf.Field('attachmentId', 1, 'string'))
57
- .add(new protobuf.Field('storageHint', 2, 'string'))
58
- .add(new protobuf.Field('mimeType', 3, 'string'))
59
- .add(new protobuf.Field('sizeBytes', 4, 'int64'))
60
- .add(new protobuf.Field('sha256', 5, 'string'))
61
- .add(new protobuf.Field('encryptionType', 6, 'EncryptionType'))
62
- .add(new protobuf.Field('wrappedKeys', 7, 'DeviceWrappedKey', 'repeated'))
63
- .add(new protobuf.Field('thumbnailAttachmentId', 8, 'string'));
45
+ // Reference to an out-of-band attachment in customer-managed storage, plus its
46
+ // nested per-device wrapped file key (PROTOCOL_VERSION 4+).
47
+ //
48
+ // A protobufjs reflection object can have only ONE parent: calling `.add(obj)`
49
+ // on a second message re-parents `obj` and silently removes it from the first.
50
+ // Envelope, GroupEnvelope and GroupMessageNotification each nest AttachmentRef,
51
+ // so each needs its OWN instance, built fresh here. Sharing a single instance
52
+ // across them re-parented it onto the last adder and orphaned it from Envelope,
53
+ // breaking every 1:1 send with "no such Type or Enum 'AttachmentRef' in Type
54
+ // Envelope". The EncryptionType enum and DeviceWrappedKey type are nested inside
55
+ // each AttachmentRef and so are rebuilt per call for the same reason.
56
+ function buildAttachmentRefType() {
57
+ return new protobuf.Type('AttachmentRef')
58
+ .add(new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 }))
59
+ .add(new protobuf.Type('DeviceWrappedKey')
60
+ .add(new protobuf.Field('deviceId', 1, 'string'))
61
+ .add(new protobuf.Field('wrappedKey', 2, 'bytes'))
62
+ .add(new protobuf.Field('senderPublicKey', 3, 'bytes'))
63
+ .add(new protobuf.Field('nonce', 4, 'bytes')))
64
+ .add(new protobuf.Field('attachmentId', 1, 'string'))
65
+ .add(new protobuf.Field('storageHint', 2, 'string'))
66
+ .add(new protobuf.Field('mimeType', 3, 'string'))
67
+ .add(new protobuf.Field('sizeBytes', 4, 'int64'))
68
+ .add(new protobuf.Field('sha256', 5, 'string'))
69
+ .add(new protobuf.Field('encryptionType', 6, 'EncryptionType'))
70
+ .add(new protobuf.Field('wrappedKeys', 7, 'DeviceWrappedKey', 'repeated'))
71
+ .add(new protobuf.Field('thumbnailAttachmentId', 8, 'string'));
72
+ }
64
73
  const EnvelopeType = new protobuf.Type('Envelope')
65
74
  .add(EncryptionTypeEnum)
66
75
  .add(DeviceEncryptedPayloadType) // nested type must be added first
67
- .add(AttachmentRefType)
76
+ .add(buildAttachmentRefType())
68
77
  .add(new protobuf.Field('messageId', 1, 'string'))
69
78
  .add(new protobuf.Field('appId', 2, 'string'))
70
79
  .add(new protobuf.Field('fromUserId', 3, 'string'))
@@ -128,7 +137,7 @@ const GroupEnvelopeEncryptionTypeEnum = new protobuf.Enum('EncryptionType', { E2
128
137
  const GroupEnvelopeType = new protobuf.Type('GroupEnvelope')
129
138
  .add(GroupEnvelopeEncryptionTypeEnum)
130
139
  .add(GroupMemberPayloadType)
131
- .add(AttachmentRefType)
140
+ .add(buildAttachmentRefType())
132
141
  .add(new protobuf.Field('messageId', 1, 'string'))
133
142
  .add(new protobuf.Field('appId', 2, 'string'))
134
143
  .add(new protobuf.Field('groupId', 3, 'string'))
@@ -149,6 +158,7 @@ const GroupNotifDeviceType = new protobuf.Type('DeviceEncryptedPayload')
149
158
  const GroupMessageNotificationType = new protobuf.Type('GroupMessageNotification')
150
159
  .add(GroupNotifEncryptionTypeEnum)
151
160
  .add(GroupNotifDeviceType)
161
+ .add(buildAttachmentRefType())
152
162
  .add(new protobuf.Field('messageId', 1, 'string'))
153
163
  .add(new protobuf.Field('groupId', 2, 'string'))
154
164
  .add(new protobuf.Field('fromUserId', 3, 'string'))
@@ -0,0 +1,28 @@
1
+ import { type SseTransportOptions } from './sse-transport';
2
+ import type { MessagingTransport, MessagingTransportClose } from './messaging-transport';
3
+ /**
4
+ * SSE adapter for {@link MessagingTransport}. Wraps the standalone
5
+ * {@link SseTransport} primitive so `MessagingClient` can use the HTTP
6
+ * fallback lane behind the same lifecycle API as WebSocket.
7
+ *
8
+ * v1 limitations: no auto-reconnect logic at the client layer beyond
9
+ * what EventSource already does; close events synthesize code 1000.
10
+ */
11
+ export declare class SseMessagingTransport implements MessagingTransport {
12
+ readonly name: "sse";
13
+ private inner;
14
+ private opened;
15
+ private openHandler;
16
+ private frameHandler;
17
+ private closeHandler;
18
+ private errorHandler;
19
+ constructor(options: SseTransportOptions);
20
+ onOpen(handler: () => void): void;
21
+ onFrame(handler: (bytes: Uint8Array) => void): void;
22
+ onClose(handler: (info: MessagingTransportClose) => void): void;
23
+ onError(handler: (err: Error) => void): void;
24
+ connect(): Promise<void>;
25
+ send(bytes: Uint8Array): void;
26
+ isOpen(): boolean;
27
+ close(_code?: number, _reason?: string): void;
28
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SseMessagingTransport = void 0;
4
+ const sse_transport_1 = require("./sse-transport");
5
+ /**
6
+ * SSE adapter for {@link MessagingTransport}. Wraps the standalone
7
+ * {@link SseTransport} primitive so `MessagingClient` can use the HTTP
8
+ * fallback lane behind the same lifecycle API as WebSocket.
9
+ *
10
+ * v1 limitations: no auto-reconnect logic at the client layer beyond
11
+ * what EventSource already does; close events synthesize code 1000.
12
+ */
13
+ class SseMessagingTransport {
14
+ constructor(options) {
15
+ this.name = 'sse';
16
+ this.opened = false;
17
+ this.openHandler = null;
18
+ this.frameHandler = null;
19
+ this.closeHandler = null;
20
+ this.errorHandler = null;
21
+ this.inner = new sse_transport_1.SseTransport(options);
22
+ this.inner.onFrame((bytes) => this.frameHandler?.(bytes));
23
+ this.inner.onState((state) => {
24
+ if (state === 'open') {
25
+ this.opened = true;
26
+ this.openHandler?.();
27
+ }
28
+ else if (state === 'closed') {
29
+ this.opened = false;
30
+ this.closeHandler?.({ code: 1000, reason: 'CLOSED' });
31
+ }
32
+ else if (state === 'error') {
33
+ this.errorHandler?.(new Error('SseMessagingTransport: stream error'));
34
+ }
35
+ });
36
+ }
37
+ onOpen(handler) { this.openHandler = handler; }
38
+ onFrame(handler) { this.frameHandler = handler; }
39
+ onClose(handler) { this.closeHandler = handler; }
40
+ onError(handler) { this.errorHandler = handler; }
41
+ async connect() {
42
+ await this.inner.connect();
43
+ }
44
+ send(bytes) {
45
+ // Fire-and-forget; surface failure via the error handler so the client's
46
+ // existing `this.transport.send(...)` call sites don't need to await.
47
+ this.inner.sendEnvelope(bytes).catch((err) => {
48
+ this.errorHandler?.(err instanceof Error ? err : new Error(String(err)));
49
+ });
50
+ }
51
+ isOpen() {
52
+ return this.opened;
53
+ }
54
+ close(_code, _reason) {
55
+ this.inner.close();
56
+ this.opened = false;
57
+ }
58
+ }
59
+ exports.SseMessagingTransport = SseMessagingTransport;
@@ -0,0 +1,24 @@
1
+ import type { MessagingTransport, MessagingTransportClose } from './messaging-transport';
2
+ /**
3
+ * WebSocket adapter for {@link MessagingTransport}. Wraps the global
4
+ * WebSocket constructor; the client supplies the full URL (already
5
+ * carrying the JWT + deviceId query params).
6
+ */
7
+ export declare class WebSocketMessagingTransport implements MessagingTransport {
8
+ private readonly url;
9
+ readonly name: "ws";
10
+ private ws;
11
+ private openHandler;
12
+ private frameHandler;
13
+ private closeHandler;
14
+ private errorHandler;
15
+ constructor(url: string);
16
+ onOpen(handler: () => void): void;
17
+ onFrame(handler: (bytes: Uint8Array) => void): void;
18
+ onClose(handler: (info: MessagingTransportClose) => void): void;
19
+ onError(handler: (err: Error) => void): void;
20
+ connect(): Promise<void>;
21
+ send(bytes: Uint8Array): void;
22
+ isOpen(): boolean;
23
+ close(code?: number, reason?: string): void;
24
+ }
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebSocketMessagingTransport = void 0;
4
+ /**
5
+ * WebSocket adapter for {@link MessagingTransport}. Wraps the global
6
+ * WebSocket constructor; the client supplies the full URL (already
7
+ * carrying the JWT + deviceId query params).
8
+ */
9
+ class WebSocketMessagingTransport {
10
+ constructor(url) {
11
+ this.url = url;
12
+ this.name = 'ws';
13
+ this.ws = null;
14
+ this.openHandler = null;
15
+ this.frameHandler = null;
16
+ this.closeHandler = null;
17
+ this.errorHandler = null;
18
+ }
19
+ onOpen(handler) { this.openHandler = handler; }
20
+ onFrame(handler) { this.frameHandler = handler; }
21
+ onClose(handler) { this.closeHandler = handler; }
22
+ onError(handler) { this.errorHandler = handler; }
23
+ connect() {
24
+ return new Promise((resolve, reject) => {
25
+ const ws = new WebSocket(this.url);
26
+ ws.binaryType = 'arraybuffer';
27
+ ws.onopen = () => {
28
+ this.ws = ws;
29
+ this.openHandler?.();
30
+ resolve();
31
+ };
32
+ ws.onerror = (event) => {
33
+ const err = new Error('Failed to connect DropOnAir websocket');
34
+ this.errorHandler?.(err);
35
+ // If the socket never opened, reject the connect promise.
36
+ if (ws.readyState !== WebSocket.OPEN) {
37
+ reject(err);
38
+ }
39
+ };
40
+ ws.onclose = (event) => {
41
+ this.ws = null;
42
+ this.closeHandler?.({ code: event.code, reason: event.reason });
43
+ };
44
+ ws.onmessage = (event) => {
45
+ if (event.data instanceof ArrayBuffer) {
46
+ this.frameHandler?.(new Uint8Array(event.data));
47
+ }
48
+ };
49
+ });
50
+ }
51
+ send(bytes) {
52
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
53
+ throw new Error('WebSocketMessagingTransport: not open');
54
+ }
55
+ this.ws.send(bytes);
56
+ }
57
+ isOpen() {
58
+ return !!this.ws && this.ws.readyState === WebSocket.OPEN;
59
+ }
60
+ close(code, reason) {
61
+ if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
62
+ this.ws.close(code, reason);
63
+ }
64
+ this.ws = null;
65
+ }
66
+ }
67
+ exports.WebSocketMessagingTransport = WebSocketMessagingTransport;
@@ -0,0 +1,25 @@
1
+ import { type WebTransportTransportOptions } from './webtransport-transport';
2
+ import type { MessagingTransport, MessagingTransportClose } from './messaging-transport';
3
+ /**
4
+ * WebTransport (HTTP/3) adapter for {@link MessagingTransport}. Wraps the
5
+ * standalone {@link WebTransportTransport} primitive so `MessagingClient`
6
+ * can use the QUIC lane behind the same lifecycle API as WebSocket.
7
+ */
8
+ export declare class WtMessagingTransport implements MessagingTransport {
9
+ readonly name: "wt";
10
+ private inner;
11
+ private opened;
12
+ private openHandler;
13
+ private frameHandler;
14
+ private closeHandler;
15
+ private errorHandler;
16
+ constructor(options: WebTransportTransportOptions);
17
+ onOpen(handler: () => void): void;
18
+ onFrame(handler: (bytes: Uint8Array) => void): void;
19
+ onClose(handler: (info: MessagingTransportClose) => void): void;
20
+ onError(handler: (err: Error) => void): void;
21
+ connect(): Promise<void>;
22
+ send(bytes: Uint8Array): void;
23
+ isOpen(): boolean;
24
+ close(_code?: number, _reason?: string): void;
25
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WtMessagingTransport = void 0;
4
+ const webtransport_transport_1 = require("./webtransport-transport");
5
+ /**
6
+ * WebTransport (HTTP/3) adapter for {@link MessagingTransport}. Wraps the
7
+ * standalone {@link WebTransportTransport} primitive so `MessagingClient`
8
+ * can use the QUIC lane behind the same lifecycle API as WebSocket.
9
+ */
10
+ class WtMessagingTransport {
11
+ constructor(options) {
12
+ this.name = 'wt';
13
+ this.opened = false;
14
+ this.openHandler = null;
15
+ this.frameHandler = null;
16
+ this.closeHandler = null;
17
+ this.errorHandler = null;
18
+ this.inner = new webtransport_transport_1.WebTransportTransport(options);
19
+ this.inner.onFrame((bytes) => this.frameHandler?.(bytes));
20
+ this.inner.onState((state) => {
21
+ if (state === 'open') {
22
+ this.opened = true;
23
+ this.openHandler?.();
24
+ }
25
+ else if (state === 'closed') {
26
+ this.opened = false;
27
+ this.closeHandler?.({ code: 1000, reason: 'CLOSED' });
28
+ }
29
+ else if (state === 'error') {
30
+ this.errorHandler?.(new Error('WtMessagingTransport: session error'));
31
+ }
32
+ });
33
+ }
34
+ onOpen(handler) { this.openHandler = handler; }
35
+ onFrame(handler) { this.frameHandler = handler; }
36
+ onClose(handler) { this.closeHandler = handler; }
37
+ onError(handler) { this.errorHandler = handler; }
38
+ async connect() {
39
+ await this.inner.connect();
40
+ }
41
+ send(bytes) {
42
+ this.inner.sendEnvelope(bytes).catch((err) => {
43
+ this.errorHandler?.(err instanceof Error ? err : new Error(String(err)));
44
+ });
45
+ }
46
+ isOpen() {
47
+ return this.opened;
48
+ }
49
+ close(_code, _reason) {
50
+ this.inner.close();
51
+ this.opened = false;
52
+ }
53
+ }
54
+ exports.WtMessagingTransport = WtMessagingTransport;
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.21.0";
10
+ export declare const SDK_VERSION = "0.22.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.21.0';
13
+ exports.SDK_VERSION = '0.22.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.21.0",
3
+ "version": "0.22.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",