@abraca/dabra 1.0.4 → 1.0.6

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/dist/index.d.ts CHANGED
@@ -1599,11 +1599,66 @@ declare class BackgroundSyncManager extends EventEmitter {
1599
1599
  private _walkXml;
1600
1600
  }
1601
1601
  //#endregion
1602
+ //#region packages/provider/src/webrtc/E2EEChannel.d.ts
1603
+ interface E2EEIdentity {
1604
+ /** Raw 32-byte Ed25519 public key. */
1605
+ publicKey: Uint8Array;
1606
+ /** Raw 32-byte X25519 private key. Caller must wipe after E2EEChannel.destroy(). */
1607
+ x25519PrivateKey: Uint8Array;
1608
+ }
1609
+ declare class E2EEChannel extends EventEmitter {
1610
+ private readonly identity;
1611
+ private readonly docId;
1612
+ private sessionKey;
1613
+ private remotePublicKey;
1614
+ private _isEstablished;
1615
+ constructor(identity: E2EEIdentity, docId: string);
1616
+ get isEstablished(): boolean;
1617
+ /**
1618
+ * Process a key-exchange message from the remote peer.
1619
+ * Called when the `key-exchange` data channel receives a message.
1620
+ *
1621
+ * The message is the remote peer's raw 32-byte Ed25519 public key.
1622
+ * After receiving it, ECDH is computed and the session key derived.
1623
+ */
1624
+ handleKeyExchange(remoteEdPubKey: Uint8Array): Promise<void>;
1625
+ /**
1626
+ * Returns the local Ed25519 public key to send to the remote peer
1627
+ * via the `key-exchange` data channel.
1628
+ */
1629
+ getKeyExchangeMessage(): Uint8Array;
1630
+ /**
1631
+ * Encrypt a message for sending over a data channel.
1632
+ * Returns `[12-byte nonce || AES-256-GCM ciphertext]`.
1633
+ *
1634
+ * @throws if the session key has not been established yet.
1635
+ */
1636
+ encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
1637
+ /**
1638
+ * Decrypt a message received from a data channel.
1639
+ * Expects `[12-byte nonce || AES-256-GCM ciphertext]`.
1640
+ *
1641
+ * @throws if the session key has not been established or decryption fails.
1642
+ */
1643
+ decrypt(data: Uint8Array): Promise<Uint8Array>;
1644
+ /** Destroy the session key and wipe sensitive material. */
1645
+ destroy(): void;
1646
+ /** Sort two keys lexicographically so both peers produce the same order. */
1647
+ private sortKeys;
1648
+ }
1649
+ //#endregion
1602
1650
  //#region packages/provider/src/webrtc/DataChannelRouter.d.ts
1651
+ /** Name of the data channel used for E2EE key exchange. */
1652
+ declare const KEY_EXCHANGE_CHANNEL = "key-exchange";
1603
1653
  declare class DataChannelRouter extends EventEmitter {
1604
1654
  private connection;
1605
1655
  private channels;
1656
+ private encryptor;
1657
+ /** Channels excluded from encryption (key-exchange itself, awareness). */
1658
+ private plaintextChannels;
1606
1659
  constructor(connection: RTCPeerConnection);
1660
+ /** Attach an E2EE encryptor. All channels (except key-exchange) will be encrypted. */
1661
+ setEncryptor(encryptor: E2EEChannel): void;
1607
1662
  /** Create a named data channel (initiator side). */
1608
1663
  createChannel(name: string, options?: RTCDataChannelInit): RTCDataChannel;
1609
1664
  /** Create the standard set of channels for Abracadabra WebRTC. */
@@ -1612,8 +1667,21 @@ declare class DataChannelRouter extends EventEmitter {
1612
1667
  enableAwareness: boolean;
1613
1668
  enableFileTransfer: boolean;
1614
1669
  }): void;
1670
+ /**
1671
+ * Create namespaced channels for a child/subdocument.
1672
+ * Channel names are prefixed with `{childId}:` to avoid collisions.
1673
+ */
1674
+ createSubdocChannels(childId: string, opts: {
1675
+ enableDocSync: boolean;
1676
+ enableAwareness: boolean;
1677
+ }): void;
1615
1678
  getChannel(name: string): RTCDataChannel | null;
1616
1679
  isOpen(name: string): boolean;
