@abraca/dabra 1.0.4 → 1.0.5

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.
@@ -2965,7 +2965,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2965
2965
  const childProvider = new AbracadabraProvider({
2966
2966
  name: childId,
2967
2967
  document: childDoc,
2968
- url: this.abracadabraConfig.url ?? this.configuration.websocketProvider?.url,
2968
+ websocketProvider: this.configuration.websocketProvider,
2969
2969
  token: this.configuration.token,
2970
2970
  subdocLoading: this.subdocLoading,
2971
2971
  disableOfflineStore: this.abracadabraConfig.disableOfflineStore,
@@ -2975,6 +2975,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2975
2975
  docKeyManager: this.abracadabraConfig.docKeyManager,
2976
2976
  keystore: this.abracadabraConfig.keystore
2977
2977
  });
2978
+ childProvider.attach();
2978
2979
  this.childProviders.set(childId, childProvider);
2979
2980
  this.emit("subdocLoaded", {
2980
2981
  childId,
@@ -6943,7 +6944,7 @@ function fromBase64url(b64) {
6943
6944
  const DB_NAME = "abracadabra:identity";
6944
6945
  const STORE_NAME = "identity";
6945
6946
  const RECORD_KEY = "current";
6946
- const HKDF_INFO$1 = new TextEncoder().encode("abracadabra-identity-v1");
6947
+ const HKDF_INFO$2 = new TextEncoder().encode("abracadabra-identity-v1");
6947
6948
  function openDb$4() {
6948
6949
  return new Promise((resolve, reject) => {
6949
6950
  const req = indexedDB.open(DB_NAME, 1);
@@ -6976,7 +6977,7 @@ async function dbDelete(db) {
6976
6977
  });
6977
6978
  }
6978
6979
  async function deriveAesKey(prfOutput, salt) {
6979
- const keyBytes = hkdf(sha256, new Uint8Array(prfOutput), salt, HKDF_INFO$1, 32);
6980
+ const keyBytes = hkdf(sha256, new Uint8Array(prfOutput), salt, HKDF_INFO$2, 32);
6980
6981
  return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
6981
6982
  }
6982
6983
  var CryptoIdentityKeystore = class {
@@ -7693,7 +7694,7 @@ var FileBlobStore = class extends EventEmitter {
7693
7694
  * Manages AES-256-GCM document keys for CSE and E2E encrypted documents.
7694
7695
  * Keys are wrapped per-user using X25519 ECDH + HKDF-SHA256 + AES-256-GCM.
7695
7696
  */
7696
- const HKDF_INFO = new TextEncoder().encode("abracadabra-dockey-v1");
7697
+ const HKDF_INFO$1 = new TextEncoder().encode("abracadabra-dockey-v1");
7697
7698
  function fromBase64$1(b64) {
7698
7699
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
7699
7700
  }
@@ -7737,7 +7738,7 @@ var DocKeyManager = class {
7737
7738
  async wrapKeyForRecipient(docKey, recipientX25519PubKey, docId) {
7738
7739
  const ephemeralPriv = crypto.getRandomValues(new Uint8Array(32));
7739
7740
  const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
7740
- const keyBytes = hkdf(sha256, x25519.getSharedSecret(ephemeralPriv, recipientX25519PubKey), new TextEncoder().encode(docId), HKDF_INFO, 32);
7741
+ const keyBytes = hkdf(sha256, x25519.getSharedSecret(ephemeralPriv, recipientX25519PubKey), new TextEncoder().encode(docId), HKDF_INFO$1, 32);
7741
7742
  const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]);
7742
7743
  const rawDocKey = await crypto.subtle.exportKey("raw", docKey);
7743
7744
  const nonce = crypto.getRandomValues(new Uint8Array(12));
@@ -7755,7 +7756,7 @@ var DocKeyManager = class {
7755
7756
  const ephemeralPub = wrapped.slice(0, 32);
7756
7757
  const nonce = wrapped.slice(32, 44);
7757
7758
  const ciphertext = wrapped.slice(44);
7758
- const keyBytes = hkdf(sha256, x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub), new TextEncoder().encode(docId), HKDF_INFO, 32);
7759
+ const keyBytes = hkdf(sha256, x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub), new TextEncoder().encode(docId), HKDF_INFO$1, 32);
7759
7760
  const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
7760
7761
  const rawDocKey = await crypto.subtle.decrypt({
7761
7762
  name: "AES-GCM",
@@ -8747,15 +8748,23 @@ const SHA256_BYTES = 32;
8747
8748
 
8748
8749
  //#endregion
8749
8750
  //#region packages/provider/src/webrtc/DataChannelRouter.ts
8751
+ /** Name of the data channel used for E2EE key exchange. */
8752
+ const KEY_EXCHANGE_CHANNEL = "key-exchange";
8750
8753
  var DataChannelRouter = class extends EventEmitter {
8751
8754
  constructor(connection) {
8752
8755
  super();
8753
8756
  this.connection = connection;
8754
8757
  this.channels = /* @__PURE__ */ new Map();
8758
+ this.encryptor = null;
8759
+ this.plaintextChannels = new Set([KEY_EXCHANGE_CHANNEL]);
8755
8760
  this.connection.ondatachannel = (event) => {
8756
8761
  this.registerChannel(event.channel);
8757
8762
  };
8758
8763
  }
8764
+ /** Attach an E2EE encryptor. All channels (except key-exchange) will be encrypted. */
8765
+ setEncryptor(encryptor) {
8766
+ this.encryptor = encryptor;
8767
+ }
8759
8768
  /** Create a named data channel (initiator side). */
8760
8769
  createChannel(name, options) {
8761
8770
  const channel = this.connection.createDataChannel(name, options);
@@ -8771,12 +8780,35 @@ var DataChannelRouter = class extends EventEmitter {
8771
8780
  });
8772
8781
  if (opts.enableFileTransfer) this.createChannel(CHANNEL_NAMES.FILE_TRANSFER, { ordered: true });
8773
8782
  }
8783
+ /**
8784
+ * Create namespaced channels for a child/subdocument.
8785
+ * Channel names are prefixed with `{childId}:` to avoid collisions.
8786
+ */
8787
+ createSubdocChannels(childId, opts) {
8788
+ if (opts.enableDocSync) this.createChannel(`${childId}:${CHANNEL_NAMES.YJS_SYNC}`, { ordered: true });
8789
+ if (opts.enableAwareness) this.createChannel(`${childId}:${CHANNEL_NAMES.AWARENESS}`, {
8790
+ ordered: false,
8791
+ maxRetransmits: 0
8792
+ });
8793
+ }
8774
8794
  getChannel(name) {
8775
8795
  return this.channels.get(name) ?? null;
8776
8796
  }
8777
8797
  isOpen(name) {
8778
8798
  return this.channels.get(name)?.readyState === "open";
8779
8799
  }
8800
+ /**
8801
+ * Send data on a named channel, encrypting if E2EE is active.
8802
+ * Falls back to plaintext if no encryptor is set or for exempt channels.
8803
+ */
8804
+ async send(name, data) {
8805
+ const channel = this.channels.get(name);
8806
+ if (!channel || channel.readyState !== "open") return;
8807
+ if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
8808
+ const encrypted = await this.encryptor.encrypt(data);
8809
+ channel.send(encrypted);
8810
+ } else channel.send(data);
8811
+ }
8780
8812
  registerChannel(channel) {
8781
8813
  channel.binaryType = "arraybuffer";
8782
8814
  this.channels.set(channel.label, channel);
@@ -8790,10 +8822,22 @@ var DataChannelRouter = class extends EventEmitter {
8790
8822
  this.emit("channelClose", { name: channel.label });
8791
8823
  this.channels.delete(channel.label);
8792
8824
  };
8793
- channel.onmessage = (event) => {
8825
+ channel.onmessage = async (event) => {
8826
+ const name = channel.label;
8827
+ let data = event.data;
8828
+ if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name) && (data instanceof ArrayBuffer || data instanceof Uint8Array)) try {
8829
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
8830
+ data = await this.encryptor.decrypt(buf);
8831
+ } catch (err) {
8832
+ this.emit("channelError", {
8833
+ name,
8834
+ error: err
8835
+ });
8836
+ return;
8837
+ }
8794
8838
  this.emit("channelMessage", {
8795
- name: channel.label,
8796
- data: event.data
8839
+ name,
8840
+ data
8797
8841
  });
8798
8842
  };
8799
8843
  channel.onerror = (event) => {
@@ -8921,7 +8965,13 @@ var PeerConnection = class extends EventEmitter {
8921
8965
  * prevent echo loops with the server-based provider.
8922
8966
  */
8923
8967
  var YjsDataChannel = class {
8924
- constructor(document, awareness, router) {
8968
+ /**
8969
+ * @param document - The Y.Doc to sync
8970
+ * @param awareness - Optional Awareness instance
8971
+ * @param router - DataChannelRouter for the peer connection
8972
+ * @param channelPrefix - Optional prefix for subdocument channels (e.g. `"{childId}:"`)
8973
+ */
8974
+ constructor(document, awareness, router, channelPrefix) {
8925
8975
  this.document = document;
8926
8976
  this.awareness = awareness;
8927
8977
  this.router = router;
@@ -8929,52 +8979,49 @@ var YjsDataChannel = class {
8929
8979
  this.awarenessUpdateHandler = null;
8930
8980
  this.channelOpenHandler = null;
8931
8981
  this.channelMessageHandler = null;
8982
+ const prefix = channelPrefix ?? "";
8983
+ this.syncChannelName = `${prefix}${CHANNEL_NAMES.YJS_SYNC}`;
8984
+ this.awarenessChannelName = `${prefix}${CHANNEL_NAMES.AWARENESS}`;
8932
8985
  }
8933
8986
  /** Start listening for Y.js updates and data channel messages. */
8934
8987
  attach() {
8935
8988
  this.docUpdateHandler = (update, origin) => {
8936
8989
  if (origin === this) return;
8937
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
8938
- if (!channel || channel.readyState !== "open") return;
8990
+ if (!this.router.isOpen(this.syncChannelName)) return;
8939
8991
  const encoder = createEncoder();
8940
8992
  writeVarUint(encoder, YJS_MSG.UPDATE);
8941
8993
  writeVarUint8Array(encoder, update);
8942
- channel.send(toUint8Array(encoder));
8994
+ this.router.send(this.syncChannelName, toUint8Array(encoder));
8943
8995
  };
8944
8996
  this.document.on("update", this.docUpdateHandler);
8945
8997
  if (this.awareness) {
8946
8998
  this.awarenessUpdateHandler = ({ added, updated, removed }, _origin) => {
8947
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
8948
- if (!channel || channel.readyState !== "open") return;
8999
+ if (!this.router.isOpen(this.awarenessChannelName)) return;
8949
9000
  const changedClients = added.concat(updated).concat(removed);
8950
9001
  const update = encodeAwarenessUpdate(this.awareness, changedClients);
8951
- channel.send(update);
9002
+ this.router.send(this.awarenessChannelName, update);
8952
9003
  };
8953
9004
  this.awareness.on("update", this.awarenessUpdateHandler);
8954
9005
  }
8955
9006
  this.channelMessageHandler = ({ name, data }) => {
8956
- if (name === CHANNEL_NAMES.YJS_SYNC) this.handleSyncMessage(data);
8957
- else if (name === CHANNEL_NAMES.AWARENESS) this.handleAwarenessMessage(data);
9007
+ if (name === this.syncChannelName) this.handleSyncMessage(data);
9008
+ else if (name === this.awarenessChannelName) this.handleAwarenessMessage(data);
8958
9009
  };
8959
9010
  this.router.on("channelMessage", this.channelMessageHandler);
8960
9011
  this.channelOpenHandler = ({ name }) => {
8961
- if (name === CHANNEL_NAMES.YJS_SYNC) this.sendSyncStep1();
8962
- else if (name === CHANNEL_NAMES.AWARENESS && this.awareness) {
8963
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
8964
- if (channel?.readyState === "open") {
9012
+ if (name === this.syncChannelName) this.sendSyncStep1();
9013
+ else if (name === this.awarenessChannelName && this.awareness) {
9014
+ if (this.router.isOpen(this.awarenessChannelName)) {
8965
9015
  const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
8966
- channel.send(update);
9016
+ this.router.send(this.awarenessChannelName, update);
8967
9017
  }
8968
9018
  }
8969
9019
  };
8970
9020
  this.router.on("channelOpen", this.channelOpenHandler);
8971
- if (this.router.isOpen(CHANNEL_NAMES.YJS_SYNC)) this.sendSyncStep1();
8972
- if (this.awareness && this.router.isOpen(CHANNEL_NAMES.AWARENESS)) {
8973
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
8974
- if (channel?.readyState === "open") {
8975
- const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
8976
- channel.send(update);
8977
- }
9021
+ if (this.router.isOpen(this.syncChannelName)) this.sendSyncStep1();
9022
+ if (this.awareness && this.router.isOpen(this.awarenessChannelName)) {
9023
+ const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
9024
+ this.router.send(this.awarenessChannelName, update);
8978
9025
  }
8979
9026
  }
8980
9027
  /** Stop listening and clean up handlers. */
@@ -9001,12 +9048,11 @@ var YjsDataChannel = class {
9001
9048
  this.detach();
9002
9049
  }
9003
9050
  sendSyncStep1() {
9004
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
9005
- if (!channel || channel.readyState !== "open") return;
9051
+ if (!this.router.isOpen(this.syncChannelName)) return;
9006
9052
  const encoder = createEncoder();
9007
9053
  writeVarUint(encoder, YJS_MSG.SYNC);
9008
9054
  writeSyncStep1(encoder, this.document);
9009
- channel.send(toUint8Array(encoder));
9055
+ this.router.send(this.syncChannelName, toUint8Array(encoder));
9010
9056
  }
9011
9057
  handleSyncMessage(data) {
9012
9058
  const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
@@ -9019,8 +9065,7 @@ var YjsDataChannel = class {
9019
9065
  const responseEncoder = createEncoder();
9020
9066
  writeVarUint(responseEncoder, YJS_MSG.SYNC);
9021
9067
  writeUint8Array(responseEncoder, toUint8Array(encoder));
9022
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
9023
- if (channel?.readyState === "open") channel.send(toUint8Array(responseEncoder));
9068
+ if (this.router.isOpen(this.syncChannelName)) this.router.send(this.syncChannelName, toUint8Array(responseEncoder));
9024
9069
  }
9025
9070
  if (syncMessageType === messageYjsSyncStep2) this.isSynced = true;
9026
9071
  } else if (msgType === YJS_MSG.UPDATE) {
@@ -9270,6 +9315,136 @@ function constantTimeEqual(a, b) {
9270
9315
  return result === 0;
9271
9316
  }
9272
9317
 
9318
+ //#endregion
9319
+ //#region packages/provider/src/webrtc/E2EEChannel.ts
9320
+ /**
9321
+ * E2EEChannel
9322
+ *
9323
+ * Per-peer end-to-end encryption for WebRTC data channels using
9324
+ * X25519 ECDH key agreement + HKDF-SHA256 + AES-256-GCM.
9325
+ *
9326
+ * Leverages the same cryptographic primitives as `DocKeyManager` and
9327
+ * `CryptoIdentityKeystore` but applied to data channel messages rather
9328
+ * than stored document keys.
9329
+ *
9330
+ * Key agreement flow:
9331
+ * 1. Both peers exchange Ed25519 public keys via the `key-exchange` data channel
9332
+ * 2. Convert Ed25519 → X25519 (Montgomery form)
9333
+ * 3. Run X25519 ECDH to derive a shared secret
9334
+ * 4. HKDF-SHA256(sharedSecret, salt, info) → 32-byte AES-256-GCM session key
9335
+ * 5. All subsequent data channel messages are encrypted with this key
9336
+ *
9337
+ * Message wire format: [12-byte nonce || AES-256-GCM ciphertext]
9338
+ *
9339
+ * Dependencies: @noble/curves (already in the project for CryptoIdentityKeystore)
9340
+ */
9341
+ const HKDF_INFO = new TextEncoder().encode("abracadabra-webrtc-e2ee-v1");
9342
+ const NONCE_BYTES = 12;
9343
+ var E2EEChannel = class extends EventEmitter {
9344
+ constructor(identity, docId) {
9345
+ super();
9346
+ this.identity = identity;
9347
+ this.docId = docId;
9348
+ this.sessionKey = null;
9349
+ this.remotePublicKey = null;
9350
+ this._isEstablished = false;
9351
+ }
9352
+ get isEstablished() {
9353
+ return this._isEstablished;
9354
+ }
9355
+ /**
9356
+ * Process a key-exchange message from the remote peer.
9357
+ * Called when the `key-exchange` data channel receives a message.
9358
+ *
9359
+ * The message is the remote peer's raw 32-byte Ed25519 public key.
9360
+ * After receiving it, ECDH is computed and the session key derived.
9361
+ */
9362
+ async handleKeyExchange(remoteEdPubKey) {
9363
+ if (remoteEdPubKey.length !== 32) {
9364
+ this.emit("error", /* @__PURE__ */ new Error("Invalid remote public key length"));
9365
+ return;
9366
+ }
9367
+ this.remotePublicKey = remoteEdPubKey;
9368
+ const remoteX25519Pub = ed25519.utils.toMontgomery(remoteEdPubKey);
9369
+ const sharedSecret = x25519.getSharedSecret(this.identity.x25519PrivateKey, remoteX25519Pub);
9370
+ const localPub = this.identity.publicKey;
9371
+ const remotePub = remoteEdPubKey;
9372
+ const [first, second] = this.sortKeys(localPub, remotePub);
9373
+ const saltParts = [
9374
+ new TextEncoder().encode(this.docId),
9375
+ first,
9376
+ second
9377
+ ];
9378
+ const salt = new Uint8Array(saltParts.reduce((acc, p) => acc + p.length, 0));
9379
+ let offset = 0;
9380
+ for (const part of saltParts) {
9381
+ salt.set(part, offset);
9382
+ offset += part.length;
9383
+ }
9384
+ const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
9385
+ this.sessionKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
9386
+ this._isEstablished = true;
9387
+ this.emit("established", { remotePublicKey: remoteEdPubKey });
9388
+ }
9389
+ /**
9390
+ * Returns the local Ed25519 public key to send to the remote peer
9391
+ * via the `key-exchange` data channel.
9392
+ */
9393
+ getKeyExchangeMessage() {
9394
+ return this.identity.publicKey;
9395
+ }
9396
+ /**
9397
+ * Encrypt a message for sending over a data channel.
9398
+ * Returns `[12-byte nonce || AES-256-GCM ciphertext]`.
9399
+ *
9400
+ * @throws if the session key has not been established yet.
9401
+ */
9402
+ async encrypt(plaintext) {
9403
+ if (!this.sessionKey) throw new Error("E2EE session key not established");
9404
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
9405
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
9406
+ name: "AES-GCM",
9407
+ iv: nonce
9408
+ }, this.sessionKey, plaintext));
9409
+ const result = new Uint8Array(NONCE_BYTES + ciphertext.length);
9410
+ result.set(nonce, 0);
9411
+ result.set(ciphertext, NONCE_BYTES);
9412
+ return result;
9413
+ }
9414
+ /**
9415
+ * Decrypt a message received from a data channel.
9416
+ * Expects `[12-byte nonce || AES-256-GCM ciphertext]`.
9417
+ *
9418
+ * @throws if the session key has not been established or decryption fails.
9419
+ */
9420
+ async decrypt(data) {
9421
+ if (!this.sessionKey) throw new Error("E2EE session key not established");
9422
+ if (data.length < NONCE_BYTES + 16) throw new Error("E2EE ciphertext too short");
9423
+ const nonce = data.slice(0, NONCE_BYTES);
9424
+ const ciphertext = data.slice(NONCE_BYTES);
9425
+ const plaintext = await crypto.subtle.decrypt({
9426
+ name: "AES-GCM",
9427
+ iv: nonce
9428
+ }, this.sessionKey, ciphertext);
9429
+ return new Uint8Array(plaintext);
9430
+ }
9431
+ /** Destroy the session key and wipe sensitive material. */
9432
+ destroy() {
9433
+ this.sessionKey = null;
9434
+ this.remotePublicKey = null;
9435
+ this._isEstablished = false;
9436
+ this.removeAllListeners();
9437
+ }
9438
+ /** Sort two keys lexicographically so both peers produce the same order. */
9439
+ sortKeys(a, b) {
9440
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
9441
+ if (a[i] < b[i]) return [a, b];
9442
+ if (a[i] > b[i]) return [b, a];
9443
+ }
9444
+ return a.length <= b.length ? [a, b] : [b, a];
9445
+ }
9446
+ };
9447
+
9273
9448
  //#endregion
9274
9449
  //#region packages/provider/src/webrtc/AbracadabraWebRTC.ts
9275
9450
  const HAS_RTC = typeof globalThis.RTCPeerConnection !== "undefined";
@@ -9290,6 +9465,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9290
9465
  this.peerConnections = /* @__PURE__ */ new Map();
9291
9466
  this.yjsChannels = /* @__PURE__ */ new Map();
9292
9467
  this.fileChannels = /* @__PURE__ */ new Map();
9468
+ this.e2eeChannels = /* @__PURE__ */ new Map();
9293
9469
  this.peers = /* @__PURE__ */ new Map();
9294
9470
  this.localPeerId = null;
9295
9471
  this.isConnected = false;
@@ -9298,6 +9474,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9298
9474
  this.config = {
9299
9475
  docId: configuration.docId,
9300
9476
  url: configuration.url,
9477
+ signalingUrl: configuration.signalingUrl ?? null,
9301
9478
  token: configuration.token,
9302
9479
  document: doc,
9303
9480
  awareness,
@@ -9308,6 +9485,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9308
9485
  enableAwarenessSync: configuration.enableAwarenessSync ?? !!awareness,
9309
9486
  enableFileTransfer: configuration.enableFileTransfer ?? false,
9310
9487
  fileChunkSize: configuration.fileChunkSize ?? 16384,
9488
+ e2ee: configuration.e2ee ?? null,
9311
9489
  WebSocketPolyfill: configuration.WebSocketPolyfill
9312
9490
  };
9313
9491
  if (configuration.autoConnect !== false && HAS_RTC) this.connect();
@@ -9488,6 +9666,11 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9488
9666
  }
9489
9667
  removePeer(peerId) {
9490
9668
  this.peers.delete(peerId);
9669
+ const e2ee = this.e2eeChannels.get(peerId);
9670
+ if (e2ee) {
9671
+ e2ee.destroy();
9672
+ this.e2eeChannels.delete(peerId);
9673
+ }
9491
9674
  const yjs = this.yjsChannels.get(peerId);
9492
9675
  if (yjs) {
9493
9676
  yjs.destroy();
@@ -9543,12 +9726,39 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9543
9726
  return pc;
9544
9727
  }
9545
9728
  attachDataHandlers(peerId, pc) {
9729
+ if (this.config.e2ee) {
9730
+ const e2ee = new E2EEChannel(this.config.e2ee, this.config.docId);
9731
+ this.e2eeChannels.set(peerId, e2ee);
9732
+ pc.router.setEncryptor(e2ee);
9733
+ pc.router.on("channelMessage", async ({ name, data }) => {
9734
+ if (name === KEY_EXCHANGE_CHANNEL) {
9735
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
9736
+ await e2ee.handleKeyExchange(buf);
9737
+ }
9738
+ });
9739
+ pc.router.on("channelOpen", ({ name, channel }) => {
9740
+ if (name === KEY_EXCHANGE_CHANNEL) channel.send(e2ee.getKeyExchangeMessage());
9741
+ });
9742
+ e2ee.on("established", () => {
9743
+ this.emit("e2eeEstablished", { peerId });
9744
+ this.startDataSync(peerId, pc);
9745
+ });
9746
+ e2ee.on("error", (err) => {
9747
+ this.emit("e2eeFailed", {
9748
+ peerId,
9749
+ error: err
9750
+ });
9751
+ });
9752
+ } else this.startDataSync(peerId, pc);
9753
+ }
9754
+ startDataSync(peerId, pc) {
9546
9755
  if (this.config.document && this.config.enableDocSync) {
9756
+ if (this.yjsChannels.has(peerId)) return;
9547
9757
  const yjs = new YjsDataChannel(this.config.document, this.config.enableAwarenessSync ? this.config.awareness : null, pc.router);
9548
9758
  yjs.attach();
9549
9759
  this.yjsChannels.set(peerId, yjs);
9550
9760
  }
9551
- if (this.config.enableFileTransfer) {
9761
+ if (this.config.enableFileTransfer && !this.fileChannels.has(peerId)) {
9552
9762
  const fc = new FileTransferChannel(pc.router, this.config.fileChunkSize);
9553
9763
  fc.on("receiveStart", (meta) => {
9554
9764
  this.emit("fileReceiveStart", {
@@ -9585,6 +9795,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9585
9795
  }
9586
9796
  async initiateConnection(peerId) {
9587
9797
  const pc = this.createPeerConnection(peerId);
9798
+ if (this.config.e2ee) pc.router.createChannel(KEY_EXCHANGE_CHANNEL, { ordered: true });
9588
9799
  pc.router.createDefaultChannels({
9589
9800
  enableDocSync: this.config.enableDocSync,
9590
9801
  enableAwareness: this.config.enableAwarenessSync,
@@ -9616,6 +9827,12 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9616
9827
  }
9617
9828
  }
9618
9829
  buildSignalingUrl() {
9830
+ if (this.config.signalingUrl) {
9831
+ let sigBase = this.config.signalingUrl;
9832
+ while (sigBase.endsWith("/")) sigBase = sigBase.slice(0, -1);
9833
+ sigBase = sigBase.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
9834
+ return `${sigBase}/ws/${encodeURIComponent(this.config.docId)}/signaling`;
9835
+ }
9619
9836
  let base = this.config.url;
9620
9837
  while (base.endsWith("/")) base = base.slice(0, -1);
9621
9838
  base = base.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
@@ -9623,6 +9840,310 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9623
9840
  }
9624
9841
  };
9625
9842
 
9843
+ //#endregion
9844
+ //#region packages/provider/src/webrtc/ManualSignaling.ts
9845
+ /**
9846
+ * ManualSignaling
9847
+ *
9848
+ * Serverless signaling adapter for WebRTC peer-to-peer connections.
9849
+ * Instead of a WebSocket server relaying SDP/ICE, peers exchange
9850
+ * offer and answer "blobs" out-of-band (QR code, copy-paste, NFC, etc.).
9851
+ *
9852
+ * Designed as a drop-in replacement for `SignalingSocket` — emits the
9853
+ * same events (`welcome`, `joined`, `offer`, `answer`, `ice`) so
9854
+ * `AbracadabraWebRTC` can use it transparently.
9855
+ *
9856
+ * Flow:
9857
+ * Device A (initiator):
9858
+ * 1. `createOffer()` → gathers ICE candidates → returns offer blob
9859
+ * 2. Share blob via QR/paste
9860
+ * 3. Receive answer blob → `acceptAnswer(blob)`
9861
+ *
9862
+ * Device B (responder):
9863
+ * 1. Receive offer blob → `acceptOffer(blob)` → returns answer blob
9864
+ * 2. Share answer blob via QR/paste
9865
+ */
9866
+ var ManualSignaling = class extends EventEmitter {
9867
+ constructor(iceServers) {
9868
+ super();
9869
+ this.isConnected = false;
9870
+ this.pc = null;
9871
+ this.localPeerId = crypto.randomUUID();
9872
+ this.iceServers = iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }];
9873
+ }
9874
+ /**
9875
+ * Initiator: create an offer blob with gathered ICE candidates.
9876
+ * Returns a blob to share with the remote peer.
9877
+ */
9878
+ async createOfferBlob() {
9879
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
9880
+ const candidates = [];
9881
+ const gatheringComplete = new Promise((resolve) => {
9882
+ this.pc.onicecandidate = (event) => {
9883
+ if (event.candidate) candidates.push(JSON.stringify(event.candidate.toJSON()));
9884
+ else resolve();
9885
+ };
9886
+ });
9887
+ const offer = await this.pc.createOffer();
9888
+ await this.pc.setLocalDescription(offer);
9889
+ await gatheringComplete;
9890
+ this.isConnected = true;
9891
+ this.emit("welcome", {
9892
+ peerId: this.localPeerId,
9893
+ peers: []
9894
+ });
9895
+ return {
9896
+ sdp: this.pc.localDescription.sdp,
9897
+ candidates,
9898
+ peerId: this.localPeerId
9899
+ };
9900
+ }
9901
+ /**
9902
+ * Responder: accept an offer blob and create an answer blob.
9903
+ * The answer blob should be shared back to the initiator.
9904
+ */
9905
+ async acceptOffer(offerBlob) {
9906
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
9907
+ const candidates = [];
9908
+ const gatheringComplete = new Promise((resolve) => {
9909
+ this.pc.onicecandidate = (event) => {
9910
+ if (event.candidate) candidates.push(JSON.stringify(event.candidate.toJSON()));
9911
+ else resolve();
9912
+ };
9913
+ });
9914
+ await this.pc.setRemoteDescription(new RTCSessionDescription({
9915
+ type: "offer",
9916
+ sdp: offerBlob.sdp
9917
+ }));
9918
+ for (const c of offerBlob.candidates) await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
9919
+ const answer = await this.pc.createAnswer();
9920
+ await this.pc.setLocalDescription(answer);
9921
+ await gatheringComplete;
9922
+ this.isConnected = true;
9923
+ this.emit("welcome", {
9924
+ peerId: this.localPeerId,
9925
+ peers: []
9926
+ });
9927
+ this.emit("joined", {
9928
+ peer_id: offerBlob.peerId,
9929
+ user_id: offerBlob.peerId,
9930
+ muted: false,
9931
+ video: false,
9932
+ screen: false,
9933
+ name: null,
9934
+ color: null
9935
+ });
9936
+ this.emit("offer", {
9937
+ from: offerBlob.peerId,
9938
+ sdp: offerBlob.sdp
9939
+ });
9940
+ for (const c of offerBlob.candidates) this.emit("ice", {
9941
+ from: offerBlob.peerId,
9942
+ candidate: c
9943
+ });
9944
+ return {
9945
+ sdp: this.pc.localDescription.sdp,
9946
+ candidates,
9947
+ peerId: this.localPeerId
9948
+ };
9949
+ }
9950
+ /**
9951
+ * Initiator: accept the answer blob from the responder.
9952
+ * Completes the connection.
9953
+ */
9954
+ async acceptAnswer(answerBlob) {
9955
+ if (!this.pc) throw new Error("Call createOfferBlob() first");
9956
+ this.emit("joined", {
9957
+ peer_id: answerBlob.peerId,
9958
+ user_id: answerBlob.peerId,
9959
+ muted: false,
9960
+ video: false,
9961
+ screen: false,
9962
+ name: null,
9963
+ color: null
9964
+ });
9965
+ this.emit("answer", {
9966
+ from: answerBlob.peerId,
9967
+ sdp: answerBlob.sdp
9968
+ });
9969
+ for (const c of answerBlob.candidates) this.emit("ice", {
9970
+ from: answerBlob.peerId,
9971
+ candidate: c
9972
+ });
9973
+ }
9974
+ sendOffer(_to, _sdp) {}
9975
+ sendAnswer(_to, _sdp) {}
9976
+ sendIce(_to, _candidate) {}
9977
+ sendMute(_muted) {}
9978
+ sendMediaState(_video, _screen) {}
9979
+ sendProfile(_name, _color) {}
9980
+ sendLeave() {}
9981
+ async connect() {}
9982
+ disconnect() {
9983
+ this.isConnected = false;
9984
+ if (this.pc) {
9985
+ this.pc.close();
9986
+ this.pc = null;
9987
+ }
9988
+ this.emit("disconnected");
9989
+ }
9990
+ destroy() {
9991
+ this.disconnect();
9992
+ this.removeAllListeners();
9993
+ }
9994
+ };
9995
+
9996
+ //#endregion
9997
+ //#region packages/provider/src/sync/BroadcastChannelSync.ts
9998
+ /**
9999
+ * Cross-tab Y.js document and awareness sync via the BroadcastChannel API.
10000
+ *
10001
+ * Opens a BroadcastChannel per document, relays Y.js updates and awareness
10002
+ * state between tabs on the same origin. No server, no WebRTC — just same-device
10003
+ * multi-tab sync. Same-origin means same trust boundary, so no encryption needed.
10004
+ *
10005
+ * Uses the same y-protocols/sync encoding as `YjsDataChannel` for consistency.
10006
+ */
10007
+ const CHANNEL_PREFIX = "abra:sync:";
10008
+ /** Message type discriminators (first byte). */
10009
+ const MSG = {
10010
+ SYNC: 0,
10011
+ UPDATE: 1,
10012
+ AWARENESS: 2,
10013
+ QUERY_PEERS: 3
10014
+ };
10015
+ var BroadcastChannelSync = class BroadcastChannelSync extends EventEmitter {
10016
+ constructor(document, awareness, channelName) {
10017
+ super();
10018
+ this.document = document;
10019
+ this.awareness = awareness;
10020
+ this.channelName = channelName;
10021
+ this.channel = null;
10022
+ this.docUpdateHandler = null;
10023
+ this.awarenessUpdateHandler = null;
10024
+ this.destroyed = false;
10025
+ }
10026
+ /** Convenience factory using standard channel naming. */
10027
+ static forDoc(document, docId, awareness) {
10028
+ return new BroadcastChannelSync(document, awareness ?? null, `${CHANNEL_PREFIX}${docId}`);
10029
+ }
10030
+ /** Start syncing. Opens the BroadcastChannel and initiates a sync handshake. */
10031
+ connect() {
10032
+ if (this.destroyed || this.channel) return;
10033
+ if (typeof globalThis.BroadcastChannel === "undefined") return;
10034
+ this.channel = new BroadcastChannel(this.channelName);
10035
+ this.channel.onmessage = (event) => this.handleMessage(event.data);
10036
+ this.docUpdateHandler = (update, origin) => {
10037
+ if (origin === this) return;
10038
+ this.broadcastUpdate(update);
10039
+ };
10040
+ this.document.on("update", this.docUpdateHandler);
10041
+ if (this.awareness) {
10042
+ this.awarenessUpdateHandler = ({ added, updated, removed }, _origin) => {
10043
+ const changedClients = added.concat(updated).concat(removed);
10044
+ const update = encodeAwarenessUpdate(this.awareness, changedClients);
10045
+ this.broadcastAwareness(update);
10046
+ };
10047
+ this.awareness.on("update", this.awarenessUpdateHandler);
10048
+ }
10049
+ this.broadcastQueryPeers();
10050
+ this.sendSyncStep1();
10051
+ this.emit("connected");
10052
+ }
10053
+ /** Stop syncing and close the BroadcastChannel. */
10054
+ disconnect() {
10055
+ if (this.docUpdateHandler) {
10056
+ this.document.off("update", this.docUpdateHandler);
10057
+ this.docUpdateHandler = null;
10058
+ }
10059
+ if (this.awarenessUpdateHandler && this.awareness) {
10060
+ this.awareness.off("update", this.awarenessUpdateHandler);
10061
+ this.awarenessUpdateHandler = null;
10062
+ }
10063
+ if (this.channel) {
10064
+ this.channel.close();
10065
+ this.channel = null;
10066
+ }
10067
+ this.emit("disconnected");
10068
+ }
10069
+ /** Disconnect and prevent reconnection. */
10070
+ destroy() {
10071
+ this.disconnect();
10072
+ this.destroyed = true;
10073
+ this.removeAllListeners();
10074
+ }
10075
+ broadcastUpdate(update) {
10076
+ if (!this.channel) return;
10077
+ const encoder = createEncoder();
10078
+ writeVarUint(encoder, MSG.UPDATE);
10079
+ writeVarUint8Array(encoder, update);
10080
+ this.channel.postMessage(toUint8Array(encoder));
10081
+ }
10082
+ broadcastAwareness(update) {
10083
+ if (!this.channel) return;
10084
+ const encoder = createEncoder();
10085
+ writeVarUint(encoder, MSG.AWARENESS);
10086
+ writeVarUint8Array(encoder, update);
10087
+ this.channel.postMessage(toUint8Array(encoder));
10088
+ }
10089
+ sendSyncStep1() {
10090
+ if (!this.channel) return;
10091
+ const encoder = createEncoder();
10092
+ writeVarUint(encoder, MSG.SYNC);
10093
+ writeSyncStep1(encoder, this.document);
10094
+ this.channel.postMessage(toUint8Array(encoder));
10095
+ }
10096
+ broadcastQueryPeers() {
10097
+ if (!this.channel) return;
10098
+ const encoder = createEncoder();
10099
+ writeVarUint(encoder, MSG.QUERY_PEERS);
10100
+ this.channel.postMessage(toUint8Array(encoder));
10101
+ }
10102
+ handleMessage(data) {
10103
+ if (!(data instanceof ArrayBuffer || data instanceof Uint8Array)) return;
10104
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
10105
+ const decoder = createDecoder(buf);
10106
+ switch (readVarUint(decoder)) {
10107
+ case MSG.SYNC:
10108
+ this.handleSyncMessage(decoder);
10109
+ break;
10110
+ case MSG.UPDATE:
10111
+ this.handleUpdateMessage(decoder);
10112
+ break;
10113
+ case MSG.AWARENESS:
10114
+ this.handleAwarenessMessage(decoder);
10115
+ break;
10116
+ case MSG.QUERY_PEERS:
10117
+ this.sendSyncStep1();
10118
+ if (this.awareness) {
10119
+ const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
10120
+ this.broadcastAwareness(update);
10121
+ }
10122
+ break;
10123
+ }
10124
+ }
10125
+ handleSyncMessage(decoder) {
10126
+ const encoder = createEncoder();
10127
+ const syncMessageType = readSyncMessage(decoder, encoder, this.document, this);
10128
+ if (length(encoder) > 0) {
10129
+ const responseEncoder = createEncoder();
10130
+ writeVarUint(responseEncoder, MSG.SYNC);
10131
+ writeUint8Array(responseEncoder, toUint8Array(encoder));
10132
+ this.channel?.postMessage(toUint8Array(responseEncoder));
10133
+ }
10134
+ if (syncMessageType === messageYjsSyncStep2) this.emit("synced");
10135
+ }
10136
+ handleUpdateMessage(decoder) {
10137
+ const update = readVarUint8Array(decoder);
10138
+ yjs.applyUpdate(this.document, update, this);
10139
+ }
10140
+ handleAwarenessMessage(decoder) {
10141
+ if (!this.awareness) return;
10142
+ const update = readVarUint8Array(decoder);
10143
+ applyAwarenessUpdate(this.awareness, update, this);
10144
+ }
10145
+ };
10146
+
9626
10147
  //#endregion
9627
10148
  exports.AbracadabraBaseProvider = AbracadabraBaseProvider;
9628
10149
  exports.AbracadabraClient = AbracadabraClient;
@@ -9633,6 +10154,7 @@ exports.AuthMessageType = AuthMessageType;
9633
10154
  exports.AwarenessError = AwarenessError;
9634
10155
  exports.BackgroundSyncManager = BackgroundSyncManager;
9635
10156
  exports.BackgroundSyncPersistence = BackgroundSyncPersistence;
10157
+ exports.BroadcastChannelSync = BroadcastChannelSync;
9636
10158
  exports.CHANNEL_NAMES = CHANNEL_NAMES;
9637
10159
  exports.ConnectionTimeout = ConnectionTimeout;
9638
10160
  exports.CryptoIdentityKeystore = CryptoIdentityKeystore;
@@ -9642,6 +10164,7 @@ exports.DataChannelRouter = DataChannelRouter;
9642
10164
  exports.DocKeyManager = DocKeyManager;
9643
10165
  exports.DocumentCache = DocumentCache;
9644
10166
  exports.E2EAbracadabraProvider = E2EAbracadabraProvider;
10167
+ exports.E2EEChannel = E2EEChannel;
9645
10168
  exports.E2EOfflineStore = E2EOfflineStore;
9646
10169
  exports.EncryptedYMap = EncryptedYMap;
9647
10170
  exports.EncryptedYText = EncryptedYText;
@@ -9651,6 +10174,8 @@ exports.FileTransferHandle = FileTransferHandle;
9651
10174
  exports.Forbidden = Forbidden;
9652
10175
  exports.HocuspocusProvider = HocuspocusProvider;
9653
10176
  exports.HocuspocusProviderWebsocket = HocuspocusProviderWebsocket;
10177
+ exports.KEY_EXCHANGE_CHANNEL = KEY_EXCHANGE_CHANNEL;
10178
+ exports.ManualSignaling = ManualSignaling;
9654
10179
  exports.MessageTooBig = MessageTooBig;
9655
10180
  exports.MessageType = MessageType;
9656
10181
  exports.OfflineStore = OfflineStore;