@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.
@@ -2935,7 +2935,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2935
2935
  const childProvider = new AbracadabraProvider({
2936
2936
  name: childId,
2937
2937
  document: childDoc,
2938
- url: this.abracadabraConfig.url ?? this.configuration.websocketProvider?.url,
2938
+ websocketProvider: this.configuration.websocketProvider,
2939
2939
  token: this.configuration.token,
2940
2940
  subdocLoading: this.subdocLoading,
2941
2941
  disableOfflineStore: this.abracadabraConfig.disableOfflineStore,
@@ -2945,6 +2945,7 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2945
2945
  docKeyManager: this.abracadabraConfig.docKeyManager,
2946
2946
  keystore: this.abracadabraConfig.keystore
2947
2947
  });
2948
+ childProvider.attach();
2948
2949
  this.childProviders.set(childId, childProvider);
2949
2950
  this.emit("subdocLoaded", {
2950
2951
  childId,
@@ -6913,7 +6914,7 @@ function fromBase64url(b64) {
6913
6914
  const DB_NAME = "abracadabra:identity";
6914
6915
  const STORE_NAME = "identity";
6915
6916
  const RECORD_KEY = "current";
6916
- const HKDF_INFO$1 = new TextEncoder().encode("abracadabra-identity-v1");
6917
+ const HKDF_INFO$2 = new TextEncoder().encode("abracadabra-identity-v1");
6917
6918
  function openDb$4() {
6918
6919
  return new Promise((resolve, reject) => {
6919
6920
  const req = indexedDB.open(DB_NAME, 1);
@@ -6946,7 +6947,7 @@ async function dbDelete(db) {
6946
6947
  });
6947
6948
  }
6948
6949
  async function deriveAesKey(prfOutput, salt) {
6949
- const keyBytes = hkdf(sha256, new Uint8Array(prfOutput), salt, HKDF_INFO$1, 32);
6950
+ const keyBytes = hkdf(sha256, new Uint8Array(prfOutput), salt, HKDF_INFO$2, 32);
6950
6951
  return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
6951
6952
  }
6952
6953
  var CryptoIdentityKeystore = class {
@@ -7663,7 +7664,7 @@ var FileBlobStore = class extends EventEmitter {
7663
7664
  * Manages AES-256-GCM document keys for CSE and E2E encrypted documents.
7664
7665
  * Keys are wrapped per-user using X25519 ECDH + HKDF-SHA256 + AES-256-GCM.
7665
7666
  */
7666
- const HKDF_INFO = new TextEncoder().encode("abracadabra-dockey-v1");
7667
+ const HKDF_INFO$1 = new TextEncoder().encode("abracadabra-dockey-v1");
7667
7668
  function fromBase64$1(b64) {
7668
7669
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
7669
7670
  }
@@ -7707,7 +7708,7 @@ var DocKeyManager = class {
7707
7708
  async wrapKeyForRecipient(docKey, recipientX25519PubKey, docId) {
7708
7709
  const ephemeralPriv = crypto.getRandomValues(new Uint8Array(32));
7709
7710
  const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
7710
- const keyBytes = hkdf(sha256, x25519.getSharedSecret(ephemeralPriv, recipientX25519PubKey), new TextEncoder().encode(docId), HKDF_INFO, 32);
7711
+ const keyBytes = hkdf(sha256, x25519.getSharedSecret(ephemeralPriv, recipientX25519PubKey), new TextEncoder().encode(docId), HKDF_INFO$1, 32);
7711
7712
  const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]);
7712
7713
  const rawDocKey = await crypto.subtle.exportKey("raw", docKey);
7713
7714
  const nonce = crypto.getRandomValues(new Uint8Array(12));
@@ -7725,7 +7726,7 @@ var DocKeyManager = class {
7725
7726
  const ephemeralPub = wrapped.slice(0, 32);
7726
7727
  const nonce = wrapped.slice(32, 44);
7727
7728
  const ciphertext = wrapped.slice(44);
7728
- const keyBytes = hkdf(sha256, x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub), new TextEncoder().encode(docId), HKDF_INFO, 32);
7729
+ const keyBytes = hkdf(sha256, x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub), new TextEncoder().encode(docId), HKDF_INFO$1, 32);
7729
7730
  const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
7730
7731
  const rawDocKey = await crypto.subtle.decrypt({
7731
7732
  name: "AES-GCM",
@@ -8695,15 +8696,23 @@ const SHA256_BYTES = 32;
8695
8696
 
8696
8697
  //#endregion
8697
8698
  //#region packages/provider/src/webrtc/DataChannelRouter.ts
8699
+ /** Name of the data channel used for E2EE key exchange. */
8700
+ const KEY_EXCHANGE_CHANNEL = "key-exchange";
8698
8701
  var DataChannelRouter = class extends EventEmitter {
8699
8702
  constructor(connection) {
8700
8703
  super();
8701
8704
  this.connection = connection;
8702
8705
  this.channels = /* @__PURE__ */ new Map();
8706
+ this.encryptor = null;
8707
+ this.plaintextChannels = new Set([KEY_EXCHANGE_CHANNEL]);
8703
8708
  this.connection.ondatachannel = (event) => {
8704
8709
  this.registerChannel(event.channel);
8705
8710
  };
8706
8711
  }
8712
+ /** Attach an E2EE encryptor. All channels (except key-exchange) will be encrypted. */
8713
+ setEncryptor(encryptor) {
8714
+ this.encryptor = encryptor;
8715
+ }
8707
8716
  /** Create a named data channel (initiator side). */
8708
8717
  createChannel(name, options) {
8709
8718
  const channel = this.connection.createDataChannel(name, options);
@@ -8719,12 +8728,35 @@ var DataChannelRouter = class extends EventEmitter {
8719
8728
  });
8720
8729
  if (opts.enableFileTransfer) this.createChannel(CHANNEL_NAMES.FILE_TRANSFER, { ordered: true });
8721
8730
  }
8731
+ /**
8732
+ * Create namespaced channels for a child/subdocument.
8733
+ * Channel names are prefixed with `{childId}:` to avoid collisions.
8734
+ */
8735
+ createSubdocChannels(childId, opts) {
8736
+ if (opts.enableDocSync) this.createChannel(`${childId}:${CHANNEL_NAMES.YJS_SYNC}`, { ordered: true });
8737
+ if (opts.enableAwareness) this.createChannel(`${childId}:${CHANNEL_NAMES.AWARENESS}`, {
8738
+ ordered: false,
8739
+ maxRetransmits: 0
8740
+ });
8741
+ }
8722
8742
  getChannel(name) {
8723
8743
  return this.channels.get(name) ?? null;
8724
8744
  }
8725
8745
  isOpen(name) {
8726
8746
  return this.channels.get(name)?.readyState === "open";
8727
8747
  }
8748
+ /**
8749
+ * Send data on a named channel, encrypting if E2EE is active.
8750
+ * Falls back to plaintext if no encryptor is set or for exempt channels.
8751
+ */
8752
+ async send(name, data) {
8753
+ const channel = this.channels.get(name);
8754
+ if (!channel || channel.readyState !== "open") return;
8755
+ if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
8756
+ const encrypted = await this.encryptor.encrypt(data);
8757
+ channel.send(encrypted);
8758
+ } else channel.send(data);
8759
+ }
8728
8760
  registerChannel(channel) {
8729
8761
  channel.binaryType = "arraybuffer";
8730
8762
  this.channels.set(channel.label, channel);
@@ -8738,10 +8770,22 @@ var DataChannelRouter = class extends EventEmitter {
8738
8770
  this.emit("channelClose", { name: channel.label });
8739
8771
  this.channels.delete(channel.label);
8740
8772
  };
8741
- channel.onmessage = (event) => {
8773
+ channel.onmessage = async (event) => {
8774
+ const name = channel.label;
8775
+ let data = event.data;
8776
+ if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name) && (data instanceof ArrayBuffer || data instanceof Uint8Array)) try {
8777
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
8778
+ data = await this.encryptor.decrypt(buf);
8779
+ } catch (err) {
8780
+ this.emit("channelError", {
8781
+ name,
8782
+ error: err
8783
+ });
8784
+ return;
8785
+ }
8742
8786
  this.emit("channelMessage", {
8743
- name: channel.label,
8744
- data: event.data
8787
+ name,
8788
+ data
8745
8789
  });
8746
8790
  };
8747
8791
  channel.onerror = (event) => {
@@ -8869,7 +8913,13 @@ var PeerConnection = class extends EventEmitter {
8869
8913
  * prevent echo loops with the server-based provider.
8870
8914
  */
8871
8915
  var YjsDataChannel = class {
8872
- constructor(document, awareness, router) {
8916
+ /**
8917
+ * @param document - The Y.Doc to sync
8918
+ * @param awareness - Optional Awareness instance
8919
+ * @param router - DataChannelRouter for the peer connection
8920
+ * @param channelPrefix - Optional prefix for subdocument channels (e.g. `"{childId}:"`)
8921
+ */
8922
+ constructor(document, awareness, router, channelPrefix) {
8873
8923
  this.document = document;
8874
8924
  this.awareness = awareness;
8875
8925
  this.router = router;
@@ -8877,52 +8927,49 @@ var YjsDataChannel = class {
8877
8927
  this.awarenessUpdateHandler = null;
8878
8928
  this.channelOpenHandler = null;
8879
8929
  this.channelMessageHandler = null;
8930
+ const prefix = channelPrefix ?? "";
8931
+ this.syncChannelName = `${prefix}${CHANNEL_NAMES.YJS_SYNC}`;
8932
+ this.awarenessChannelName = `${prefix}${CHANNEL_NAMES.AWARENESS}`;
8880
8933
  }
8881
8934
  /** Start listening for Y.js updates and data channel messages. */
8882
8935
  attach() {
8883
8936
  this.docUpdateHandler = (update, origin) => {
8884
8937
  if (origin === this) return;
8885
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
8886
- if (!channel || channel.readyState !== "open") return;
8938
+ if (!this.router.isOpen(this.syncChannelName)) return;
8887
8939
  const encoder = createEncoder();
8888
8940
  writeVarUint(encoder, YJS_MSG.UPDATE);
8889
8941
  writeVarUint8Array(encoder, update);
8890
- channel.send(toUint8Array(encoder));
8942
+ this.router.send(this.syncChannelName, toUint8Array(encoder));
8891
8943
  };
8892
8944
  this.document.on("update", this.docUpdateHandler);
8893
8945
  if (this.awareness) {
8894
8946
  this.awarenessUpdateHandler = ({ added, updated, removed }, _origin) => {
8895
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
8896
- if (!channel || channel.readyState !== "open") return;
8947
+ if (!this.router.isOpen(this.awarenessChannelName)) return;
8897
8948
  const changedClients = added.concat(updated).concat(removed);
8898
8949
  const update = encodeAwarenessUpdate(this.awareness, changedClients);
8899
- channel.send(update);
8950
+ this.router.send(this.awarenessChannelName, update);
8900
8951
  };
8901
8952
  this.awareness.on("update", this.awarenessUpdateHandler);
8902
8953
  }
8903
8954
  this.channelMessageHandler = ({ name, data }) => {
8904
- if (name === CHANNEL_NAMES.YJS_SYNC) this.handleSyncMessage(data);
8905
- else if (name === CHANNEL_NAMES.AWARENESS) this.handleAwarenessMessage(data);
8955
+ if (name === this.syncChannelName) this.handleSyncMessage(data);
8956
+ else if (name === this.awarenessChannelName) this.handleAwarenessMessage(data);
8906
8957
  };
8907
8958
  this.router.on("channelMessage", this.channelMessageHandler);
8908
8959
  this.channelOpenHandler = ({ name }) => {
8909
- if (name === CHANNEL_NAMES.YJS_SYNC) this.sendSyncStep1();
8910
- else if (name === CHANNEL_NAMES.AWARENESS && this.awareness) {
8911
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
8912
- if (channel?.readyState === "open") {
8960
+ if (name === this.syncChannelName) this.sendSyncStep1();
8961
+ else if (name === this.awarenessChannelName && this.awareness) {
8962
+ if (this.router.isOpen(this.awarenessChannelName)) {
8913
8963
  const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
8914
- channel.send(update);
8964
+ this.router.send(this.awarenessChannelName, update);
8915
8965
  }
8916
8966
  }
8917
8967
  };
8918
8968
  this.router.on("channelOpen", this.channelOpenHandler);
8919
- if (this.router.isOpen(CHANNEL_NAMES.YJS_SYNC)) this.sendSyncStep1();
8920
- if (this.awareness && this.router.isOpen(CHANNEL_NAMES.AWARENESS)) {
8921
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
8922
- if (channel?.readyState === "open") {
8923
- const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
8924
- channel.send(update);
8925
- }
8969
+ if (this.router.isOpen(this.syncChannelName)) this.sendSyncStep1();
8970
+ if (this.awareness && this.router.isOpen(this.awarenessChannelName)) {
8971
+ const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
8972
+ this.router.send(this.awarenessChannelName, update);
8926
8973
  }
8927
8974
  }
8928
8975
  /** Stop listening and clean up handlers. */
@@ -8949,12 +8996,11 @@ var YjsDataChannel = class {
8949
8996
  this.detach();
8950
8997
  }
8951
8998
  sendSyncStep1() {
8952
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
8953
- if (!channel || channel.readyState !== "open") return;
8999
+ if (!this.router.isOpen(this.syncChannelName)) return;
8954
9000
  const encoder = createEncoder();
8955
9001
  writeVarUint(encoder, YJS_MSG.SYNC);
8956
9002
  writeSyncStep1(encoder, this.document);
8957
- channel.send(toUint8Array(encoder));
9003
+ this.router.send(this.syncChannelName, toUint8Array(encoder));
8958
9004
  }
8959
9005
  handleSyncMessage(data) {
8960
9006
  const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
@@ -8967,8 +9013,7 @@ var YjsDataChannel = class {
8967
9013
  const responseEncoder = createEncoder();
8968
9014
  writeVarUint(responseEncoder, YJS_MSG.SYNC);
8969
9015
  writeUint8Array(responseEncoder, toUint8Array(encoder));
8970
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
8971
- if (channel?.readyState === "open") channel.send(toUint8Array(responseEncoder));
9016
+ if (this.router.isOpen(this.syncChannelName)) this.router.send(this.syncChannelName, toUint8Array(responseEncoder));
8972
9017
  }
8973
9018
  if (syncMessageType === messageYjsSyncStep2) this.isSynced = true;
8974
9019
  } else if (msgType === YJS_MSG.UPDATE) {
@@ -9218,6 +9263,136 @@ function constantTimeEqual(a, b) {
9218
9263
  return result === 0;
9219
9264
  }
9220
9265
 
9266
+ //#endregion
9267
+ //#region packages/provider/src/webrtc/E2EEChannel.ts
9268
+ /**
9269
+ * E2EEChannel
9270
+ *
9271
+ * Per-peer end-to-end encryption for WebRTC data channels using
9272
+ * X25519 ECDH key agreement + HKDF-SHA256 + AES-256-GCM.
9273
+ *
9274
+ * Leverages the same cryptographic primitives as `DocKeyManager` and
9275
+ * `CryptoIdentityKeystore` but applied to data channel messages rather
9276
+ * than stored document keys.
9277
+ *
9278
+ * Key agreement flow:
9279
+ * 1. Both peers exchange Ed25519 public keys via the `key-exchange` data channel
9280
+ * 2. Convert Ed25519 → X25519 (Montgomery form)
9281
+ * 3. Run X25519 ECDH to derive a shared secret
9282
+ * 4. HKDF-SHA256(sharedSecret, salt, info) → 32-byte AES-256-GCM session key
9283
+ * 5. All subsequent data channel messages are encrypted with this key
9284
+ *
9285
+ * Message wire format: [12-byte nonce || AES-256-GCM ciphertext]
9286
+ *
9287
+ * Dependencies: @noble/curves (already in the project for CryptoIdentityKeystore)
9288
+ */
9289
+ const HKDF_INFO = new TextEncoder().encode("abracadabra-webrtc-e2ee-v1");
9290
+ const NONCE_BYTES = 12;
9291
+ var E2EEChannel = class extends EventEmitter {
9292
+ constructor(identity, docId) {
9293
+ super();
9294
+ this.identity = identity;
9295
+ this.docId = docId;
9296
+ this.sessionKey = null;
9297
+ this.remotePublicKey = null;
9298
+ this._isEstablished = false;
9299
+ }
9300
+ get isEstablished() {
9301
+ return this._isEstablished;
9302
+ }
9303
+ /**
9304
+ * Process a key-exchange message from the remote peer.
9305
+ * Called when the `key-exchange` data channel receives a message.
9306
+ *
9307
+ * The message is the remote peer's raw 32-byte Ed25519 public key.
9308
+ * After receiving it, ECDH is computed and the session key derived.
9309
+ */
9310
+ async handleKeyExchange(remoteEdPubKey) {
9311
+ if (remoteEdPubKey.length !== 32) {
9312
+ this.emit("error", /* @__PURE__ */ new Error("Invalid remote public key length"));
9313
+ return;
9314
+ }
9315
+ this.remotePublicKey = remoteEdPubKey;
9316
+ const remoteX25519Pub = ed25519.utils.toMontgomery(remoteEdPubKey);
9317
+ const sharedSecret = x25519.getSharedSecret(this.identity.x25519PrivateKey, remoteX25519Pub);
9318
+ const localPub = this.identity.publicKey;
9319
+ const remotePub = remoteEdPubKey;
9320
+ const [first, second] = this.sortKeys(localPub, remotePub);
9321
+ const saltParts = [
9322
+ new TextEncoder().encode(this.docId),
9323
+ first,
9324
+ second
9325
+ ];
9326
+ const salt = new Uint8Array(saltParts.reduce((acc, p) => acc + p.length, 0));
9327
+ let offset = 0;
9328
+ for (const part of saltParts) {
9329
+ salt.set(part, offset);
9330
+ offset += part.length;
9331
+ }
9332
+ const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
9333
+ this.sessionKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
9334
+ this._isEstablished = true;
9335
+ this.emit("established", { remotePublicKey: remoteEdPubKey });
9336
+ }
9337
+ /**
9338
+ * Returns the local Ed25519 public key to send to the remote peer
9339
+ * via the `key-exchange` data channel.
9340
+ */
9341
+ getKeyExchangeMessage() {
9342
+ return this.identity.publicKey;
9343
+ }
9344
+ /**
9345
+ * Encrypt a message for sending over a data channel.
9346
+ * Returns `[12-byte nonce || AES-256-GCM ciphertext]`.
9347
+ *
9348
+ * @throws if the session key has not been established yet.
9349
+ */
9350
+ async encrypt(plaintext) {
9351
+ if (!this.sessionKey) throw new Error("E2EE session key not established");
9352
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
9353
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
9354
+ name: "AES-GCM",
9355
+ iv: nonce
9356
+ }, this.sessionKey, plaintext));
9357
+ const result = new Uint8Array(NONCE_BYTES + ciphertext.length);
9358
+ result.set(nonce, 0);
9359
+ result.set(ciphertext, NONCE_BYTES);
9360
+ return result;
9361
+ }
9362
+ /**
9363
+ * Decrypt a message received from a data channel.
9364
+ * Expects `[12-byte nonce || AES-256-GCM ciphertext]`.
9365
+ *
9366
+ * @throws if the session key has not been established or decryption fails.
9367
+ */
9368
+ async decrypt(data) {
9369
+ if (!this.sessionKey) throw new Error("E2EE session key not established");
9370
+ if (data.length < NONCE_BYTES + 16) throw new Error("E2EE ciphertext too short");
9371
+ const nonce = data.slice(0, NONCE_BYTES);
9372
+ const ciphertext = data.slice(NONCE_BYTES);
9373
+ const plaintext = await crypto.subtle.decrypt({
9374
+ name: "AES-GCM",
9375
+ iv: nonce
9376
+ }, this.sessionKey, ciphertext);
9377
+ return new Uint8Array(plaintext);
9378
+ }
9379
+ /** Destroy the session key and wipe sensitive material. */
9380
+ destroy() {
9381
+ this.sessionKey = null;
9382
+ this.remotePublicKey = null;
9383
+ this._isEstablished = false;
9384
+ this.removeAllListeners();
9385
+ }
9386
+ /** Sort two keys lexicographically so both peers produce the same order. */
9387
+ sortKeys(a, b) {
9388
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
9389
+ if (a[i] < b[i]) return [a, b];
9390
+ if (a[i] > b[i]) return [b, a];
9391
+ }
9392
+ return a.length <= b.length ? [a, b] : [b, a];
9393
+ }
9394
+ };
9395
+
9221
9396
  //#endregion
9222
9397
  //#region packages/provider/src/webrtc/AbracadabraWebRTC.ts
9223
9398
  const HAS_RTC = typeof globalThis.RTCPeerConnection !== "undefined";
@@ -9238,6 +9413,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9238
9413
  this.peerConnections = /* @__PURE__ */ new Map();
9239
9414
  this.yjsChannels = /* @__PURE__ */ new Map();
9240
9415
  this.fileChannels = /* @__PURE__ */ new Map();
9416
+ this.e2eeChannels = /* @__PURE__ */ new Map();
9241
9417
  this.peers = /* @__PURE__ */ new Map();
9242
9418
  this.localPeerId = null;
9243
9419
  this.isConnected = false;
@@ -9246,6 +9422,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9246
9422
  this.config = {
9247
9423
  docId: configuration.docId,
9248
9424
  url: configuration.url,
9425
+ signalingUrl: configuration.signalingUrl ?? null,
9249
9426
  token: configuration.token,
9250
9427
  document: doc,
9251
9428
  awareness,
@@ -9256,6 +9433,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9256
9433
  enableAwarenessSync: configuration.enableAwarenessSync ?? !!awareness,
9257
9434
  enableFileTransfer: configuration.enableFileTransfer ?? false,
9258
9435
  fileChunkSize: configuration.fileChunkSize ?? 16384,
9436
+ e2ee: configuration.e2ee ?? null,
9259
9437
  WebSocketPolyfill: configuration.WebSocketPolyfill
9260
9438
  };
9261
9439
  if (configuration.autoConnect !== false && HAS_RTC) this.connect();
@@ -9436,6 +9614,11 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9436
9614
  }
9437
9615
  removePeer(peerId) {
9438
9616
  this.peers.delete(peerId);
9617
+ const e2ee = this.e2eeChannels.get(peerId);
9618
+ if (e2ee) {
9619
+ e2ee.destroy();
9620
+ this.e2eeChannels.delete(peerId);
9621
+ }
9439
9622
  const yjs = this.yjsChannels.get(peerId);
9440
9623
  if (yjs) {
9441
9624
  yjs.destroy();
@@ -9491,12 +9674,39 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9491
9674
  return pc;
9492
9675
  }
9493
9676
  attachDataHandlers(peerId, pc) {
9677
+ if (this.config.e2ee) {
9678
+ const e2ee = new E2EEChannel(this.config.e2ee, this.config.docId);
9679
+ this.e2eeChannels.set(peerId, e2ee);
9680
+ pc.router.setEncryptor(e2ee);
9681
+ pc.router.on("channelMessage", async ({ name, data }) => {
9682
+ if (name === KEY_EXCHANGE_CHANNEL) {
9683
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
9684
+ await e2ee.handleKeyExchange(buf);
9685
+ }
9686
+ });
9687
+ pc.router.on("channelOpen", ({ name, channel }) => {
9688
+ if (name === KEY_EXCHANGE_CHANNEL) channel.send(e2ee.getKeyExchangeMessage());
9689
+ });
9690
+ e2ee.on("established", () => {
9691
+ this.emit("e2eeEstablished", { peerId });
9692
+ this.startDataSync(peerId, pc);
9693
+ });
9694
+ e2ee.on("error", (err) => {
9695
+ this.emit("e2eeFailed", {
9696
+ peerId,
9697
+ error: err
9698
+ });
9699
+ });
9700
+ } else this.startDataSync(peerId, pc);
9701
+ }
9702
+ startDataSync(peerId, pc) {
9494
9703
  if (this.config.document && this.config.enableDocSync) {
9704
+ if (this.yjsChannels.has(peerId)) return;
9495
9705
  const yjs = new YjsDataChannel(this.config.document, this.config.enableAwarenessSync ? this.config.awareness : null, pc.router);
9496
9706
  yjs.attach();
9497
9707
  this.yjsChannels.set(peerId, yjs);
9498
9708
  }
9499
- if (this.config.enableFileTransfer) {
9709
+ if (this.config.enableFileTransfer && !this.fileChannels.has(peerId)) {
9500
9710
  const fc = new FileTransferChannel(pc.router, this.config.fileChunkSize);
9501
9711
  fc.on("receiveStart", (meta) => {
9502
9712
  this.emit("fileReceiveStart", {
@@ -9533,6 +9743,7 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9533
9743
  }
9534
9744
  async initiateConnection(peerId) {
9535
9745
  const pc = this.createPeerConnection(peerId);
9746
+ if (this.config.e2ee) pc.router.createChannel(KEY_EXCHANGE_CHANNEL, { ordered: true });
9536
9747
  pc.router.createDefaultChannels({
9537
9748
  enableDocSync: this.config.enableDocSync,
9538
9749
  enableAwareness: this.config.enableAwarenessSync,
@@ -9564,6 +9775,12 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9564
9775
  }
9565
9776
  }
9566
9777
  buildSignalingUrl() {
9778
+ if (this.config.signalingUrl) {
9779
+ let sigBase = this.config.signalingUrl;
9780
+ while (sigBase.endsWith("/")) sigBase = sigBase.slice(0, -1);
9781
+ sigBase = sigBase.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
9782
+ return `${sigBase}/ws/${encodeURIComponent(this.config.docId)}/signaling`;
9783
+ }
9567
9784
  let base = this.config.url;
9568
9785
  while (base.endsWith("/")) base = base.slice(0, -1);
9569
9786
  base = base.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
@@ -9572,5 +9789,309 @@ var AbracadabraWebRTC = class AbracadabraWebRTC extends EventEmitter {
9572
9789
  };
9573
9790
 
9574
9791
  //#endregion
9575
- export { AbracadabraBaseProvider, AbracadabraClient, AbracadabraProvider, AbracadabraWS, AbracadabraWebRTC, AuthMessageType, AwarenessError, BackgroundSyncManager, BackgroundSyncPersistence, CHANNEL_NAMES, ConnectionTimeout, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DocKeyManager, DocumentCache, E2EAbracadabraProvider, E2EOfflineStore, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, Forbidden, HocuspocusProvider, HocuspocusProviderWebsocket, MessageTooBig, MessageType, OfflineStore, PeerConnection, ResetConnection, SearchIndex, SignalingSocket, SubdocMessage, Unauthorized, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
9792
+ //#region packages/provider/src/webrtc/ManualSignaling.ts
9793
+ /**
9794
+ * ManualSignaling
9795
+ *
9796
+ * Serverless signaling adapter for WebRTC peer-to-peer connections.
9797
+ * Instead of a WebSocket server relaying SDP/ICE, peers exchange
9798
+ * offer and answer "blobs" out-of-band (QR code, copy-paste, NFC, etc.).
9799
+ *
9800
+ * Designed as a drop-in replacement for `SignalingSocket` — emits the
9801
+ * same events (`welcome`, `joined`, `offer`, `answer`, `ice`) so
9802
+ * `AbracadabraWebRTC` can use it transparently.
9803
+ *
9804
+ * Flow:
9805
+ * Device A (initiator):
9806
+ * 1. `createOffer()` → gathers ICE candidates → returns offer blob
9807
+ * 2. Share blob via QR/paste
9808
+ * 3. Receive answer blob → `acceptAnswer(blob)`
9809
+ *
9810
+ * Device B (responder):
9811
+ * 1. Receive offer blob → `acceptOffer(blob)` → returns answer blob
9812
+ * 2. Share answer blob via QR/paste
9813
+ */
9814
+ var ManualSignaling = class extends EventEmitter {
9815
+ constructor(iceServers) {
9816
+ super();
9817
+ this.isConnected = false;
9818
+ this.pc = null;
9819
+ this.localPeerId = crypto.randomUUID();
9820
+ this.iceServers = iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }];
9821
+ }
9822
+ /**
9823
+ * Initiator: create an offer blob with gathered ICE candidates.
9824
+ * Returns a blob to share with the remote peer.
9825
+ */
9826
+ async createOfferBlob() {
9827
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
9828
+ const candidates = [];
9829
+ const gatheringComplete = new Promise((resolve) => {
9830
+ this.pc.onicecandidate = (event) => {
9831
+ if (event.candidate) candidates.push(JSON.stringify(event.candidate.toJSON()));
9832
+ else resolve();
9833
+ };
9834
+ });
9835
+ const offer = await this.pc.createOffer();
9836
+ await this.pc.setLocalDescription(offer);
9837
+ await gatheringComplete;
9838
+ this.isConnected = true;
9839
+ this.emit("welcome", {
9840
+ peerId: this.localPeerId,
9841
+ peers: []
9842
+ });
9843
+ return {
9844
+ sdp: this.pc.localDescription.sdp,
9845
+ candidates,
9846
+ peerId: this.localPeerId
9847
+ };
9848
+ }
9849
+ /**
9850
+ * Responder: accept an offer blob and create an answer blob.
9851
+ * The answer blob should be shared back to the initiator.
9852
+ */
9853
+ async acceptOffer(offerBlob) {
9854
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
9855
+ const candidates = [];
9856
+ const gatheringComplete = new Promise((resolve) => {
9857
+ this.pc.onicecandidate = (event) => {
9858
+ if (event.candidate) candidates.push(JSON.stringify(event.candidate.toJSON()));
9859
+ else resolve();
9860
+ };
9861
+ });
9862
+ await this.pc.setRemoteDescription(new RTCSessionDescription({
9863
+ type: "offer",
9864
+ sdp: offerBlob.sdp
9865
+ }));
9866
+ for (const c of offerBlob.candidates) await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
9867
+ const answer = await this.pc.createAnswer();
9868
+ await this.pc.setLocalDescription(answer);
9869
+ await gatheringComplete;
9870
+ this.isConnected = true;
9871
+ this.emit("welcome", {
9872
+ peerId: this.localPeerId,
9873
+ peers: []
9874
+ });
9875
+ this.emit("joined", {
9876
+ peer_id: offerBlob.peerId,
9877
+ user_id: offerBlob.peerId,
9878
+ muted: false,
9879
+ video: false,
9880
+ screen: false,
9881
+ name: null,
9882
+ color: null
9883
+ });
9884
+ this.emit("offer", {
9885
+ from: offerBlob.peerId,
9886
+ sdp: offerBlob.sdp
9887
+ });
9888
+ for (const c of offerBlob.candidates) this.emit("ice", {
9889
+ from: offerBlob.peerId,
9890
+ candidate: c
9891
+ });
9892
+ return {
9893
+ sdp: this.pc.localDescription.sdp,
9894
+ candidates,
9895
+ peerId: this.localPeerId
9896
+ };
9897
+ }
9898
+ /**
9899
+ * Initiator: accept the answer blob from the responder.
9900
+ * Completes the connection.
9901
+ */
9902
+ async acceptAnswer(answerBlob) {
9903
+ if (!this.pc) throw new Error("Call createOfferBlob() first");
9904
+ this.emit("joined", {
9905
+ peer_id: answerBlob.peerId,
9906
+ user_id: answerBlob.peerId,
9907
+ muted: false,
9908
+ video: false,
9909
+ screen: false,
9910
+ name: null,
9911
+ color: null
9912
+ });
9913
+ this.emit("answer", {
9914
+ from: answerBlob.peerId,
9915
+ sdp: answerBlob.sdp
9916
+ });
9917
+ for (const c of answerBlob.candidates) this.emit("ice", {
9918
+ from: answerBlob.peerId,
9919
+ candidate: c
9920
+ });
9921
+ }
9922
+ sendOffer(_to, _sdp) {}
9923
+ sendAnswer(_to, _sdp) {}
9924
+ sendIce(_to, _candidate) {}
9925
+ sendMute(_muted) {}
9926
+ sendMediaState(_video, _screen) {}
9927
+ sendProfile(_name, _color) {}
9928
+ sendLeave() {}
9929
+ async connect() {}
9930
+ disconnect() {
9931
+ this.isConnected = false;
9932
+ if (this.pc) {
9933
+ this.pc.close();
9934
+ this.pc = null;
9935
+ }
9936
+ this.emit("disconnected");
9937
+ }
9938
+ destroy() {
9939
+ this.disconnect();
9940
+ this.removeAllListeners();
9941
+ }
9942
+ };
9943
+
9944
+ //#endregion
9945
+ //#region packages/provider/src/sync/BroadcastChannelSync.ts
9946
+ /**
9947
+ * Cross-tab Y.js document and awareness sync via the BroadcastChannel API.
9948
+ *
9949
+ * Opens a BroadcastChannel per document, relays Y.js updates and awareness
9950
+ * state between tabs on the same origin. No server, no WebRTC — just same-device
9951
+ * multi-tab sync. Same-origin means same trust boundary, so no encryption needed.
9952
+ *
9953
+ * Uses the same y-protocols/sync encoding as `YjsDataChannel` for consistency.
9954
+ */
9955
+ const CHANNEL_PREFIX = "abra:sync:";
9956
+ /** Message type discriminators (first byte). */
9957
+ const MSG = {
9958
+ SYNC: 0,
9959
+ UPDATE: 1,
9960
+ AWARENESS: 2,
9961
+ QUERY_PEERS: 3
9962
+ };
9963
+ var BroadcastChannelSync = class BroadcastChannelSync extends EventEmitter {
9964
+ constructor(document, awareness, channelName) {
9965
+ super();
9966
+ this.document = document;
9967
+ this.awareness = awareness;
9968
+ this.channelName = channelName;
9969
+ this.channel = null;
9970
+ this.docUpdateHandler = null;
9971
+ this.awarenessUpdateHandler = null;
9972
+ this.destroyed = false;
9973
+ }
9974
+ /** Convenience factory using standard channel naming. */
9975
+ static forDoc(document, docId, awareness) {
9976
+ return new BroadcastChannelSync(document, awareness ?? null, `${CHANNEL_PREFIX}${docId}`);
9977
+ }
9978
+ /** Start syncing. Opens the BroadcastChannel and initiates a sync handshake. */
9979
+ connect() {
9980
+ if (this.destroyed || this.channel) return;
9981
+ if (typeof globalThis.BroadcastChannel === "undefined") return;
9982
+ this.channel = new BroadcastChannel(this.channelName);
9983
+ this.channel.onmessage = (event) => this.handleMessage(event.data);
9984
+ this.docUpdateHandler = (update, origin) => {
9985
+ if (origin === this) return;
9986
+ this.broadcastUpdate(update);
9987
+ };
9988
+ this.document.on("update", this.docUpdateHandler);
9989
+ if (this.awareness) {
9990
+ this.awarenessUpdateHandler = ({ added, updated, removed }, _origin) => {
9991
+ const changedClients = added.concat(updated).concat(removed);
9992
+ const update = encodeAwarenessUpdate(this.awareness, changedClients);
9993
+ this.broadcastAwareness(update);
9994
+ };
9995
+ this.awareness.on("update", this.awarenessUpdateHandler);
9996
+ }
9997
+ this.broadcastQueryPeers();
9998
+ this.sendSyncStep1();
9999
+ this.emit("connected");
10000
+ }
10001
+ /** Stop syncing and close the BroadcastChannel. */
10002
+ disconnect() {
10003
+ if (this.docUpdateHandler) {
10004
+ this.document.off("update", this.docUpdateHandler);
10005
+ this.docUpdateHandler = null;
10006
+ }
10007
+ if (this.awarenessUpdateHandler && this.awareness) {
10008
+ this.awareness.off("update", this.awarenessUpdateHandler);
10009
+ this.awarenessUpdateHandler = null;
10010
+ }
10011
+ if (this.channel) {
10012
+ this.channel.close();
10013
+ this.channel = null;
10014
+ }
10015
+ this.emit("disconnected");
10016
+ }
10017
+ /** Disconnect and prevent reconnection. */
10018
+ destroy() {
10019
+ this.disconnect();
10020
+ this.destroyed = true;
10021
+ this.removeAllListeners();
10022
+ }
10023
+ broadcastUpdate(update) {
10024
+ if (!this.channel) return;
10025
+ const encoder = createEncoder();
10026
+ writeVarUint(encoder, MSG.UPDATE);
10027
+ writeVarUint8Array(encoder, update);
10028
+ this.channel.postMessage(toUint8Array(encoder));
10029
+ }
10030
+ broadcastAwareness(update) {
10031
+ if (!this.channel) return;
10032
+ const encoder = createEncoder();
10033
+ writeVarUint(encoder, MSG.AWARENESS);
10034
+ writeVarUint8Array(encoder, update);
10035
+ this.channel.postMessage(toUint8Array(encoder));
10036
+ }
10037
+ sendSyncStep1() {
10038
+ if (!this.channel) return;
10039
+ const encoder = createEncoder();
10040
+ writeVarUint(encoder, MSG.SYNC);
10041
+ writeSyncStep1(encoder, this.document);
10042
+ this.channel.postMessage(toUint8Array(encoder));
10043
+ }
10044
+ broadcastQueryPeers() {
10045
+ if (!this.channel) return;
10046
+ const encoder = createEncoder();
10047
+ writeVarUint(encoder, MSG.QUERY_PEERS);
10048
+ this.channel.postMessage(toUint8Array(encoder));
10049
+ }
10050
+ handleMessage(data) {
10051
+ if (!(data instanceof ArrayBuffer || data instanceof Uint8Array)) return;
10052
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
10053
+ const decoder = createDecoder(buf);
10054
+ switch (readVarUint(decoder)) {
10055
+ case MSG.SYNC:
10056
+ this.handleSyncMessage(decoder);
10057
+ break;
10058
+ case MSG.UPDATE:
10059
+ this.handleUpdateMessage(decoder);
10060
+ break;
10061
+ case MSG.AWARENESS:
10062
+ this.handleAwarenessMessage(decoder);
10063
+ break;
10064
+ case MSG.QUERY_PEERS:
10065
+ this.sendSyncStep1();
10066
+ if (this.awareness) {
10067
+ const update = encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()));
10068
+ this.broadcastAwareness(update);
10069
+ }
10070
+ break;
10071
+ }
10072
+ }
10073
+ handleSyncMessage(decoder) {
10074
+ const encoder = createEncoder();
10075
+ const syncMessageType = readSyncMessage(decoder, encoder, this.document, this);
10076
+ if (length(encoder) > 0) {
10077
+ const responseEncoder = createEncoder();
10078
+ writeVarUint(responseEncoder, MSG.SYNC);
10079
+ writeUint8Array(responseEncoder, toUint8Array(encoder));
10080
+ this.channel?.postMessage(toUint8Array(responseEncoder));
10081
+ }
10082
+ if (syncMessageType === messageYjsSyncStep2) this.emit("synced");
10083
+ }
10084
+ handleUpdateMessage(decoder) {
10085
+ const update = readVarUint8Array(decoder);
10086
+ Y.applyUpdate(this.document, update, this);
10087
+ }
10088
+ handleAwarenessMessage(decoder) {
10089
+ if (!this.awareness) return;
10090
+ const update = readVarUint8Array(decoder);
10091
+ applyAwarenessUpdate(this.awareness, update, this);
10092
+ }
10093
+ };
10094
+
10095
+ //#endregion
10096
+ export { AbracadabraBaseProvider, AbracadabraClient, AbracadabraProvider, AbracadabraWS, AbracadabraWebRTC, AuthMessageType, AwarenessError, BackgroundSyncManager, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ConnectionTimeout, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DocKeyManager, DocumentCache, E2EAbracadabraProvider, E2EEChannel, E2EOfflineStore, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, Forbidden, HocuspocusProvider, HocuspocusProviderWebsocket, KEY_EXCHANGE_CHANNEL, ManualSignaling, MessageTooBig, MessageType, OfflineStore, PeerConnection, ResetConnection, SearchIndex, SignalingSocket, SubdocMessage, Unauthorized, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
9576
10097
  //# sourceMappingURL=abracadabra-provider.esm.js.map