@abraca/dabra 1.0.2 → 1.0.4
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 +1196 -10
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +1187 -11
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +363 -1
- package/package.json +1 -1
- package/src/AbracadabraClient.ts +18 -0
- package/src/AbracadabraWS.ts +3 -1
- package/src/index.ts +1 -0
- package/src/types.ts +2 -0
- package/src/webrtc/AbracadabraWebRTC.ts +540 -0
- package/src/webrtc/DataChannelRouter.ts +110 -0
- package/src/webrtc/FileTransferChannel.ts +359 -0
- package/src/webrtc/PeerConnection.ts +133 -0
- package/src/webrtc/SignalingSocket.ts +366 -0
- package/src/webrtc/YjsDataChannel.ts +195 -0
- package/src/webrtc/index.ts +20 -0
- package/src/webrtc/types.ts +159 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import type * as Y from "yjs";
|
|
2
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
3
|
+
import EventEmitter from "../EventEmitter.ts";
|
|
4
|
+
import type { AbracadabraProvider } from "../AbracadabraProvider.ts";
|
|
5
|
+
import { SignalingSocket } from "./SignalingSocket.ts";
|
|
6
|
+
import { PeerConnection } from "./PeerConnection.ts";
|
|
7
|
+
import { YjsDataChannel } from "./YjsDataChannel.ts";
|
|
8
|
+
import { FileTransferChannel, FileTransferHandle } from "./FileTransferChannel.ts";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ICE_SERVERS,
|
|
11
|
+
type AbracadabraWebRTCConfiguration,
|
|
12
|
+
type PeerInfo,
|
|
13
|
+
type PeerState,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const HAS_RTC = typeof globalThis.RTCPeerConnection !== "undefined";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optional WebRTC provider for peer-to-peer Y.js sync, awareness, and file transfer.
|
|
20
|
+
*
|
|
21
|
+
* Uses the server's signaling endpoint (`/ws/:doc_id/signaling`) for connection
|
|
22
|
+
* negotiation, then establishes direct data channels between peers. Designed to
|
|
23
|
+
* work alongside `AbracadabraProvider` — the server remains the persistence layer,
|
|
24
|
+
* while WebRTC provides low-latency P2P sync.
|
|
25
|
+
*
|
|
26
|
+
* Falls back to a no-op when `RTCPeerConnection` is unavailable (e.g. Node.js).
|
|
27
|
+
*/
|
|
28
|
+
export class AbracadabraWebRTC extends EventEmitter {
|
|
29
|
+
private signaling: SignalingSocket | null = null;
|
|
30
|
+
private peerConnections = new Map<string, PeerConnection>();
|
|
31
|
+
private yjsChannels = new Map<string, YjsDataChannel>();
|
|
32
|
+
private fileChannels = new Map<string, FileTransferChannel>();
|
|
33
|
+
|
|
34
|
+
private readonly config: {
|
|
35
|
+
docId: string;
|
|
36
|
+
url: string;
|
|
37
|
+
token: string | (() => string) | (() => Promise<string>);
|
|
38
|
+
document: Y.Doc | null;
|
|
39
|
+
awareness: Awareness | null;
|
|
40
|
+
iceServers: RTCIceServer[];
|
|
41
|
+
displayName: string | null;
|
|
42
|
+
color: string | null;
|
|
43
|
+
enableDocSync: boolean;
|
|
44
|
+
enableAwarenessSync: boolean;
|
|
45
|
+
enableFileTransfer: boolean;
|
|
46
|
+
fileChunkSize: number;
|
|
47
|
+
WebSocketPolyfill: any;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
public readonly peers = new Map<string, PeerState>();
|
|
51
|
+
public localPeerId: string | null = null;
|
|
52
|
+
public isConnected = false;
|
|
53
|
+
|
|
54
|
+
constructor(configuration: AbracadabraWebRTCConfiguration) {
|
|
55
|
+
super();
|
|
56
|
+
|
|
57
|
+
const doc = configuration.document ?? null;
|
|
58
|
+
const awareness = configuration.awareness ?? null;
|
|
59
|
+
|
|
60
|
+
this.config = {
|
|
61
|
+
docId: configuration.docId,
|
|
62
|
+
url: configuration.url,
|
|
63
|
+
token: configuration.token,
|
|
64
|
+
document: doc,
|
|
65
|
+
awareness,
|
|
66
|
+
iceServers: configuration.iceServers ?? DEFAULT_ICE_SERVERS,
|
|
67
|
+
displayName: configuration.displayName ?? null,
|
|
68
|
+
color: configuration.color ?? null,
|
|
69
|
+
enableDocSync: configuration.enableDocSync ?? !!doc,
|
|
70
|
+
enableAwarenessSync: configuration.enableAwarenessSync ?? !!awareness,
|
|
71
|
+
enableFileTransfer: configuration.enableFileTransfer ?? false,
|
|
72
|
+
fileChunkSize: configuration.fileChunkSize ?? 16384,
|
|
73
|
+
WebSocketPolyfill: configuration.WebSocketPolyfill,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (configuration.autoConnect !== false && HAS_RTC) {
|
|
77
|
+
this.connect();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create an AbracadabraWebRTC instance from an existing provider,
|
|
83
|
+
* reusing its document, awareness, URL, and token.
|
|
84
|
+
*/
|
|
85
|
+
static fromProvider(
|
|
86
|
+
provider: AbracadabraProvider,
|
|
87
|
+
options?: Partial<AbracadabraWebRTCConfiguration>,
|
|
88
|
+
): AbracadabraWebRTC {
|
|
89
|
+
// Derive the HTTP URL from the provider's WebSocket URL.
|
|
90
|
+
const config = provider.configuration;
|
|
91
|
+
const wsUrl =
|
|
92
|
+
config.websocketProvider?.url ??
|
|
93
|
+
(config as { url?: string }).url ??
|
|
94
|
+
"";
|
|
95
|
+
const httpUrl = wsUrl
|
|
96
|
+
.replace(/^wss:/, "https:")
|
|
97
|
+
.replace(/^ws:/, "http:");
|
|
98
|
+
|
|
99
|
+
return new AbracadabraWebRTC({
|
|
100
|
+
docId: config.name,
|
|
101
|
+
url: httpUrl,
|
|
102
|
+
token: config.token as string | (() => string) | (() => Promise<string>),
|
|
103
|
+
document: provider.document,
|
|
104
|
+
awareness: provider.awareness,
|
|
105
|
+
...options,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Connection Lifecycle ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
async connect(): Promise<void> {
|
|
112
|
+
if (!HAS_RTC) return;
|
|
113
|
+
if (this.isConnected) return;
|
|
114
|
+
|
|
115
|
+
const signalingUrl = this.buildSignalingUrl();
|
|
116
|
+
|
|
117
|
+
this.signaling = new SignalingSocket({
|
|
118
|
+
url: signalingUrl,
|
|
119
|
+
token: this.config.token,
|
|
120
|
+
autoConnect: false,
|
|
121
|
+
WebSocketPolyfill: this.config.WebSocketPolyfill,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
this.signaling.on(
|
|
125
|
+
"welcome",
|
|
126
|
+
(data: { peerId: string; peers: PeerInfo[] }) => {
|
|
127
|
+
this.localPeerId = data.peerId;
|
|
128
|
+
this.isConnected = true;
|
|
129
|
+
this.emit("connected");
|
|
130
|
+
|
|
131
|
+
// Send profile if configured.
|
|
132
|
+
if (this.config.displayName && this.config.color) {
|
|
133
|
+
this.signaling!.sendProfile(
|
|
134
|
+
this.config.displayName,
|
|
135
|
+
this.config.color,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Initiate connections to existing peers.
|
|
140
|
+
for (const peer of data.peers) {
|
|
141
|
+
this.addPeer(peer);
|
|
142
|
+
// Deterministic initiator: lower peer_id sends offer.
|
|
143
|
+
if (this.localPeerId! < peer.peer_id) {
|
|
144
|
+
this.initiateConnection(peer.peer_id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
this.signaling.on("joined", (peer: PeerInfo) => {
|
|
151
|
+
this.addPeer(peer);
|
|
152
|
+
this.emit("peerJoined", peer);
|
|
153
|
+
|
|
154
|
+
// Deterministic initiator.
|
|
155
|
+
if (this.localPeerId! < peer.peer_id) {
|
|
156
|
+
this.initiateConnection(peer.peer_id);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
this.signaling.on("left", ({ peerId }: { peerId: string }) => {
|
|
161
|
+
this.removePeer(peerId);
|
|
162
|
+
this.emit("peerLeft", { peerId });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.signaling.on(
|
|
166
|
+
"offer",
|
|
167
|
+
async ({ from, sdp }: { from: string; sdp: string }) => {
|
|
168
|
+
await this.handleOffer(from, sdp);
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
this.signaling.on(
|
|
173
|
+
"answer",
|
|
174
|
+
async ({ from, sdp }: { from: string; sdp: string }) => {
|
|
175
|
+
const pc = this.peerConnections.get(from);
|
|
176
|
+
if (pc) {
|
|
177
|
+
await pc.setRemoteAnswer(sdp);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
this.signaling.on(
|
|
183
|
+
"ice",
|
|
184
|
+
async ({
|
|
185
|
+
from,
|
|
186
|
+
candidate,
|
|
187
|
+
}: { from: string; candidate: string }) => {
|
|
188
|
+
const pc = this.peerConnections.get(from);
|
|
189
|
+
if (pc) {
|
|
190
|
+
await pc.addIceCandidate(candidate);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
this.signaling.on(
|
|
196
|
+
"mute",
|
|
197
|
+
({ peerId, muted }: { peerId: string; muted: boolean }) => {
|
|
198
|
+
const peer = this.peers.get(peerId);
|
|
199
|
+
if (peer) {
|
|
200
|
+
peer.muted = muted;
|
|
201
|
+
this.emit("peerMuted", { peerId, muted });
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
this.signaling.on(
|
|
207
|
+
"media-state",
|
|
208
|
+
({
|
|
209
|
+
peerId,
|
|
210
|
+
video,
|
|
211
|
+
screen,
|
|
212
|
+
}: { peerId: string; video: boolean; screen: boolean }) => {
|
|
213
|
+
const peer = this.peers.get(peerId);
|
|
214
|
+
if (peer) {
|
|
215
|
+
peer.video = video;
|
|
216
|
+
peer.screen = screen;
|
|
217
|
+
this.emit("peerMediaState", { peerId, video, screen });
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
this.signaling.on(
|
|
223
|
+
"profile",
|
|
224
|
+
({
|
|
225
|
+
peerId,
|
|
226
|
+
name,
|
|
227
|
+
color,
|
|
228
|
+
}: { peerId: string; name: string; color: string }) => {
|
|
229
|
+
const peer = this.peers.get(peerId);
|
|
230
|
+
if (peer) {
|
|
231
|
+
peer.name = name;
|
|
232
|
+
peer.color = color;
|
|
233
|
+
this.emit("peerProfile", { peerId, name, color });
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
this.signaling.on("disconnected", () => {
|
|
239
|
+
this.isConnected = false;
|
|
240
|
+
this.localPeerId = null;
|
|
241
|
+
// Clean up all peer connections — signaling will auto-reconnect.
|
|
242
|
+
this.removeAllPeers();
|
|
243
|
+
this.emit("disconnected");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
this.signaling.on(
|
|
247
|
+
"error",
|
|
248
|
+
(err: { code: string; message: string }) => {
|
|
249
|
+
this.emit("signalingError", err);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
await this.signaling.connect();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
disconnect(): void {
|
|
257
|
+
if (!HAS_RTC) return;
|
|
258
|
+
|
|
259
|
+
this.removeAllPeers();
|
|
260
|
+
|
|
261
|
+
if (this.signaling) {
|
|
262
|
+
this.signaling.disconnect();
|
|
263
|
+
this.signaling = null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.isConnected = false;
|
|
267
|
+
this.localPeerId = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
destroy(): void {
|
|
271
|
+
this.disconnect();
|
|
272
|
+
|
|
273
|
+
if (this.signaling) {
|
|
274
|
+
this.signaling.destroy();
|
|
275
|
+
this.signaling = null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.removeAllListeners();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Media State ─────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
setMuted(muted: boolean): void {
|
|
284
|
+
this.signaling?.sendMute(muted);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setMediaState(video: boolean, screen: boolean): void {
|
|
288
|
+
this.signaling?.sendMediaState(video, screen);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
setProfile(name: string, color: string): void {
|
|
292
|
+
this.signaling?.sendProfile(name, color);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── File Transfer ───────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Send a file to a specific peer. Returns a handle for tracking progress.
|
|
299
|
+
*/
|
|
300
|
+
async sendFile(
|
|
301
|
+
peerId: string,
|
|
302
|
+
file: File | Blob,
|
|
303
|
+
filename: string,
|
|
304
|
+
): Promise<FileTransferHandle | null> {
|
|
305
|
+
const fc = this.fileChannels.get(peerId);
|
|
306
|
+
if (!fc) return null;
|
|
307
|
+
return fc.send(file, filename);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Send a file to all connected peers. Returns an array of handles.
|
|
312
|
+
*/
|
|
313
|
+
async broadcastFile(
|
|
314
|
+
file: File | Blob,
|
|
315
|
+
filename: string,
|
|
316
|
+
): Promise<FileTransferHandle[]> {
|
|
317
|
+
const handles: FileTransferHandle[] = [];
|
|
318
|
+
for (const [peerId, fc] of this.fileChannels) {
|
|
319
|
+
const handle = await fc.send(file, filename);
|
|
320
|
+
handles.push(handle);
|
|
321
|
+
}
|
|
322
|
+
return handles;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Custom Messages ─────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Send a custom string message to a specific peer via a data channel.
|
|
329
|
+
*/
|
|
330
|
+
sendCustomMessage(peerId: string, payload: string): void {
|
|
331
|
+
const pc = this.peerConnections.get(peerId);
|
|
332
|
+
if (!pc) return;
|
|
333
|
+
|
|
334
|
+
let channel = pc.router.getChannel("custom");
|
|
335
|
+
if (!channel || channel.readyState !== "open") {
|
|
336
|
+
// Create on-demand.
|
|
337
|
+
channel = pc.router.createChannel("custom", { ordered: true });
|
|
338
|
+
// Wait for open before sending.
|
|
339
|
+
channel.onopen = () => {
|
|
340
|
+
channel!.send(payload);
|
|
341
|
+
};
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
channel.send(payload);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Send a custom string message to all connected peers.
|
|
349
|
+
*/
|
|
350
|
+
broadcastCustomMessage(payload: string): void {
|
|
351
|
+
for (const peerId of this.peerConnections.keys()) {
|
|
352
|
+
this.sendCustomMessage(peerId, payload);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Private: Peer Management ────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
private addPeer(info: PeerInfo): void {
|
|
359
|
+
this.peers.set(info.peer_id, {
|
|
360
|
+
...info,
|
|
361
|
+
connectionState: "new",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private removePeer(peerId: string): void {
|
|
366
|
+
this.peers.delete(peerId);
|
|
367
|
+
|
|
368
|
+
const yjs = this.yjsChannels.get(peerId);
|
|
369
|
+
if (yjs) {
|
|
370
|
+
yjs.destroy();
|
|
371
|
+
this.yjsChannels.delete(peerId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const fc = this.fileChannels.get(peerId);
|
|
375
|
+
if (fc) {
|
|
376
|
+
fc.destroy();
|
|
377
|
+
this.fileChannels.delete(peerId);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const pc = this.peerConnections.get(peerId);
|
|
381
|
+
if (pc) {
|
|
382
|
+
pc.destroy();
|
|
383
|
+
this.peerConnections.delete(peerId);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private removeAllPeers(): void {
|
|
388
|
+
for (const peerId of [...this.peers.keys()]) {
|
|
389
|
+
this.removePeer(peerId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Private: Connection Negotiation ─────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
private createPeerConnection(peerId: string): PeerConnection {
|
|
396
|
+
const pc = new PeerConnection(peerId, this.config.iceServers);
|
|
397
|
+
|
|
398
|
+
// Forward ICE candidates to signaling.
|
|
399
|
+
pc.on(
|
|
400
|
+
"iceCandidate",
|
|
401
|
+
({ peerId, candidate }: { peerId: string; candidate: string }) => {
|
|
402
|
+
this.signaling?.sendIce(peerId, candidate);
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// Handle ICE failure with restart.
|
|
407
|
+
pc.on("iceFailed", async ({ peerId }: { peerId: string }) => {
|
|
408
|
+
try {
|
|
409
|
+
const sdp = await pc.createOffer(true);
|
|
410
|
+
this.signaling?.sendOffer(peerId, sdp);
|
|
411
|
+
} catch {
|
|
412
|
+
// If ICE restart fails, remove peer.
|
|
413
|
+
this.removePeer(peerId);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Track connection state.
|
|
418
|
+
pc.on(
|
|
419
|
+
"connectionStateChange",
|
|
420
|
+
({ peerId, state }: { peerId: string; state: RTCPeerConnectionState }) => {
|
|
421
|
+
const peer = this.peers.get(peerId);
|
|
422
|
+
if (peer) {
|
|
423
|
+
peer.connectionState = state;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (state === "disconnected" || state === "closed") {
|
|
427
|
+
// Peer connection dropped. Don't remove yet — ICE may recover.
|
|
428
|
+
// If "failed", the iceFailed handler will attempt restart.
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
this.emit("peerConnectionState", { peerId, state });
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Listen for custom messages on the router.
|
|
436
|
+
pc.router.on("channelMessage", ({ name, data }: { name: string; data: any }) => {
|
|
437
|
+
if (name === "custom") {
|
|
438
|
+
const payload = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
439
|
+
this.emit("customMessage", { peerId, payload });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
this.peerConnections.set(peerId, pc);
|
|
444
|
+
this.attachDataHandlers(peerId, pc);
|
|
445
|
+
|
|
446
|
+
return pc;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private attachDataHandlers(peerId: string, pc: PeerConnection): void {
|
|
450
|
+
// Attach Y.js sync.
|
|
451
|
+
if (this.config.document && this.config.enableDocSync) {
|
|
452
|
+
const yjs = new YjsDataChannel(
|
|
453
|
+
this.config.document,
|
|
454
|
+
this.config.enableAwarenessSync ? this.config.awareness : null,
|
|
455
|
+
pc.router,
|
|
456
|
+
);
|
|
457
|
+
yjs.attach();
|
|
458
|
+
this.yjsChannels.set(peerId, yjs);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Attach file transfer.
|
|
462
|
+
if (this.config.enableFileTransfer) {
|
|
463
|
+
const fc = new FileTransferChannel(pc.router, this.config.fileChunkSize);
|
|
464
|
+
|
|
465
|
+
fc.on("receiveStart", (meta: any) => {
|
|
466
|
+
this.emit("fileReceiveStart", { peerId, ...meta });
|
|
467
|
+
});
|
|
468
|
+
fc.on("receiveProgress", (data: any) => {
|
|
469
|
+
this.emit("fileReceiveProgress", { peerId, ...data });
|
|
470
|
+
});
|
|
471
|
+
fc.on("receiveComplete", (data: any) => {
|
|
472
|
+
this.emit("fileReceiveComplete", { peerId, ...data });
|
|
473
|
+
});
|
|
474
|
+
fc.on("receiveError", (data: any) => {
|
|
475
|
+
this.emit("fileReceiveError", { peerId, ...data });
|
|
476
|
+
});
|
|
477
|
+
fc.on("receiveCancelled", (data: any) => {
|
|
478
|
+
this.emit("fileReceiveCancelled", { peerId, ...data });
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
this.fileChannels.set(peerId, fc);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async initiateConnection(peerId: string): Promise<void> {
|
|
486
|
+
const pc = this.createPeerConnection(peerId);
|
|
487
|
+
|
|
488
|
+
// Create data channels (initiator creates them).
|
|
489
|
+
pc.router.createDefaultChannels({
|
|
490
|
+
enableDocSync: this.config.enableDocSync,
|
|
491
|
+
enableAwareness: this.config.enableAwarenessSync,
|
|
492
|
+
enableFileTransfer: this.config.enableFileTransfer,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const sdp = await pc.createOffer();
|
|
497
|
+
this.signaling?.sendOffer(peerId, sdp);
|
|
498
|
+
} catch {
|
|
499
|
+
this.removePeer(peerId);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async handleOffer(from: string, sdp: string): Promise<void> {
|
|
504
|
+
// Clean up any existing connection to this peer.
|
|
505
|
+
const existing = this.peerConnections.get(from);
|
|
506
|
+
if (existing) {
|
|
507
|
+
existing.destroy();
|
|
508
|
+
this.yjsChannels.get(from)?.destroy();
|
|
509
|
+
this.yjsChannels.delete(from);
|
|
510
|
+
this.fileChannels.get(from)?.destroy();
|
|
511
|
+
this.fileChannels.delete(from);
|
|
512
|
+
this.peerConnections.delete(from);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const pc = this.createPeerConnection(from);
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const answerSdp = await pc.setRemoteOffer(sdp);
|
|
519
|
+
this.signaling?.sendAnswer(from, answerSdp);
|
|
520
|
+
} catch {
|
|
521
|
+
this.removePeer(from);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── Private: URL Building ───────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
private buildSignalingUrl(): string {
|
|
528
|
+
let base = this.config.url;
|
|
529
|
+
|
|
530
|
+
// Remove trailing slashes.
|
|
531
|
+
while (base.endsWith("/")) {
|
|
532
|
+
base = base.slice(0, -1);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Convert http(s) to ws(s).
|
|
536
|
+
base = base.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
|
|
537
|
+
|
|
538
|
+
return `${base}/ws/${encodeURIComponent(this.config.docId)}/signaling`;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import EventEmitter from "../EventEmitter.ts";
|
|
2
|
+
import { CHANNEL_NAMES } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export class DataChannelRouter extends EventEmitter {
|
|
5
|
+
private channels = new Map<string, RTCDataChannel>();
|
|
6
|
+
|
|
7
|
+
constructor(private connection: RTCPeerConnection) {
|
|
8
|
+
super();
|
|
9
|
+
|
|
10
|
+
// Accept incoming data channels from the remote peer.
|
|
11
|
+
this.connection.ondatachannel = (event) => {
|
|
12
|
+
this.registerChannel(event.channel);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Create a named data channel (initiator side). */
|
|
17
|
+
createChannel(
|
|
18
|
+
name: string,
|
|
19
|
+
options?: RTCDataChannelInit,
|
|
20
|
+
): RTCDataChannel {
|
|
21
|
+
const channel = this.connection.createDataChannel(name, options);
|
|
22
|
+
this.registerChannel(channel);
|
|
23
|
+
return channel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Create the standard set of channels for Abracadabra WebRTC. */
|
|
27
|
+
createDefaultChannels(opts: {
|
|
28
|
+
enableDocSync: boolean;
|
|
29
|
+
enableAwareness: boolean;
|
|
30
|
+
enableFileTransfer: boolean;
|
|
31
|
+
}): void {
|
|
32
|
+
if (opts.enableDocSync) {
|
|
33
|
+
this.createChannel(CHANNEL_NAMES.YJS_SYNC, {
|
|
34
|
+
ordered: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (opts.enableAwareness) {
|
|
39
|
+
this.createChannel(CHANNEL_NAMES.AWARENESS, {
|
|
40
|
+
ordered: false,
|
|
41
|
+
maxRetransmits: 0,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (opts.enableFileTransfer) {
|
|
46
|
+
this.createChannel(CHANNEL_NAMES.FILE_TRANSFER, {
|
|
47
|
+
ordered: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getChannel(name: string): RTCDataChannel | null {
|
|
53
|
+
return this.channels.get(name) ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isOpen(name: string): boolean {
|
|
57
|
+
return this.channels.get(name)?.readyState === "open";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private registerChannel(channel: RTCDataChannel): void {
|
|
61
|
+
channel.binaryType = "arraybuffer";
|
|
62
|
+
this.channels.set(channel.label, channel);
|
|
63
|
+
|
|
64
|
+
channel.onopen = () => {
|
|
65
|
+
this.emit("channelOpen", { name: channel.label, channel });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
channel.onclose = () => {
|
|
69
|
+
this.emit("channelClose", { name: channel.label });
|
|
70
|
+
this.channels.delete(channel.label);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
channel.onmessage = (event) => {
|
|
74
|
+
this.emit("channelMessage", {
|
|
75
|
+
name: channel.label,
|
|
76
|
+
data: event.data,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
channel.onerror = (event) => {
|
|
81
|
+
this.emit("channelError", {
|
|
82
|
+
name: channel.label,
|
|
83
|
+
error: event,
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// If the channel is already open (can happen with ondatachannel),
|
|
88
|
+
// emit immediately.
|
|
89
|
+
if (channel.readyState === "open") {
|
|
90
|
+
this.emit("channelOpen", { name: channel.label, channel });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
close(): void {
|
|
95
|
+
for (const channel of this.channels.values()) {
|
|
96
|
+
try {
|
|
97
|
+
channel.close();
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.channels.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
destroy(): void {
|
|
106
|
+
this.close();
|
|
107
|
+
this.connection.ondatachannel = null;
|
|
108
|
+
this.removeAllListeners();
|
|
109
|
+
}
|
|
110
|
+
}
|