@abraca/dabra 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abracadabra-provider.cjs +561 -36
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +558 -37
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +163 -2
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +11 -8
- package/src/index.ts +1 -0
- package/src/sync/BroadcastChannelSync.ts +235 -0
- package/src/webrtc/AbracadabraWebRTC.ts +68 -1
- package/src/webrtc/DataChannelRouter.ts +73 -5
- package/src/webrtc/E2EEChannel.ts +195 -0
- package/src/webrtc/ManualSignaling.ts +197 -0
- package/src/webrtc/YjsDataChannel.ts +37 -30
- package/src/webrtc/index.ts +5 -1
- package/src/webrtc/types.ts +18 -0
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
80
|
+
if (name === this.syncChannelName) {
|
|
68
81
|
this.handleSyncMessage(data);
|
|
69
|
-
} else if (name ===
|
|
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 ===
|
|
90
|
+
if (name === this.syncChannelName) {
|
|
78
91
|
this.sendSyncStep1();
|
|
79
|
-
} else if (name ===
|
|
92
|
+
} else if (name === this.awarenessChannelName && this.awareness) {
|
|
80
93
|
// Send full awareness state on channel open.
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
package/src/webrtc/index.ts
CHANGED
|
@@ -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,
|