1680
+ /**
1681
+ * Send data on a named channel, encrypting if E2EE is active.
1682
+ * Falls back to plaintext if no encryptor is set or for exempt channels.
1683
+ */
1684
+ send(name: string, data: Uint8Array): Promise<void>;
1617
1685
  private registerChannel;
1618
1686
  close(): void;
1619
1687
  destroy(): void;
@@ -1731,6 +1799,12 @@ interface AbracadabraWebRTCConfiguration {
1731
1799
  docId: string;
1732
1800
  /** Server base URL (http/https). Signaling URL derived automatically. */
1733
1801
  url: string;
1802
+ /**
1803
+ * Override the signaling WebSocket URL. When set, signaling connects to
1804
+ * this server instead of deriving from `url`. Useful for local spaces that
1805
+ * piggyback a remote server's signaling endpoint.
1806
+ */
1807
+ signalingUrl?: string;
1734
1808
  /** JWT token or async token factory. */
1735
1809
  token: string | (() => string) | (() => Promise<string>);
1736
1810
  /** Optional Y.Doc to sync over data channels (hybrid mode). */
@@ -1753,6 +1827,12 @@ interface AbracadabraWebRTCConfiguration {
1753
1827
  fileChunkSize?: number;
1754
1828
  /** Auto-connect on construction. Default: true. */
1755
1829
  autoConnect?: boolean;
1830
+ /**
1831
+ * E2EE identity for application-level encryption on data channels.
1832
+ * When provided, all data channel messages (except key-exchange) are
1833
+ * encrypted with AES-256-GCM using X25519 ECDH-derived session keys.
1834
+ */
1835
+ e2ee?: E2EEIdentity;
1756
1836
  /** WebSocket polyfill for signaling (e.g. for Node.js). */
1757
1837
  WebSocketPolyfill?: any;
1758
1838
  }
@@ -1811,6 +1891,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
1811
1891
  private peerConnections;
1812
1892
  private yjsChannels;
1813
1893
  private fileChannels;
1894
+ private e2eeChannels;
1814
1895
  private readonly config;
1815
1896
  readonly peers: Map<string, PeerState>;
1816
1897
  localPeerId: string | null;
@@ -1848,6 +1929,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
1848
1929
  private removeAllPeers;
1849
1930
  private createPeerConnection;
1850
1931
  private attachDataHandlers;
1932
+ private startDataSync;
1851
1933
  private initiateConnection;
1852
1934
  private handleOffer;
1853
1935
  private buildSignalingUrl;
@@ -1942,7 +2024,16 @@ declare class YjsDataChannel {
1942
2024
  private awarenessUpdateHandler;
1943
2025
  private channelOpenHandler;
1944
2026
  private channelMessageHandler;
1945
- constructor(document: Y.Doc, awareness: Awareness | null, router: DataChannelRouter);
2027
+ /** Channel names used for sync and awareness (supports namespaced subdoc channels). */
2028
+ private readonly syncChannelName;
2029
+ private readonly awarenessChannelName;
2030
+ /**
2031
+ * @param document - The Y.Doc to sync
2032
+ * @param awareness - Optional Awareness instance
2033
+ * @param router - DataChannelRouter for the peer connection
2034
+ * @param channelPrefix - Optional prefix for subdocument channels (e.g. `"{childId}:"`)
2035
+ */
2036
+ constructor(document: Y.Doc, awareness: Awareness | null, router: DataChannelRouter, channelPrefix?: string);
1946
2037
  /** Start listening for Y.js updates and data channel messages. */
1947
2038
  attach(): void;
1948
2039
  /** Stop listening and clean up handlers. */
@@ -1953,4 +2044,74 @@ declare class YjsDataChannel {
1953
2044
  private handleAwarenessMessage;
1954
2045
  }
1955
2046
  //#endregion
1956
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EOfflineStore, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
2047
+ //#region packages/provider/src/webrtc/ManualSignaling.d.ts
2048
+ interface ManualSignalingBlob {
2049
+ /** SDP offer or answer. */
2050
+ sdp: string;
2051
+ /** Gathered ICE candidates. */
2052
+ candidates: string[];
2053
+ /** Peer ID of the sender. */
2054
+ peerId: string;
2055
+ }
2056
+ declare class ManualSignaling extends EventEmitter {
2057
+ localPeerId: string;
2058
+ isConnected: boolean;
2059
+ private pc;
2060
+ private iceServers;
2061
+ constructor(iceServers?: RTCIceServer[]);
2062
+ /**
2063
+ * Initiator: create an offer blob with gathered ICE candidates.
2064
+ * Returns a blob to share with the remote peer.
2065
+ */
2066
+ createOfferBlob(): Promise<ManualSignalingBlob>;
2067
+ /**
2068
+ * Responder: accept an offer blob and create an answer blob.
2069
+ * The answer blob should be shared back to the initiator.
2070
+ */
2071
+ acceptOffer(offerBlob: ManualSignalingBlob): Promise<ManualSignalingBlob>;
2072
+ /**
2073
+ * Initiator: accept the answer blob from the responder.
2074
+ * Completes the connection.
2075
+ */
2076
+ acceptAnswer(answerBlob: ManualSignalingBlob): Promise<void>;
2077
+ sendOffer(_to: string, _sdp: string): void;
2078
+ sendAnswer(_to: string, _sdp: string): void;
2079
+ sendIce(_to: string, _candidate: string): void;
2080
+ sendMute(_muted: boolean): void;
2081
+ sendMediaState(_video: boolean, _screen: boolean): void;
2082
+ sendProfile(_name: string, _color: string): void;
2083
+ sendLeave(): void;
2084
+ connect(): Promise<void>;
2085
+ disconnect(): void;
2086
+ destroy(): void;
2087
+ }
2088
+ //#endregion
2089
+ //#region packages/provider/src/sync/BroadcastChannelSync.d.ts
2090
+ declare class BroadcastChannelSync extends EventEmitter {
2091
+ private readonly document;
2092
+ private readonly awareness;
2093
+ private readonly channelName;
2094
+ private channel;
2095
+ private docUpdateHandler;
2096
+ private awarenessUpdateHandler;
2097
+ private destroyed;
2098
+ constructor(document: Y.Doc, awareness: Awareness | null, channelName: string);
2099
+ /** Convenience factory using standard channel naming. */
2100
+ static forDoc(document: Y.Doc, docId: string, awareness?: Awareness | null): BroadcastChannelSync;
2101
+ /** Start syncing. Opens the BroadcastChannel and initiates a sync handshake. */
2102
+ connect(): void;
2103
+ /** Stop syncing and close the BroadcastChannel. */
2104
+ disconnect(): void;
2105
+ /** Disconnect and prevent reconnection. */
2106
+ destroy(): void;
2107
+ private broadcastUpdate;
2108
+ private broadcastAwareness;
2109
+ private sendSyncStep1;
2110
+ private broadcastQueryPeers;
2111
+ private handleMessage;
2112
+ private handleSyncMessage;
2113
+ private handleUpdateMessage;
2114
+ private handleAwarenessMessage;
2115
+ }
2116
+ //#endregion
2117
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -442,19 +442,18 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
442
442
  const childDoc = new Y.Doc({ guid: childId });
443
443
 
444
444
  // Fire-and-forget: tell the server this child belongs to the parent.
445
- // The child provider's own WebSocket handshake (TCP + TLS + auth) is
446
- // slow enough that the server will have processed the registration
447
- // before the child's first SyncStep1 arrives. In the rare race the
448
- // child's built-in reconnect logic retries automatically.
449
445
  this.registerSubdoc(childDoc);
450
446
 
451
- // Each child gets its own WebSocket connection. Omitting
452
- // websocketProvider lets AbracadabraBaseProvider create one automatically
453
- // (manageSocket = true), so we do NOT call attach() manually.
447
+ // Reuse the parent's WebSocket for multiplexing — all children share a
448
+ // single TCP connection instead of each opening its own. The shared
449
+ // AbracadabraWS demuxes frames by documentName, so each child provider
450
+ // still syncs independently. Because we pass websocketProvider the
451
+ // child's manageSocket will be false, meaning destroy() detaches
452
+ // without tearing down the shared connection.
454
453
  const childProvider = new AbracadabraProvider({
455
454
  name: childId,
456
455
  document: childDoc,
457
- url: this.abracadabraConfig.url ?? this.configuration.websocketProvider?.url,
456
+ websocketProvider: this.configuration.websocketProvider,
458
457
  token: this.configuration.token,
459
458
  subdocLoading: this.subdocLoading,
460
459
  disableOfflineStore: this.abracadabraConfig.disableOfflineStore,
@@ -465,6 +464,10 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
465
464
  keystore: this.abracadabraConfig.keystore,
466
465
  });
467
466
 
467
+ // Shared websocketProvider means manageSocket is false, so the base
468
+ // class constructor skips auto-attach. Attach explicitly.
469
+ childProvider.attach();
470
+
468
471
  this.childProviders.set(childId, childProvider);
469
472
 
470
473
  this.emit("subdocLoaded", { childId, provider: childProvider });
package/src/index.ts CHANGED
@@ -24,3 +24,4 @@ export type { BackgroundSyncManagerOptions } from "./BackgroundSyncManager.ts";
24
24
  export { BackgroundSyncPersistence } from "./BackgroundSyncPersistence.ts";
25
25
  export type { DocSyncState } from "./BackgroundSyncPersistence.ts";
26
26
  export * from "./webrtc/index.ts";
27
+ export { BroadcastChannelSync } from "./sync/BroadcastChannelSync.ts";
@@ -0,0 +1,235 @@
1
+ import * as Y from "yjs";
2
+ import * as syncProtocol from "y-protocols/sync";
3
+ import {
4
+ encodeAwarenessUpdate,
5
+ applyAwarenessUpdate,
6
+ type Awareness,
7
+ } from "y-protocols/awareness";
8
+ import * as encoding from "lib0/encoding";
9
+ import * as decoding from "lib0/decoding";
10
+ import EventEmitter from "../EventEmitter.ts";
11
+
12
+ /**
13
+ * Cross-tab Y.js document and awareness sync via the BroadcastChannel API.
14
+ *
15
+ * Opens a BroadcastChannel per document, relays Y.js updates and awareness
16
+ * state between tabs on the same origin. No server, no WebRTC — just same-device
17
+ * multi-tab sync. Same-origin means same trust boundary, so no encryption needed.
18
+ *
19
+ * Uses the same y-protocols/sync encoding as `YjsDataChannel` for consistency.
20
+ */
21
+
22
+ const CHANNEL_PREFIX = "abra:sync:";
23
+
24
+ /** Message type discriminators (first byte). */
25
+ const MSG = {
26
+ SYNC: 0x00,
27
+ UPDATE: 0x01,
28
+ AWARENESS: 0x02,
29
+ /** Request all tabs to respond with SyncStep1. */
30
+ QUERY_PEERS: 0x03,
31
+ } as const;
32
+
33
+ export class BroadcastChannelSync extends EventEmitter {
34
+ private channel: BroadcastChannel | null = null;
35
+ private docUpdateHandler: ((update: Uint8Array, origin: any) => void) | null =
36
+ null;
37
+ private awarenessUpdateHandler:
38
+ | ((
39
+ changes: { added: number[]; updated: number[]; removed: number[] },
40
+ origin: any,
41
+ ) => void)
42
+ | null = null;
43
+ private destroyed = false;
44
+
45
+ constructor(
46
+ private readonly document: Y.Doc,
47
+ private readonly awareness: Awareness | null,
48
+ private readonly channelName: string,
49
+ ) {
50
+ super();
51
+ }
52
+
53
+ /** Convenience factory using standard channel naming. */
54
+ static forDoc(
55
+ document: Y.Doc,
56
+ docId: string,
57
+ awareness?: Awareness | null,
58
+ ): BroadcastChannelSync {
59
+ return new BroadcastChannelSync(
60
+ document,
61
+ awareness ?? null,
62
+ `${CHANNEL_PREFIX}${docId}`,
63
+ );
64
+ }
65
+
66
+ /** Start syncing. Opens the BroadcastChannel and initiates a sync handshake. */
67
+ connect(): void {
68
+ if (this.destroyed || this.channel) return;
69
+
70
+ if (typeof globalThis.BroadcastChannel === "undefined") {
71
+ // Environment doesn't support BroadcastChannel (e.g. some Node.js builds).
72
+ return;
73
+ }
74
+
75
+ this.channel = new BroadcastChannel(this.channelName);
76
+ this.channel.onmessage = (event) => this.handleMessage(event.data);
77
+
78
+ // Listen for local doc updates → broadcast to other tabs.
79
+ this.docUpdateHandler = (update: Uint8Array, origin: any) => {
80
+ if (origin === this) return; // Don't echo.
81
+ this.broadcastUpdate(update);
82
+ };
83
+ this.document.on("update", this.docUpdateHandler);
84
+
85
+ // Listen for local awareness changes → broadcast to other tabs.
86
+ if (this.awareness) {
87
+ this.awarenessUpdateHandler = (
88
+ {
89
+ added,
90
+ updated,
91
+ removed,
92
+ }: { added: number[]; updated: number[]; removed: number[] },
93
+ _origin: any,
94
+ ) => {
95
+ const changedClients = added.concat(updated).concat(removed);
96
+ const update = encodeAwarenessUpdate(this.awareness!, changedClients);
97
+ this.broadcastAwareness(update);
98
+ };
99
+ this.awareness.on("update", this.awarenessUpdateHandler);
100
+ }
101
+
102
+ // Announce presence — other tabs reply with SyncStep1.
103
+ this.broadcastQueryPeers();
104
+
105
+ // Send our SyncStep1 to trigger state exchange.
106
+ this.sendSyncStep1();
107
+
108
+ this.emit("connected");
109
+ }
110
+
111
+ /** Stop syncing and close the BroadcastChannel. */
112
+ disconnect(): void {
113
+ if (this.docUpdateHandler) {
114
+ this.document.off("update", this.docUpdateHandler);
115
+ this.docUpdateHandler = null;
116
+ }
117
+ if (this.awarenessUpdateHandler && this.awareness) {
118
+ this.awareness.off("update", this.awarenessUpdateHandler);
119
+ this.awarenessUpdateHandler = null;
120
+ }
121
+ if (this.channel) {
122
+ this.channel.close();
123
+ this.channel = null;
124
+ }
125
+ this.emit("disconnected");
126
+ }
127
+
128
+ /** Disconnect and prevent reconnection. */
129
+ destroy(): void {
130
+ this.disconnect();
131
+ this.destroyed = true;
132
+ this.removeAllListeners();
133
+ }
134
+
135
+ // ── Outgoing ────────────────────────────────────────────────────────────
136
+
137
+ private broadcastUpdate(update: Uint8Array): void {
138
+ if (!this.channel) return;
139
+ const encoder = encoding.createEncoder();
140
+ encoding.writeVarUint(encoder, MSG.UPDATE);
141
+ encoding.writeVarUint8Array(encoder, update);
142
+ this.channel.postMessage(encoding.toUint8Array(encoder));
143
+ }
144
+
145
+ private broadcastAwareness(update: Uint8Array): void {
146
+ if (!this.channel) return;
147
+ const encoder = encoding.createEncoder();
148
+ encoding.writeVarUint(encoder, MSG.AWARENESS);
149
+ encoding.writeVarUint8Array(encoder, update);
150
+ this.channel.postMessage(encoding.toUint8Array(encoder));
151
+ }
152
+
153
+ private sendSyncStep1(): void {
154
+ if (!this.channel) return;
155
+ const encoder = encoding.createEncoder();
156
+ encoding.writeVarUint(encoder, MSG.SYNC);
157
+ syncProtocol.writeSyncStep1(encoder, this.document);
158
+ this.channel.postMessage(encoding.toUint8Array(encoder));
159
+ }
160
+
161
+ private broadcastQueryPeers(): void {
162
+ if (!this.channel) return;
163
+ const encoder = encoding.createEncoder();
164
+ encoding.writeVarUint(encoder, MSG.QUERY_PEERS);
165
+ this.channel.postMessage(encoding.toUint8Array(encoder));
166
+ }
167
+
168
+ // ── Incoming ────────────────────────────────────────────────────────────
169
+
170
+ private handleMessage(data: any): void {
171
+ if (!(data instanceof ArrayBuffer || data instanceof Uint8Array)) return;
172
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
173
+ const decoder = decoding.createDecoder(buf);
174
+ const msgType = decoding.readVarUint(decoder);
175
+
176
+ switch (msgType) {
177
+ case MSG.SYNC:
178
+ this.handleSyncMessage(decoder);
179
+ break;
180
+ case MSG.UPDATE:
181
+ this.handleUpdateMessage(decoder);
182
+ break;
183
+ case MSG.AWARENESS:
184
+ this.handleAwarenessMessage(decoder);
185
+ break;
186
+ case MSG.QUERY_PEERS:
187
+ // Another tab joined — respond with our SyncStep1.
188
+ this.sendSyncStep1();
189
+ if (this.awareness) {
190
+ const update = encodeAwarenessUpdate(
191
+ this.awareness,
192
+ Array.from(this.awareness.getStates().keys()),
193
+ );
194
+ this.broadcastAwareness(update);
195
+ }
196
+ break;
197
+ }
198
+ }
199
+
200
+ private handleSyncMessage(decoder: decoding.Decoder): void {
201
+ const encoder = encoding.createEncoder();
202
+ const syncMessageType = syncProtocol.readSyncMessage(
203
+ decoder,
204
+ encoder,
205
+ this.document,
206
+ this,
207
+ );
208
+
209
+ // If the sync protocol generated a response (SyncStep2), broadcast it.
210
+ if (encoding.length(encoder) > 0) {
211
+ const responseEncoder = encoding.createEncoder();
212
+ encoding.writeVarUint(responseEncoder, MSG.SYNC);
213
+ encoding.writeUint8Array(
214
+ responseEncoder,
215
+ encoding.toUint8Array(encoder),
216
+ );
217
+ this.channel?.postMessage(encoding.toUint8Array(responseEncoder));
218
+ }
219
+
220
+ if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
221
+ this.emit("synced");
222
+ }
223
+ }
224
+
225
+ private handleUpdateMessage(decoder: decoding.Decoder): void {
226
+ const update = decoding.readVarUint8Array(decoder);
227
+ Y.applyUpdate(this.document, update, this);
228
+ }
229
+
230
+ private handleAwarenessMessage(decoder: decoding.Decoder): void {
231
+ if (!this.awareness) return;
232
+ const update = decoding.readVarUint8Array(decoder);
233
+ applyAwarenessUpdate(this.awareness, update, this);
234
+ }
235
+ }
@@ -6,8 +6,11 @@ import { SignalingSocket } from "./SignalingSocket.ts";
6
6
  import { PeerConnection } from "./PeerConnection.ts";
7
7
  import { YjsDataChannel } from "./YjsDataChannel.ts";
8
8
  import { FileTransferChannel, FileTransferHandle } from "./FileTransferChannel.ts";
9
+ import { E2EEChannel, type E2EEIdentity } from "./E2EEChannel.ts";
10
+ import { KEY_EXCHANGE_CHANNEL } from "./DataChannelRouter.ts";
9
11
  import {
10
12
  DEFAULT_ICE_SERVERS,
13
+ CHANNEL_NAMES,
11
14
  type AbracadabraWebRTCConfiguration,
12
15
  type PeerInfo,
13
16
  type PeerState,
@@ -30,10 +33,12 @@ export class AbracadabraWebRTC extends EventEmitter {
30
33
  private peerConnections = new Map<string, PeerConnection>();
31
34
  private yjsChannels = new Map<string, YjsDataChannel>();
32
35
  private fileChannels = new Map<string, FileTransferChannel>();
36
+ private e2eeChannels = new Map<string, E2EEChannel>();
33
37
 
34
38
  private readonly config: {
35
39
  docId: string;
36
40
  url: string;
41
+ signalingUrl: string | null;
37
42
  token: string | (() => string) | (() => Promise<string>);
38
43
  document: Y.Doc | null;
39
44
  awareness: Awareness | null;
@@ -44,6 +49,7 @@ export class AbracadabraWebRTC extends EventEmitter {
44
49
  enableAwarenessSync: boolean;
45
50
  enableFileTransfer: boolean;
46
51
  fileChunkSize: number;
52
+ e2ee: E2EEIdentity | null;
47
53
  WebSocketPolyfill: any;
48
54
  };
49
55
 
@@ -60,6 +66,7 @@ export class AbracadabraWebRTC extends EventEmitter {
60
66
  this.config = {
61
67
  docId: configuration.docId,
62
68
  url: configuration.url,
69
+ signalingUrl: configuration.signalingUrl ?? null,
63
70
  token: configuration.token,
64
71
  document: doc,
65
72
  awareness,
@@ -70,6 +77,7 @@ export class AbracadabraWebRTC extends EventEmitter {
70
77
  enableAwarenessSync: configuration.enableAwarenessSync ?? !!awareness,
71
78
  enableFileTransfer: configuration.enableFileTransfer ?? false,
72
79
  fileChunkSize: configuration.fileChunkSize ?? 16384,
80
+ e2ee: configuration.e2ee ?? null,
73
81
  WebSocketPolyfill: configuration.WebSocketPolyfill,
74
82
  };
75
83
 
@@ -365,6 +373,12 @@ export class AbracadabraWebRTC extends EventEmitter {
365
373
  private removePeer(peerId: string): void {
366
374
  this.peers.delete(peerId);
367
375
 
376
+ const e2ee = this.e2eeChannels.get(peerId);
377
+ if (e2ee) {
378
+ e2ee.destroy();
379
+ this.e2eeChannels.delete(peerId);
380
+ }
381
+
368
382
  const yjs = this.yjsChannels.get(peerId);
369
383
  if (yjs) {
370
384
  yjs.destroy();
@@ -447,8 +461,48 @@ export class AbracadabraWebRTC extends EventEmitter {
447
461
  }
448
462
 
449
463
  private attachDataHandlers(peerId: string, pc: PeerConnection): void {
464
+ // Set up E2EE if configured.
465
+ if (this.config.e2ee) {
466
+ const e2ee = new E2EEChannel(this.config.e2ee, this.config.docId);
467
+ this.e2eeChannels.set(peerId, e2ee);
468
+ pc.router.setEncryptor(e2ee);
469
+
470
+ // Listen for key-exchange messages on the router.
471
+ pc.router.on("channelMessage", async ({ name, data }: { name: string; data: any }) => {
472
+ if (name === KEY_EXCHANGE_CHANNEL) {
473
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
474
+ await e2ee.handleKeyExchange(buf);
475
+ }
476
+ });
477
+
478
+ // When key-exchange channel opens, send our public key.
479
+ pc.router.on("channelOpen", ({ name, channel }: { name: string; channel: RTCDataChannel }) => {
480
+ if (name === KEY_EXCHANGE_CHANNEL) {
481
+ channel.send(e2ee.getKeyExchangeMessage());
482
+ }
483
+ });
484
+
485
+ e2ee.on("established", () => {
486
+ this.emit("e2eeEstablished", { peerId });
487
+ // Now that E2EE is ready, start Y.js sync (deferred).
488
+ this.startDataSync(peerId, pc);
489
+ });
490
+
491
+ e2ee.on("error", (err: Error) => {
492
+ this.emit("e2eeFailed", { peerId, error: err });
493
+ });
494
+ } else {
495
+ // No E2EE — start data sync immediately.
496
+ this.startDataSync(peerId, pc);
497
+ }
498
+ }
499
+
500
+ private startDataSync(peerId: string, pc: PeerConnection): void {
450
501
  // Attach Y.js sync.
451
502
  if (this.config.document && this.config.enableDocSync) {
503
+ // Don't double-attach if already set up.
504
+ if (this.yjsChannels.has(peerId)) return;
505
+
452
506
  const yjs = new YjsDataChannel(
453
507
  this.config.document,
454
508
  this.config.enableAwarenessSync ? this.config.awareness : null,
@@ -459,7 +513,7 @@ export class AbracadabraWebRTC extends EventEmitter {
459
513
  }
460
514
 
461
515
  // Attach file transfer.
462
- if (this.config.enableFileTransfer) {
516
+ if (this.config.enableFileTransfer && !this.fileChannels.has(peerId)) {
463
517
  const fc = new FileTransferChannel(pc.router, this.config.fileChunkSize);
464
518
 
465
519
  fc.on("receiveStart", (meta: any) => {
@@ -485,6 +539,11 @@ export class AbracadabraWebRTC extends EventEmitter {
485
539
  private async initiateConnection(peerId: string): Promise<void> {
486
540
  const pc = this.createPeerConnection(peerId);
487
541
 
542
+ // Create key-exchange channel first if E2EE is enabled.
543
+ if (this.config.e2ee) {
544
+ pc.router.createChannel(KEY_EXCHANGE_CHANNEL, { ordered: true });
545
+ }
546
+
488
547
  // Create data channels (initiator creates them).
489
548
  pc.router.createDefaultChannels({
490
549
  enableDocSync: this.config.enableDocSync,
@@ -525,6 +584,14 @@ export class AbracadabraWebRTC extends EventEmitter {
525
584
  // ── Private: URL Building ───────────────────────────────────────────────
526
585
 
527
586
  private buildSignalingUrl(): string {
587
+ // Use explicit signalingUrl if provided.
588
+ if (this.config.signalingUrl) {
589
+ let sigBase = this.config.signalingUrl;
590
+ while (sigBase.endsWith("/")) sigBase = sigBase.slice(0, -1);
591
+ sigBase = sigBase.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
592
+ return `${sigBase}/ws/${encodeURIComponent(this.config.docId)}/signaling`;
593
+ }
594
+
528
595
  let base = this.config.url;
529
596
 
530
597
  // Remove trailing slashes.