@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.
@@ -1,8 +1,15 @@
1
1
  import EventEmitter from "../EventEmitter.ts";
2
+ import type { E2EEChannel } from "./E2EEChannel.ts";
2
3
  import { CHANNEL_NAMES } from "./types.ts";
3
4
 
5
+ /** Name of the data channel used for E2EE key exchange. */
6
+ export const KEY_EXCHANGE_CHANNEL = "key-exchange";
7
+
4
8
  export class DataChannelRouter extends EventEmitter {
5
9
  private channels = new Map<string, RTCDataChannel>();
10
+ private encryptor: E2EEChannel | null = null;
11
+ /** Channels excluded from encryption (key-exchange itself, awareness). */
12
+ private plaintextChannels = new Set<string>([KEY_EXCHANGE_CHANNEL]);
6
13
 
7
14
  constructor(private connection: RTCPeerConnection) {
8
15
  super();
@@ -13,6 +20,11 @@ export class DataChannelRouter extends EventEmitter {
13
20
  };
14
21
  }
15
22
 
23
+ /** Attach an E2EE encryptor. All channels (except key-exchange) will be encrypted. */
24
+ setEncryptor(encryptor: E2EEChannel): void {
25
+ this.encryptor = encryptor;
26
+ }
27
+
16
28
  /** Create a named data channel (initiator side). */
17
29
  createChannel(
18
30
  name: string,
@@ -49,6 +61,27 @@ export class DataChannelRouter extends EventEmitter {
49
61
  }
50
62
  }
51
63
 
64
+ /**
65
+ * Create namespaced channels for a child/subdocument.
66
+ * Channel names are prefixed with `{childId}:` to avoid collisions.
67
+ */
68
+ createSubdocChannels(childId: string, opts: {
69
+ enableDocSync: boolean;
70
+ enableAwareness: boolean;
71
+ }): void {
72
+ if (opts.enableDocSync) {
73
+ this.createChannel(`${childId}:${CHANNEL_NAMES.YJS_SYNC}`, {
74
+ ordered: true,
75
+ });
76
+ }
77
+ if (opts.enableAwareness) {
78
+ this.createChannel(`${childId}:${CHANNEL_NAMES.AWARENESS}`, {
79
+ ordered: false,
80
+ maxRetransmits: 0,
81
+ });
82
+ }
83
+ }
84
+
52
85
  getChannel(name: string): RTCDataChannel | null {
53
86
  return this.channels.get(name) ?? null;
54
87
  }
@@ -57,6 +90,22 @@ export class DataChannelRouter extends EventEmitter {
57
90
  return this.channels.get(name)?.readyState === "open";
58
91
  }
59
92
 
93
+ /**
94
+ * Send data on a named channel, encrypting if E2EE is active.
95
+ * Falls back to plaintext if no encryptor is set or for exempt channels.
96
+ */
97
+ async send(name: string, data: Uint8Array): Promise<void> {
98
+ const channel = this.channels.get(name);
99
+ if (!channel || channel.readyState !== "open") return;
100
+
101
+ if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
102
+ const encrypted = await this.encryptor.encrypt(data);
103
+ channel.send(encrypted);
104
+ } else {
105
+ channel.send(data);
106
+ }
107
+ }
108
+
60
109
  private registerChannel(channel: RTCDataChannel): void {
61
110
  channel.binaryType = "arraybuffer";
62
111
  this.channels.set(channel.label, channel);
@@ -70,11 +119,30 @@ export class DataChannelRouter extends EventEmitter {
70
119
  this.channels.delete(channel.label);
71
120
  };
72
121
 
73
- channel.onmessage = (event) => {
74
- this.emit("channelMessage", {
75
- name: channel.label,
76
- data: event.data,
77
- });
122
+ channel.onmessage = async (event) => {
123
+ const name = channel.label;
124
+ let data = event.data;
125
+
126
+ // Decrypt incoming data if E2EE is active (skip plaintext channels).
127
+ if (
128
+ this.encryptor?.isEstablished &&
129
+ !this.plaintextChannels.has(name) &&
130
+ (data instanceof ArrayBuffer || data instanceof Uint8Array)
131
+ ) {
132
+ try {
133
+ const buf =
134
+ data instanceof ArrayBuffer ? new Uint8Array(data) : data;
135
+ data = await this.encryptor.decrypt(buf);
136
+ } catch (err) {
137
+ this.emit("channelError", {
138
+ name,
139
+ error: err,
140
+ });
141
+ return;
142
+ }
143
+ }
144
+
145
+ this.emit("channelMessage", { name, data });
78
146
  };
79
147
 
80
148
  channel.onerror = (event) => {
@@ -0,0 +1,195 @@
1
+ /**
2
+ * E2EEChannel
3
+ *
4
+ * Per-peer end-to-end encryption for WebRTC data channels using
5
+ * X25519 ECDH key agreement + HKDF-SHA256 + AES-256-GCM.
6
+ *
7
+ * Leverages the same cryptographic primitives as `DocKeyManager` and
8
+ * `CryptoIdentityKeystore` but applied to data channel messages rather
9
+ * than stored document keys.
10
+ *
11
+ * Key agreement flow:
12
+ * 1. Both peers exchange Ed25519 public keys via the `key-exchange` data channel
13
+ * 2. Convert Ed25519 → X25519 (Montgomery form)
14
+ * 3. Run X25519 ECDH to derive a shared secret
15
+ * 4. HKDF-SHA256(sharedSecret, salt, info) → 32-byte AES-256-GCM session key
16
+ * 5. All subsequent data channel messages are encrypted with this key
17
+ *
18
+ * Message wire format: [12-byte nonce || AES-256-GCM ciphertext]
19
+ *
20
+ * Dependencies: @noble/curves (already in the project for CryptoIdentityKeystore)
21
+ */
22
+
23
+ import { x25519, ed25519 as nobleEd25519 } from "@noble/curves/ed25519.js";
24
+ import { hkdf } from "@noble/hashes/hkdf";
25
+ import { sha256 } from "@noble/hashes/sha256";
26
+ import EventEmitter from "../EventEmitter.ts";
27
+
28
+ const HKDF_INFO = new TextEncoder().encode("abracadabra-webrtc-e2ee-v1");
29
+ const NONCE_BYTES = 12;
30
+
31
+ export interface E2EEIdentity {
32
+ /** Raw 32-byte Ed25519 public key. */
33
+ publicKey: Uint8Array;
34
+ /** Raw 32-byte X25519 private key. Caller must wipe after E2EEChannel.destroy(). */
35
+ x25519PrivateKey: Uint8Array;
36
+ }
37
+
38
+ export class E2EEChannel extends EventEmitter {
39
+ private sessionKey: CryptoKey | null = null;
40
+ private remotePublicKey: Uint8Array | null = null;
41
+ private _isEstablished = false;
42
+
43
+ constructor(
44
+ private readonly identity: E2EEIdentity,
45
+ private readonly docId: string,
46
+ ) {
47
+ super();
48
+ }
49
+
50
+ get isEstablished(): boolean {
51
+ return this._isEstablished;
52
+ }
53
+
54
+ /**
55
+ * Process a key-exchange message from the remote peer.
56
+ * Called when the `key-exchange` data channel receives a message.
57
+ *
58
+ * The message is the remote peer's raw 32-byte Ed25519 public key.
59
+ * After receiving it, ECDH is computed and the session key derived.
60
+ */
61
+ async handleKeyExchange(remoteEdPubKey: Uint8Array): Promise<void> {
62
+ if (remoteEdPubKey.length !== 32) {
63
+ this.emit("error", new Error("Invalid remote public key length"));
64
+ return;
65
+ }
66
+
67
+ this.remotePublicKey = remoteEdPubKey;
68
+
69
+ // Convert remote Ed25519 public key to X25519 (Montgomery form).
70
+ const remoteX25519Pub = nobleEd25519.utils.toMontgomery(remoteEdPubKey);
71
+
72
+ // X25519 ECDH: compute shared secret.
73
+ const sharedSecret = x25519.getSharedSecret(
74
+ this.identity.x25519PrivateKey,
75
+ remoteX25519Pub,
76
+ );
77
+
78
+ // Deterministic salt: docId + sorted public keys (ensures both peers derive the same key).
79
+ const localPub = this.identity.publicKey;
80
+ const remotePub = remoteEdPubKey;
81
+ const [first, second] = this.sortKeys(localPub, remotePub);
82
+ const saltParts = [
83
+ new TextEncoder().encode(this.docId),
84
+ first,
85
+ second,
86
+ ];
87
+ const salt = new Uint8Array(
88
+ saltParts.reduce((acc, p) => acc + p.length, 0),
89
+ );
90
+ let offset = 0;
91
+ for (const part of saltParts) {
92
+ salt.set(part, offset);
93
+ offset += part.length;
94
+ }
95
+
96
+ // HKDF-SHA256 → 32-byte AES key.
97
+ const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
98
+
99
+ this.sessionKey = await crypto.subtle.importKey(
100
+ "raw",
101
+ keyBytes,
102
+ { name: "AES-GCM" },
103
+ false,
104
+ ["encrypt", "decrypt"],
105
+ );
106
+
107
+ this._isEstablished = true;
108
+ this.emit("established", { remotePublicKey: remoteEdPubKey });
109
+ }
110
+
111
+ /**
112
+ * Returns the local Ed25519 public key to send to the remote peer
113
+ * via the `key-exchange` data channel.
114
+ */
115
+ getKeyExchangeMessage(): Uint8Array {
116
+ return this.identity.publicKey;
117
+ }
118
+
119
+ /**
120
+ * Encrypt a message for sending over a data channel.
121
+ * Returns `[12-byte nonce || AES-256-GCM ciphertext]`.
122
+ *
123
+ * @throws if the session key has not been established yet.
124
+ */
125
+ async encrypt(plaintext: Uint8Array): Promise<Uint8Array> {
126
+ if (!this.sessionKey) {
127
+ throw new Error("E2EE session key not established");
128
+ }
129
+
130
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
131
+ const ciphertext = new Uint8Array(
132
+ await crypto.subtle.encrypt(
133
+ { name: "AES-GCM", iv: nonce },
134
+ this.sessionKey,
135
+ plaintext,
136
+ ),
137
+ );
138
+
139
+ // Wire format: [nonce(12) || ciphertext].
140
+ const result = new Uint8Array(NONCE_BYTES + ciphertext.length);
141
+ result.set(nonce, 0);
142
+ result.set(ciphertext, NONCE_BYTES);
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Decrypt a message received from a data channel.
148
+ * Expects `[12-byte nonce || AES-256-GCM ciphertext]`.
149
+ *
150
+ * @throws if the session key has not been established or decryption fails.
151
+ */
152
+ async decrypt(data: Uint8Array): Promise<Uint8Array> {
153
+ if (!this.sessionKey) {
154
+ throw new Error("E2EE session key not established");
155
+ }
156
+
157
+ if (data.length < NONCE_BYTES + 16) {
158
+ // Minimum: nonce + AES-GCM tag (16 bytes).
159
+ throw new Error("E2EE ciphertext too short");
160
+ }
161
+
162
+ const nonce = data.slice(0, NONCE_BYTES);
163
+ const ciphertext = data.slice(NONCE_BYTES);
164
+
165
+ const plaintext = await crypto.subtle.decrypt(
166
+ { name: "AES-GCM", iv: nonce },
167
+ this.sessionKey,
168
+ ciphertext,
169
+ );
170
+
171
+ return new Uint8Array(plaintext);
172
+ }
173
+
174
+ /** Destroy the session key and wipe sensitive material. */
175
+ destroy(): void {
176
+ this.sessionKey = null;
177
+ this.remotePublicKey = null;
178
+ this._isEstablished = false;
179
+ this.removeAllListeners();
180
+ }
181
+
182
+ // ── Private ──────────────────────────────────────────────────────────────
183
+
184
+ /** Sort two keys lexicographically so both peers produce the same order. */
185
+ private sortKeys(
186
+ a: Uint8Array,
187
+ b: Uint8Array,
188
+ ): [Uint8Array, Uint8Array] {
189
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
190
+ if (a[i] < b[i]) return [a, b];
191
+ if (a[i] > b[i]) return [b, a];
192
+ }
193
+ return a.length <= b.length ? [a, b] : [b, a];
194
+ }
195
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ManualSignaling
3
+ *
4
+ * Serverless signaling adapter for WebRTC peer-to-peer connections.
5
+ * Instead of a WebSocket server relaying SDP/ICE, peers exchange
6
+ * offer and answer "blobs" out-of-band (QR code, copy-paste, NFC, etc.).
7
+ *
8
+ * Designed as a drop-in replacement for `SignalingSocket` — emits the
9
+ * same events (`welcome`, `joined`, `offer`, `answer`, `ice`) so
10
+ * `AbracadabraWebRTC` can use it transparently.
11
+ *
12
+ * Flow:
13
+ * Device A (initiator):
14
+ * 1. `createOffer()` → gathers ICE candidates → returns offer blob
15
+ * 2. Share blob via QR/paste
16
+ * 3. Receive answer blob → `acceptAnswer(blob)`
17
+ *
18
+ * Device B (responder):
19
+ * 1. Receive offer blob → `acceptOffer(blob)` → returns answer blob
20
+ * 2. Share answer blob via QR/paste
21
+ */
22
+
23
+ import EventEmitter from "../EventEmitter.ts";
24
+
25
+ export interface ManualSignalingBlob {
26
+ /** SDP offer or answer. */
27
+ sdp: string;
28
+ /** Gathered ICE candidates. */
29
+ candidates: string[];
30
+ /** Peer ID of the sender. */
31
+ peerId: string;
32
+ }
33
+
34
+ export class ManualSignaling extends EventEmitter {
35
+ public localPeerId: string;
36
+ public isConnected = false;
37
+
38
+ private pc: RTCPeerConnection | null = null;
39
+ private iceServers: RTCIceServer[];
40
+
41
+ constructor(iceServers?: RTCIceServer[]) {
42
+ super();
43
+ this.localPeerId = crypto.randomUUID();
44
+ this.iceServers = iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }];
45
+ }
46
+
47
+ /**
48
+ * Initiator: create an offer blob with gathered ICE candidates.
49
+ * Returns a blob to share with the remote peer.
50
+ */
51
+ async createOfferBlob(): Promise<ManualSignalingBlob> {
52
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
53
+
54
+ const candidates: string[] = [];
55
+ const gatheringComplete = new Promise<void>((resolve) => {
56
+ this.pc!.onicecandidate = (event) => {
57
+ if (event.candidate) {
58
+ candidates.push(JSON.stringify(event.candidate.toJSON()));
59
+ } else {
60
+ // Null candidate = gathering complete.
61
+ resolve();
62
+ }
63
+ };
64
+ });
65
+
66
+ const offer = await this.pc.createOffer();
67
+ await this.pc.setLocalDescription(offer);
68
+ await gatheringComplete;
69
+
70
+ // Emit welcome (as if we connected to signaling).
71
+ this.isConnected = true;
72
+ this.emit("welcome", { peerId: this.localPeerId, peers: [] });
73
+
74
+ return {
75
+ sdp: this.pc.localDescription!.sdp,
76
+ candidates,
77
+ peerId: this.localPeerId,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Responder: accept an offer blob and create an answer blob.
83
+ * The answer blob should be shared back to the initiator.
84
+ */
85
+ async acceptOffer(offerBlob: ManualSignalingBlob): Promise<ManualSignalingBlob> {
86
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
87
+
88
+ const candidates: string[] = [];
89
+ const gatheringComplete = new Promise<void>((resolve) => {
90
+ this.pc!.onicecandidate = (event) => {
91
+ if (event.candidate) {
92
+ candidates.push(JSON.stringify(event.candidate.toJSON()));
93
+ } else {
94
+ resolve();
95
+ }
96
+ };
97
+ });
98
+
99
+ // Set remote offer.
100
+ await this.pc.setRemoteDescription(
101
+ new RTCSessionDescription({ type: "offer", sdp: offerBlob.sdp }),
102
+ );
103
+
104
+ // Add remote ICE candidates.
105
+ for (const c of offerBlob.candidates) {
106
+ await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
107
+ }
108
+
109
+ // Create answer.
110
+ const answer = await this.pc.createAnswer();
111
+ await this.pc.setLocalDescription(answer);
112
+ await gatheringComplete;
113
+
114
+ // Emit welcome + joined (remote peer from the offer).
115
+ this.isConnected = true;
116
+ this.emit("welcome", { peerId: this.localPeerId, peers: [] });
117
+ this.emit("joined", {
118
+ peer_id: offerBlob.peerId,
119
+ user_id: offerBlob.peerId,
120
+ muted: false,
121
+ video: false,
122
+ screen: false,
123
+ name: null,
124
+ color: null,
125
+ });
126
+
127
+ // Emit the offer so AbracadabraWebRTC handles it.
128
+ this.emit("offer", { from: offerBlob.peerId, sdp: offerBlob.sdp });
129
+
130
+ // Feed ICE candidates.
131
+ for (const c of offerBlob.candidates) {
132
+ this.emit("ice", { from: offerBlob.peerId, candidate: c });
133
+ }
134
+
135
+ return {
136
+ sdp: this.pc.localDescription!.sdp,
137
+ candidates,
138
+ peerId: this.localPeerId,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Initiator: accept the answer blob from the responder.
144
+ * Completes the connection.
145
+ */
146
+ async acceptAnswer(answerBlob: ManualSignalingBlob): Promise<void> {
147
+ if (!this.pc) throw new Error("Call createOfferBlob() first");
148
+
149
+ // Emit joined for the remote peer.
150
+ this.emit("joined", {
151
+ peer_id: answerBlob.peerId,
152
+ user_id: answerBlob.peerId,
153
+ muted: false,
154
+ video: false,
155
+ screen: false,
156
+ name: null,
157
+ color: null,
158
+ });
159
+
160
+ // Emit the answer so AbracadabraWebRTC handles it.
161
+ this.emit("answer", { from: answerBlob.peerId, sdp: answerBlob.sdp });
162
+
163
+ // Feed ICE candidates.
164
+ for (const c of answerBlob.candidates) {
165
+ this.emit("ice", { from: answerBlob.peerId, candidate: c });
166
+ }
167
+ }
168
+
169
+ // ── SignalingSocket-compatible stubs ─────────────────────────────────────
170
+ // These are no-ops since manual signaling doesn't relay through a server.
171
+
172
+ sendOffer(_to: string, _sdp: string): void {}
173
+ sendAnswer(_to: string, _sdp: string): void {}
174
+ sendIce(_to: string, _candidate: string): void {}
175
+ sendMute(_muted: boolean): void {}
176
+ sendMediaState(_video: boolean, _screen: boolean): void {}
177
+ sendProfile(_name: string, _color: string): void {}
178
+ sendLeave(): void {}
179
+
180
+ async connect(): Promise<void> {
181
+ // No-op — connection is initiated via createOfferBlob() or acceptOffer().
182
+ }
183
+
184
+ disconnect(): void {
185
+ this.isConnected = false;
186
+ if (this.pc) {
187
+ this.pc.close();
188
+ this.pc = null;
189
+ }
190
+ this.emit("disconnected");
191
+ }
192
+
193
+ destroy(): void {
194
+ this.disconnect();
195
+ this.removeAllListeners();
196
+ }
197
+ }
@@ -23,11 +23,26 @@ export class YjsDataChannel {
23
23
  private channelOpenHandler: ((data: { name: string; channel: RTCDataChannel }) => void) | null = null;
24
24
  private channelMessageHandler: ((data: { name: string; data: any }) => void) | null = null;
25
25
 
26
+ /** Channel names used for sync and awareness (supports namespaced subdoc channels). */
27
+ private readonly syncChannelName: string;
28
+ private readonly awarenessChannelName: string;
29
+
30
+ /**
31
+ * @param document - The Y.Doc to sync
32
+ * @param awareness - Optional Awareness instance
33
+ * @param router - DataChannelRouter for the peer connection
34
+ * @param channelPrefix - Optional prefix for subdocument channels (e.g. `"{childId}:"`)
35
+ */
26
36
  constructor(
27
37
  private readonly document: Y.Doc,
28
38
  private readonly awareness: Awareness | null,
29
39
  private readonly router: DataChannelRouter,
30
- ) {}
40
+ channelPrefix?: string,
41
+ ) {
42
+ const prefix = channelPrefix ?? "";
43
+ this.syncChannelName = `${prefix}${CHANNEL_NAMES.YJS_SYNC}`;
44
+ this.awarenessChannelName = `${prefix}${CHANNEL_NAMES.AWARENESS}`;
45
+ }
31
46
 
32
47
  /** Start listening for Y.js updates and data channel messages. */
33
48
  attach(): void {
@@ -36,13 +51,12 @@ export class YjsDataChannel {
36
51
  // Don't echo updates we received from this data channel.
37
52
  if (origin === this) return;
38
53
 
39
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
40
- if (!channel || channel.readyState !== "open") return;
54
+ if (!this.router.isOpen(this.syncChannelName)) return;
41
55
 
42
56
  const encoder = encoding.createEncoder();
43
57
  encoding.writeVarUint(encoder, YJS_MSG.UPDATE);
44
58
  encoding.writeVarUint8Array(encoder, update);
45
- channel.send(encoding.toUint8Array(encoder));
59
+ this.router.send(this.syncChannelName, encoding.toUint8Array(encoder));
46
60
  };
47
61
  this.document.on("update", this.docUpdateHandler);
48
62
 
@@ -52,21 +66,20 @@ export class YjsDataChannel {
52
66
  { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
53
67
  _origin: any,
54
68
  ) => {
55
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
56
- if (!channel || channel.readyState !== "open") return;
69
+ if (!this.router.isOpen(this.awarenessChannelName)) return;
57
70
 
58
71
  const changedClients = added.concat(updated).concat(removed);
59
72
  const update = encodeAwarenessUpdate(this.awareness!, changedClients);
60
- channel.send(update);
73
+ this.router.send(this.awarenessChannelName, update);
61
74
  };
62
75
  this.awareness.on("update", this.awarenessUpdateHandler);
63
76
  }
64
77
 
65
78
  // Handle incoming data channel messages.
66
79
  this.channelMessageHandler = ({ name, data }: { name: string; data: any }) => {
67
- if (name === CHANNEL_NAMES.YJS_SYNC) {
80
+ if (name === this.syncChannelName) {
68
81
  this.handleSyncMessage(data);
69
- } else if (name === CHANNEL_NAMES.AWARENESS) {
82
+ } else if (name === this.awarenessChannelName) {
70
83
  this.handleAwarenessMessage(data);
71
84
  }
72
85
  };
@@ -74,37 +87,33 @@ export class YjsDataChannel {
74
87
 
75
88
  // When sync channel opens, initiate sync handshake.
76
89
  this.channelOpenHandler = ({ name }: { name: string; channel: RTCDataChannel }) => {
77
- if (name === CHANNEL_NAMES.YJS_SYNC) {
90
+ if (name === this.syncChannelName) {
78
91
  this.sendSyncStep1();
79
- } else if (name === CHANNEL_NAMES.AWARENESS && this.awareness) {
92
+ } else if (name === this.awarenessChannelName && this.awareness) {
80
93
  // Send full awareness state on channel open.
81
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
82
- if (channel?.readyState === "open") {
94
+ if (this.router.isOpen(this.awarenessChannelName)) {
83
95
  const update = encodeAwarenessUpdate(
84
96
  this.awareness,
85
97
  Array.from(this.awareness.getStates().keys()),
86
98
  );
87
- channel.send(update);
99
+ this.router.send(this.awarenessChannelName, update);
88
100
  }
89
101
  }
90
102
  };
91
103
  this.router.on("channelOpen", this.channelOpenHandler);
92
104
 
93
105
  // If sync channel is already open, start sync immediately.
94
- if (this.router.isOpen(CHANNEL_NAMES.YJS_SYNC)) {
106
+ if (this.router.isOpen(this.syncChannelName)) {
95
107
  this.sendSyncStep1();
96
108
  }
97
109
 
98
110
  // If awareness channel is already open, send state immediately.
99
- if (this.awareness && this.router.isOpen(CHANNEL_NAMES.AWARENESS)) {
100
- const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
101
- if (channel?.readyState === "open") {
102
- const update = encodeAwarenessUpdate(
103
- this.awareness,
104
- Array.from(this.awareness.getStates().keys()),
105
- );
106
- channel.send(update);
107
- }
111
+ if (this.awareness && this.router.isOpen(this.awarenessChannelName)) {
112
+ const update = encodeAwarenessUpdate(
113
+ this.awareness,
114
+ Array.from(this.awareness.getStates().keys()),
115
+ );
116
+ this.router.send(this.awarenessChannelName, update);
108
117
  }
109
118
  }
110
119
 
@@ -138,13 +147,12 @@ export class YjsDataChannel {
138
147
  }
139
148
 
140
149
  private sendSyncStep1(): void {
141
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
142
- if (!channel || channel.readyState !== "open") return;
150
+ if (!this.router.isOpen(this.syncChannelName)) return;
143
151
 
144
152
  const encoder = encoding.createEncoder();
145
153
  encoding.writeVarUint(encoder, YJS_MSG.SYNC);
146
154
  syncProtocol.writeSyncStep1(encoder, this.document);
147
- channel.send(encoding.toUint8Array(encoder));
155
+ this.router.send(this.syncChannelName, encoding.toUint8Array(encoder));
148
156
  }
149
157
 
150
158
  private handleSyncMessage(data: ArrayBuffer | Uint8Array): void {
@@ -170,9 +178,8 @@ export class YjsDataChannel {
170
178
  responseEncoder,
171
179
  encoding.toUint8Array(encoder),
172
180
  );
173
- const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
174
- if (channel?.readyState === "open") {
175
- channel.send(encoding.toUint8Array(responseEncoder));
181
+ if (this.router.isOpen(this.syncChannelName)) {
182
+ this.router.send(this.syncChannelName, encoding.toUint8Array(responseEncoder));
176
183
  }
177
184
  }
178
185
 
@@ -1,9 +1,13 @@
1
1
  export { AbracadabraWebRTC } from "./AbracadabraWebRTC.ts";
2
2
  export { SignalingSocket } from "./SignalingSocket.ts";
3
3
  export { PeerConnection } from "./PeerConnection.ts";
4
- export { DataChannelRouter } from "./DataChannelRouter.ts";
4
+ export { DataChannelRouter, KEY_EXCHANGE_CHANNEL } from "./DataChannelRouter.ts";
5
5
  export { YjsDataChannel } from "./YjsDataChannel.ts";
6
6
  export { FileTransferChannel, FileTransferHandle } from "./FileTransferChannel.ts";
7
+ export { E2EEChannel } from "./E2EEChannel.ts";
8
+ export type { E2EEIdentity } from "./E2EEChannel.ts";
9
+ export { ManualSignaling } from "./ManualSignaling.ts";
10
+ export type { ManualSignalingBlob } from "./ManualSignaling.ts";
7
11
  export type {
8
12
  AbracadabraWebRTCConfiguration,
9
13
  PeerInfo,