@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.
@@ -0,0 +1,366 @@
1
+ import { retry } from "@lifeomic/attempt";
2
+ import EventEmitter from "../EventEmitter.ts";
3
+ import type {
4
+ PeerInfo,
5
+ SignalingIncoming,
6
+ SignalingOutgoing,
7
+ } from "./types.ts";
8
+
9
+ export interface SignalingSocketConfiguration {
10
+ /** WebSocket URL for the signaling endpoint. */
11
+ url: string;
12
+
13
+ /** JWT token or async token factory for auth. */
14
+ token: string | (() => string) | (() => Promise<string>);
15
+
16
+ /** Auto-connect on construction. Default: true. */
17
+ autoConnect?: boolean;
18
+
19
+ /** WebSocket polyfill (e.g. for Node.js). */
20
+ WebSocketPolyfill?: any;
21
+
22
+ /** Retry delay in ms. Default: 1000. */
23
+ delay?: number;
24
+ /** Retry factor. Default: 2. */
25
+ factor?: number;
26
+ /** Min retry delay. Default: 1000. */
27
+ minDelay?: number;
28
+ /** Max retry delay. Default: 30000. */
29
+ maxDelay?: number;
30
+ /** Randomize delay. Default: true. */
31
+ jitter?: boolean;
32
+ /** Max retry attempts (0 = unlimited). Default: 0. */
33
+ maxAttempts?: number;
34
+ }
35
+
36
+ export class SignalingSocket extends EventEmitter {
37
+ private ws: WebSocket | null = null;
38
+ private wsHandlers: Record<string, (e: any) => void> = {};
39
+ private shouldConnect = true;
40
+ private cancelRetry?: () => void;
41
+ private connectionAttempt: {
42
+ resolve: (v?: any) => void;
43
+ reject: (r?: any) => void;
44
+ } | null = null;
45
+
46
+ private readonly config: Required<
47
+ Pick<
48
+ SignalingSocketConfiguration,
49
+ | "url"
50
+ | "token"
51
+ | "delay"
52
+ | "factor"
53
+ | "minDelay"
54
+ | "maxDelay"
55
+ | "jitter"
56
+ | "maxAttempts"
57
+ >
58
+ > & { WebSocketPolyfill: any };
59
+
60
+ public localPeerId: string | null = null;
61
+ public isConnected = false;
62
+
63
+ constructor(configuration: SignalingSocketConfiguration) {
64
+ super();
65
+
66
+ this.config = {
67
+ url: configuration.url,
68
+ token: configuration.token,
69
+ delay: configuration.delay ?? 1000,
70
+ factor: configuration.factor ?? 2,
71
+ minDelay: configuration.minDelay ?? 1000,
72
+ maxDelay: configuration.maxDelay ?? 30000,
73
+ jitter: configuration.jitter ?? true,
74
+ maxAttempts: configuration.maxAttempts ?? 0,
75
+ WebSocketPolyfill: configuration.WebSocketPolyfill ?? WebSocket,
76
+ };
77
+
78
+ if (configuration.autoConnect !== false) {
79
+ this.connect();
80
+ }
81
+ }
82
+
83
+ private async getToken(): Promise<string> {
84
+ if (typeof this.config.token === "function") {
85
+ return await this.config.token();
86
+ }
87
+ return this.config.token;
88
+ }
89
+
90
+ async connect(): Promise<void> {
91
+ if (this.isConnected) return;
92
+
93
+ if (this.cancelRetry) {
94
+ this.cancelRetry();
95
+ this.cancelRetry = undefined;
96
+ }
97
+
98
+ this.shouldConnect = true;
99
+
100
+ let cancelAttempt = false;
101
+
102
+ const retryPromise = retry(
103
+ () => this.createConnection(),
104
+ {
105
+ delay: this.config.delay,
106
+ initialDelay: 0,
107
+ factor: this.config.factor,
108
+ maxAttempts: this.config.maxAttempts,
109
+ minDelay: this.config.minDelay,
110
+ maxDelay: this.config.maxDelay,
111
+ jitter: this.config.jitter,
112
+ timeout: 0,
113
+ beforeAttempt: (context) => {
114
+ if (!this.shouldConnect || cancelAttempt) {
115
+ context.abort();
116
+ }
117
+ },
118
+ },
119
+ ).catch((error: any) => {
120
+ if (error && error.code !== "ATTEMPT_ABORTED") {
121
+ throw error;
122
+ }
123
+ });
124
+
125
+ this.cancelRetry = () => {
126
+ cancelAttempt = true;
127
+ };
128
+
129
+ return retryPromise;
130
+ }
131
+
132
+ private async createConnection(): Promise<void> {
133
+ this.cleanup();
134
+
135
+ const token = await this.getToken();
136
+ const separator = this.config.url.includes("?") ? "&" : "?";
137
+ const url = `${this.config.url}${separator}token=${encodeURIComponent(token)}`;
138
+
139
+ const ws = new this.config.WebSocketPolyfill(url);
140
+
141
+ return new Promise((resolve, reject) => {
142
+ const onOpen = () => {
143
+ this.isConnected = true;
144
+ // Server auto-sends Welcome on join; we send Join to register
145
+ this.sendRaw({ type: "join" });
146
+ };
147
+
148
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
149
+ const onMessage = (event: MessageEvent | { data: string }) => {
150
+ const data =
151
+ typeof event === "string"
152
+ ? event
153
+ : typeof (event as MessageEvent).data === "string"
154
+ ? (event as MessageEvent).data
155
+ : null;
156
+ if (!data) return;
157
+
158
+ let msg: SignalingOutgoing;
159
+ try {
160
+ msg = JSON.parse(data);
161
+ } catch {
162
+ return;
163
+ }
164
+
165
+ this.handleMessage(msg, resolve);
166
+ };
167
+
168
+ const onClose = (event: CloseEvent | { code?: number }) => {
169
+ const wasConnected = this.isConnected;
170
+ this.isConnected = false;
171
+ this.localPeerId = null;
172
+
173
+ if (this.connectionAttempt) {
174
+ this.connectionAttempt = null;
175
+ reject(
176
+ new Error(`Signaling WebSocket closed: ${(event as CloseEvent)?.code}`),
177
+ );
178
+ }
179
+
180
+ this.emit("disconnected");
181
+
182
+ if (!wasConnected) return;
183
+
184
+ // Auto-reconnect if desired
185
+ if (this.shouldConnect && !this.cancelRetry) {
186
+ setTimeout(() => this.connect(), this.config.delay);
187
+ }
188
+ };
189
+
190
+ const onError = (err: Event) => {
191
+ if (this.connectionAttempt) {
192
+ this.connectionAttempt = null;
193
+ reject(err);
194
+ }
195
+ };
196
+
197
+ this.wsHandlers = {
198
+ open: onOpen,
199
+ message: onMessage,
200
+ close: onClose,
201
+ error: onError,
202
+ };
203
+
204
+ for (const [name, handler] of Object.entries(this.wsHandlers)) {
205
+ ws.addEventListener(name, handler);
206
+ }
207
+
208
+ this.ws = ws;
209
+ this.connectionAttempt = { resolve, reject };
210
+ });
211
+ }
212
+
213
+ private handleMessage(msg: SignalingOutgoing, resolveConnection?: (v?: any) => void): void {
214
+ switch (msg.type) {
215
+ case "welcome":
216
+ this.localPeerId = msg.peer_id;
217
+ if (this.connectionAttempt) {
218
+ this.connectionAttempt = null;
219
+ resolveConnection?.();
220
+ }
221
+ this.emit("welcome", {
222
+ peerId: msg.peer_id,
223
+ peers: msg.peers,
224
+ });
225
+ break;
226
+
227
+ case "joined":
228
+ this.emit("joined", {
229
+ peerId: msg.peer_id,
230
+ userId: msg.user_id,
231
+ muted: msg.muted,
232
+ video: msg.video,
233
+ screen: msg.screen,
234
+ name: msg.name,
235
+ color: msg.color,
236
+ } satisfies PeerInfo);
237
+ break;
238
+
239
+ case "left":
240
+ this.emit("left", { peerId: msg.peer_id });
241
+ break;
242
+
243
+ case "offer":
244
+ this.emit("offer", { from: msg.from, sdp: msg.sdp });
245
+ break;
246
+
247
+ case "answer":
248
+ this.emit("answer", { from: msg.from, sdp: msg.sdp });
249
+ break;
250
+
251
+ case "ice":
252
+ this.emit("ice", { from: msg.from, candidate: msg.candidate });
253
+ break;
254
+
255
+ case "mute":
256
+ this.emit("mute", {
257
+ peerId: msg.peer_id,
258
+ muted: msg.muted,
259
+ });
260
+ break;
261
+
262
+ case "media-state":
263
+ this.emit("media-state", {
264
+ peerId: msg.peer_id,
265
+ video: msg.video,
266
+ screen: msg.screen,
267
+ });
268
+ break;
269
+
270
+ case "profile":
271
+ this.emit("profile", {
272
+ peerId: msg.peer_id,
273
+ name: msg.name,
274
+ color: msg.color,
275
+ });
276
+ break;
277
+
278
+ case "ping":
279
+ this.sendRaw({ type: "pong" });
280
+ break;
281
+
282
+ case "error":
283
+ this.emit("error", {
284
+ code: msg.code,
285
+ message: msg.message,
286
+ });
287
+ break;
288
+ }
289
+ }
290
+
291
+ private sendRaw(msg: SignalingIncoming): void {
292
+ if (this.ws?.readyState === 1 /* OPEN */) {
293
+ this.ws.send(JSON.stringify(msg));
294
+ }
295
+ }
296
+
297
+ // ── Public send methods ─────────────────────────────────────────────────
298
+
299
+ sendOffer(to: string, sdp: string): void {
300
+ this.sendRaw({ type: "offer", to, sdp });
301
+ }
302
+
303
+ sendAnswer(to: string, sdp: string): void {
304
+ this.sendRaw({ type: "answer", to, sdp });
305
+ }
306
+
307
+ sendIce(to: string, candidate: string): void {
308
+ this.sendRaw({ type: "ice", to, candidate });
309
+ }
310
+
311
+ sendMute(muted: boolean): void {
312
+ this.sendRaw({ type: "mute", muted });
313
+ }
314
+
315
+ sendMediaState(video: boolean, screen: boolean): void {
316
+ this.sendRaw({ type: "media-state", video, screen });
317
+ }
318
+
319
+ sendProfile(name: string, color: string): void {
320
+ this.sendRaw({ type: "profile", name, color });
321
+ }
322
+
323
+ sendLeave(): void {
324
+ this.sendRaw({ type: "leave" });
325
+ }
326
+
327
+ // ── Lifecycle ───────────────────────────────────────────────────────────
328
+
329
+ disconnect(): void {
330
+ this.shouldConnect = false;
331
+ this.sendLeave();
332
+
333
+ if (this.cancelRetry) {
334
+ this.cancelRetry();
335
+ this.cancelRetry = undefined;
336
+ }
337
+
338
+ this.cleanup();
339
+ }
340
+
341
+ destroy(): void {
342
+ this.disconnect();
343
+ this.removeAllListeners();
344
+ }
345
+
346
+ private cleanup(): void {
347
+ if (!this.ws) return;
348
+
349
+ for (const [name, handler] of Object.entries(this.wsHandlers)) {
350
+ this.ws.removeEventListener(name, handler);
351
+ }
352
+ this.wsHandlers = {};
353
+
354
+ try {
355
+ if (this.ws.readyState !== 3 /* CLOSED */) {
356
+ this.ws.close();
357
+ }
358
+ } catch {
359
+ // Ignore close errors
360
+ }
361
+
362
+ this.ws = null;
363
+ this.isConnected = false;
364
+ this.localPeerId = null;
365
+ }
366
+ }
@@ -0,0 +1,195 @@
1
+ import * as Y from "yjs";
2
+ import * as syncProtocol from "y-protocols/sync";
3
+ import {
4
+ encodeAwarenessUpdate,
5
+ applyAwarenessUpdate,
6
+ type Awareness,
7
+ } from "y-protocols/awareness";
8
+ import * as encoding from "lib0/encoding";
9
+ import * as decoding from "lib0/decoding";
10
+ import type { DataChannelRouter } from "./DataChannelRouter.ts";
11
+ import { CHANNEL_NAMES, YJS_MSG } from "./types.ts";
12
+
13
+ /**
14
+ * Handles Y.js document sync and awareness over WebRTC data channels.
15
+ *
16
+ * Uses the same y-protocols/sync encoding as the WebSocket provider but
17
+ * transported over RTCDataChannel instead. A unique origin is used to
18
+ * prevent echo loops with the server-based provider.
19
+ */
20
+ export class YjsDataChannel {
21
+ private docUpdateHandler: ((update: Uint8Array, origin: any) => void) | null = null;
22
+ private awarenessUpdateHandler: ((changes: { added: number[]; updated: number[]; removed: number[] }, origin: any) => void) | null = null;
23
+ private channelOpenHandler: ((data: { name: string; channel: RTCDataChannel }) => void) | null = null;
24
+ private channelMessageHandler: ((data: { name: string; data: any }) => void) | null = null;
25
+
26
+ constructor(
27
+ private readonly document: Y.Doc,
28
+ private readonly awareness: Awareness | null,
29
+ private readonly router: DataChannelRouter,
30
+ ) {}
31
+
32
+ /** Start listening for Y.js updates and data channel messages. */
33
+ attach(): void {
34
+ // Listen for local doc updates and send to peer.
35
+ this.docUpdateHandler = (update: Uint8Array, origin: any) => {
36
+ // Don't echo updates we received from this data channel.
37
+ if (origin === this) return;
38
+
39
+ const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
40
+ if (!channel || channel.readyState !== "open") return;
41
+
42
+ const encoder = encoding.createEncoder();
43
+ encoding.writeVarUint(encoder, YJS_MSG.UPDATE);
44
+ encoding.writeVarUint8Array(encoder, update);
45
+ channel.send(encoding.toUint8Array(encoder));
46
+ };
47
+ this.document.on("update", this.docUpdateHandler);
48
+
49
+ // Listen for local awareness updates and send to peer.
50
+ if (this.awareness) {
51
+ this.awarenessUpdateHandler = (
52
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
53
+ _origin: any,
54
+ ) => {
55
+ const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
56
+ if (!channel || channel.readyState !== "open") return;
57
+
58
+ const changedClients = added.concat(updated).concat(removed);
59
+ const update = encodeAwarenessUpdate(this.awareness!, changedClients);
60
+ channel.send(update);
61
+ };
62
+ this.awareness.on("update", this.awarenessUpdateHandler);
63
+ }
64
+
65
+ // Handle incoming data channel messages.
66
+ this.channelMessageHandler = ({ name, data }: { name: string; data: any }) => {
67
+ if (name === CHANNEL_NAMES.YJS_SYNC) {
68
+ this.handleSyncMessage(data);
69
+ } else if (name === CHANNEL_NAMES.AWARENESS) {
70
+ this.handleAwarenessMessage(data);
71
+ }
72
+ };
73
+ this.router.on("channelMessage", this.channelMessageHandler);
74
+
75
+ // When sync channel opens, initiate sync handshake.
76
+ this.channelOpenHandler = ({ name }: { name: string; channel: RTCDataChannel }) => {
77
+ if (name === CHANNEL_NAMES.YJS_SYNC) {
78
+ this.sendSyncStep1();
79
+ } else if (name === CHANNEL_NAMES.AWARENESS && this.awareness) {
80
+ // Send full awareness state on channel open.
81
+ const channel = this.router.getChannel(CHANNEL_NAMES.AWARENESS);
82
+ if (channel?.readyState === "open") {
83
+ const update = encodeAwarenessUpdate(
84
+ this.awareness,
85
+ Array.from(this.awareness.getStates().keys()),
86
+ );
87
+ channel.send(update);
88
+ }
89
+ }
90
+ };
91
+ this.router.on("channelOpen", this.channelOpenHandler);
92
+
93
+ // If sync channel is already open, start sync immediately.
94
+ if (this.router.isOpen(CHANNEL_NAMES.YJS_SYNC)) {
95
+ this.sendSyncStep1();
96
+ }
97
+
98
+ // 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
+ }
108
+ }
109
+ }
110
+
111
+ /** Stop listening and clean up handlers. */
112
+ detach(): void {
113
+ if (this.docUpdateHandler) {
114
+ this.document.off("update", this.docUpdateHandler);
115
+ this.docUpdateHandler = null;
116
+ }
117
+
118
+ if (this.awarenessUpdateHandler && this.awareness) {
119
+ this.awareness.off("update", this.awarenessUpdateHandler);
120
+ this.awarenessUpdateHandler = null;
121
+ }
122
+
123
+ if (this.channelMessageHandler) {
124
+ this.router.off("channelMessage", this.channelMessageHandler);
125
+ this.channelMessageHandler = null;
126
+ }
127
+
128
+ if (this.channelOpenHandler) {
129
+ this.router.off("channelOpen", this.channelOpenHandler);
130
+ this.channelOpenHandler = null;
131
+ }
132
+
133
+ this.isSynced = false;
134
+ }
135
+
136
+ destroy(): void {
137
+ this.detach();
138
+ }
139
+
140
+ private sendSyncStep1(): void {
141
+ const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
142
+ if (!channel || channel.readyState !== "open") return;
143
+
144
+ const encoder = encoding.createEncoder();
145
+ encoding.writeVarUint(encoder, YJS_MSG.SYNC);
146
+ syncProtocol.writeSyncStep1(encoder, this.document);
147
+ channel.send(encoding.toUint8Array(encoder));
148
+ }
149
+
150
+ private handleSyncMessage(data: ArrayBuffer | Uint8Array): void {
151
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
152
+ const decoder = decoding.createDecoder(buf);
153
+ const msgType = decoding.readVarUint(decoder);
154
+
155
+ if (msgType === YJS_MSG.SYNC) {
156
+ // Standard y-protocols/sync message (SyncStep1, SyncStep2, or Update).
157
+ const encoder = encoding.createEncoder();
158
+ const syncMessageType = syncProtocol.readSyncMessage(
159
+ decoder,
160
+ encoder,
161
+ this.document,
162
+ this,
163
+ );
164
+
165
+ // If the sync protocol generated a response (SyncStep2), send it.
166
+ if (encoding.length(encoder) > 0) {
167
+ const responseEncoder = encoding.createEncoder();
168
+ encoding.writeVarUint(responseEncoder, YJS_MSG.SYNC);
169
+ encoding.writeUint8Array(
170
+ responseEncoder,
171
+ encoding.toUint8Array(encoder),
172
+ );
173
+ const channel = this.router.getChannel(CHANNEL_NAMES.YJS_SYNC);
174
+ if (channel?.readyState === "open") {
175
+ channel.send(encoding.toUint8Array(responseEncoder));
176
+ }
177
+ }
178
+
179
+ // SyncStep2 means initial sync is complete.
180
+ if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
181
+ this.isSynced = true;
182
+ }
183
+ } else if (msgType === YJS_MSG.UPDATE) {
184
+ // Raw incremental update.
185
+ const update = decoding.readVarUint8Array(decoder);
186
+ Y.applyUpdate(this.document, update, this);
187
+ }
188
+ }
189
+
190
+ private handleAwarenessMessage(data: ArrayBuffer | Uint8Array): void {
191
+ if (!this.awareness) return;
192
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
193
+ applyAwarenessUpdate(this.awareness, buf, this);
194
+ }
195
+ }
@@ -0,0 +1,20 @@
1
+ export { AbracadabraWebRTC } from "./AbracadabraWebRTC.ts";
2
+ export { SignalingSocket } from "./SignalingSocket.ts";
3
+ export { PeerConnection } from "./PeerConnection.ts";
4
+ export { DataChannelRouter } from "./DataChannelRouter.ts";
5
+ export { YjsDataChannel } from "./YjsDataChannel.ts";
6
+ export { FileTransferChannel, FileTransferHandle } from "./FileTransferChannel.ts";
7
+ export type {
8
+ AbracadabraWebRTCConfiguration,
9
+ PeerInfo,
10
+ PeerState,
11
+ FileTransferMeta,
12
+ FileTransferStatus,
13
+ SignalingIncoming,
14
+ SignalingOutgoing,
15
+ } from "./types.ts";
16
+ export {
17
+ CHANNEL_NAMES,
18
+ DEFAULT_ICE_SERVERS,
19
+ DEFAULT_FILE_CHUNK_SIZE,
20
+ } from "./types.ts";