@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
package/dist/index.d.ts
CHANGED
|
@@ -1599,11 +1599,66 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1599
1599
|
private _walkXml;
|
|
1600
1600
|
}
|
|
1601
1601
|
//#endregion
|
|
1602
|
+
//#region packages/provider/src/webrtc/E2EEChannel.d.ts
|
|
1603
|
+
interface E2EEIdentity {
|
|
1604
|
+
/** Raw 32-byte Ed25519 public key. */
|
|
1605
|
+
publicKey: Uint8Array;
|
|
1606
|
+
/** Raw 32-byte X25519 private key. Caller must wipe after E2EEChannel.destroy(). */
|
|
1607
|
+
x25519PrivateKey: Uint8Array;
|
|
1608
|
+
}
|
|
1609
|
+
declare class E2EEChannel extends EventEmitter {
|
|
1610
|
+
private readonly identity;
|
|
1611
|
+
private readonly docId;
|
|
1612
|
+
private sessionKey;
|
|
1613
|
+
private remotePublicKey;
|
|
1614
|
+
private _isEstablished;
|
|
1615
|
+
constructor(identity: E2EEIdentity, docId: string);
|
|
1616
|
+
get isEstablished(): boolean;
|
|
1617
|
+
/**
|
|
1618
|
+
* Process a key-exchange message from the remote peer.
|
|
1619
|
+
* Called when the `key-exchange` data channel receives a message.
|
|
1620
|
+
*
|
|
1621
|
+
* The message is the remote peer's raw 32-byte Ed25519 public key.
|
|
1622
|
+
* After receiving it, ECDH is computed and the session key derived.
|
|
1623
|
+
*/
|
|
1624
|
+
handleKeyExchange(remoteEdPubKey: Uint8Array): Promise<void>;
|
|
1625
|
+
/**
|
|
1626
|
+
* Returns the local Ed25519 public key to send to the remote peer
|
|
1627
|
+
* via the `key-exchange` data channel.
|
|
1628
|
+
*/
|
|
1629
|
+
getKeyExchangeMessage(): Uint8Array;
|
|
1630
|
+
/**
|
|
1631
|
+
* Encrypt a message for sending over a data channel.
|
|
1632
|
+
* Returns `[12-byte nonce || AES-256-GCM ciphertext]`.
|
|
1633
|
+
*
|
|
1634
|
+
* @throws if the session key has not been established yet.
|
|
1635
|
+
*/
|
|
1636
|
+
encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
|
|
1637
|
+
/**
|
|
1638
|
+
* Decrypt a message received from a data channel.
|
|
1639
|
+
* Expects `[12-byte nonce || AES-256-GCM ciphertext]`.
|
|
1640
|
+
*
|
|
1641
|
+
* @throws if the session key has not been established or decryption fails.
|
|
1642
|
+
*/
|
|
1643
|
+
decrypt(data: Uint8Array): Promise<Uint8Array>;
|
|
1644
|
+
/** Destroy the session key and wipe sensitive material. */
|
|
1645
|
+
destroy(): void;
|
|
1646
|
+
/** Sort two keys lexicographically so both peers produce the same order. */
|
|
1647
|
+
private sortKeys;
|
|
1648
|
+
}
|
|
1649
|
+
//#endregion
|
|
1602
1650
|
//#region packages/provider/src/webrtc/DataChannelRouter.d.ts
|
|
1651
|
+
/** Name of the data channel used for E2EE key exchange. */
|
|
1652
|
+
declare const KEY_EXCHANGE_CHANNEL = "key-exchange";
|
|
1603
1653
|
declare class DataChannelRouter extends EventEmitter {
|
|
1604
1654
|
private connection;
|
|
1605
1655
|
private channels;
|
|
1656
|
+
private encryptor;
|
|
1657
|
+
/** Channels excluded from encryption (key-exchange itself, awareness). */
|
|
1658
|
+
private plaintextChannels;
|
|
1606
1659
|
constructor(connection: RTCPeerConnection);
|
|
1660
|
+
/** Attach an E2EE encryptor. All channels (except key-exchange) will be encrypted. */
|
|
1661
|
+
setEncryptor(encryptor: E2EEChannel): void;
|
|
1607
1662
|
/** Create a named data channel (initiator side). */
|
|
1608
1663
|
createChannel(name: string, options?: RTCDataChannelInit): RTCDataChannel;
|
|
1609
1664
|
/** Create the standard set of channels for Abracadabra WebRTC. */
|
|
@@ -1612,8 +1667,21 @@ declare class DataChannelRouter extends EventEmitter {
|
|
|
1612
1667
|
enableAwareness: boolean;
|
|
1613
1668
|
enableFileTransfer: boolean;
|
|
1614
1669
|
}): void;
|
|
1670
|
+
/**
|
|
1671
|
+
* Create namespaced channels for a child/subdocument.
|
|
1672
|
+
* Channel names are prefixed with `{childId}:` to avoid collisions.
|
|
1673
|
+
*/
|
|
1674
|
+
createSubdocChannels(childId: string, opts: {
|
|
1675
|
+
enableDocSync: boolean;
|
|
1676
|
+
enableAwareness: boolean;
|
|
1677
|
+
}): void;
|
|
1615
1678
|
getChannel(name: string): RTCDataChannel | null;
|
|
1616
1679
|
isOpen(name: string): boolean;
|
|
1680
|
+
/**
|
|
1681
|
+
* Send data on a named channel, encrypting if E2EE is active.
|
|
1682
|
+
* Falls back to plaintext if no encryptor is set or for exempt channels.
|
|
1683
|
+
*/
|
|
1684
|
+
send(name: string, data: Uint8Array): Promise<void>;
|
|
1617
1685
|
private registerChannel;
|
|
1618
1686
|
close(): void;
|
|
1619
1687
|
destroy(): void;
|
|
@@ -1731,6 +1799,12 @@ interface AbracadabraWebRTCConfiguration {
|
|
|
1731
1799
|
docId: string;
|
|
1732
1800
|
/** Server base URL (http/https). Signaling URL derived automatically. */
|
|
1733
1801
|
url: string;
|
|
1802
|
+
/**
|
|
1803
|
+
* Override the signaling WebSocket URL. When set, signaling connects to
|
|
1804
|
+
* this server instead of deriving from `url`. Useful for local spaces that
|
|
1805
|
+
* piggyback a remote server's signaling endpoint.
|
|
1806
|
+
*/
|
|
1807
|
+
signalingUrl?: string;
|
|
1734
1808
|
/** JWT token or async token factory. */
|
|
1735
1809
|
token: string | (() => string) | (() => Promise<string>);
|
|
1736
1810
|
/** Optional Y.Doc to sync over data channels (hybrid mode). */
|
|
@@ -1753,6 +1827,12 @@ interface AbracadabraWebRTCConfiguration {
|
|
|
1753
1827
|
fileChunkSize?: number;
|
|
1754
1828
|
/** Auto-connect on construction. Default: true. */
|
|
1755
1829
|
autoConnect?: boolean;
|
|
1830
|
+
/**
|
|
1831
|
+
* E2EE identity for application-level encryption on data channels.
|
|
1832
|
+
* When provided, all data channel messages (except key-exchange) are
|
|
1833
|
+
* encrypted with AES-256-GCM using X25519 ECDH-derived session keys.
|
|
1834
|
+
*/
|
|
1835
|
+
e2ee?: E2EEIdentity;
|
|
1756
1836
|
/** WebSocket polyfill for signaling (e.g. for Node.js). */
|
|
1757
1837
|
WebSocketPolyfill?: any;
|
|
1758
1838
|
}
|
|
@@ -1811,6 +1891,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
|
|
|
1811
1891
|
private peerConnections;
|
|
1812
1892
|
private yjsChannels;
|
|
1813
1893
|
private fileChannels;
|
|
1894
|
+
private e2eeChannels;
|
|
1814
1895
|
private readonly config;
|
|
1815
1896
|
readonly peers: Map<string, PeerState>;
|
|
1816
1897
|
localPeerId: string | null;
|
|
@@ -1848,6 +1929,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
|
|
|
1848
1929
|
private removeAllPeers;
|
|
1849
1930
|
private createPeerConnection;
|
|
1850
1931
|
private attachDataHandlers;
|
|
1932
|
+
private startDataSync;
|
|
1851
1933
|
private initiateConnection;
|
|
1852
1934
|
private handleOffer;
|
|
1853
1935
|
private buildSignalingUrl;
|
|
@@ -1942,7 +2024,16 @@ declare class YjsDataChannel {
|
|
|
1942
2024
|
private awarenessUpdateHandler;
|
|
1943
2025
|
private channelOpenHandler;
|
|
1944
2026
|
private channelMessageHandler;
|
|
1945
|
-
|
|
2027
|
+
/** Channel names used for sync and awareness (supports namespaced subdoc channels). */
|
|
2028
|
+
private readonly syncChannelName;
|
|
2029
|
+
private readonly awarenessChannelName;
|
|
2030
|
+
/**
|
|
2031
|
+
* @param document - The Y.Doc to sync
|
|
2032
|
+
* @param awareness - Optional Awareness instance
|
|
2033
|
+
* @param router - DataChannelRouter for the peer connection
|
|
2034
|
+
* @param channelPrefix - Optional prefix for subdocument channels (e.g. `"{childId}:"`)
|
|
2035
|
+
*/
|
|
2036
|
+
constructor(document: Y.Doc, awareness: Awareness | null, router: DataChannelRouter, channelPrefix?: string);
|
|
1946
2037
|
/** Start listening for Y.js updates and data channel messages. */
|
|
1947
2038
|
attach(): void;
|
|
1948
2039
|
/** Stop listening and clean up handlers. */
|
|
@@ -1953,4 +2044,74 @@ declare class YjsDataChannel {
|
|
|
1953
2044
|
private handleAwarenessMessage;
|
|
1954
2045
|
}
|
|
1955
2046
|
//#endregion
|
|
1956
|
-
|
|
2047
|
+
//#region packages/provider/src/webrtc/ManualSignaling.d.ts
|
|
2048
|
+
interface ManualSignalingBlob {
|
|
2049
|
+
/** SDP offer or answer. */
|
|
2050
|
+
sdp: string;
|
|
2051
|
+
/** Gathered ICE candidates. */
|
|
2052
|
+
candidates: string[];
|
|
2053
|
+
/** Peer ID of the sender. */
|
|
2054
|
+
peerId: string;
|
|
2055
|
+
}
|
|
2056
|
+
declare class ManualSignaling extends EventEmitter {
|
|
2057
|
+
localPeerId: string;
|
|
2058
|
+
isConnected: boolean;
|
|
2059
|
+
private pc;
|
|
2060
|
+
private iceServers;
|
|
2061
|
+
constructor(iceServers?: RTCIceServer[]);
|
|
2062
|
+
/**
|
|
2063
|
+
* Initiator: create an offer blob with gathered ICE candidates.
|
|
2064
|
+
* Returns a blob to share with the remote peer.
|
|
2065
|
+
*/
|
|
2066
|
+
createOfferBlob(): Promise<ManualSignalingBlob>;
|
|
2067
|
+
/**
|
|
2068
|
+
* Responder: accept an offer blob and create an answer blob.
|
|
2069
|
+
* The answer blob should be shared back to the initiator.
|
|
2070
|
+
*/
|
|
2071
|
+
acceptOffer(offerBlob: ManualSignalingBlob): Promise<ManualSignalingBlob>;
|
|
2072
|
+
/**
|
|
2073
|
+
* Initiator: accept the answer blob from the responder.
|
|
2074
|
+
* Completes the connection.
|
|
2075
|
+
*/
|
|
2076
|
+
acceptAnswer(answerBlob: ManualSignalingBlob): Promise<void>;
|
|
2077
|
+
sendOffer(_to: string, _sdp: string): void;
|
|
2078
|
+
sendAnswer(_to: string, _sdp: string): void;
|
|
2079
|
+
sendIce(_to: string, _candidate: string): void;
|
|
2080
|
+
sendMute(_muted: boolean): void;
|
|
2081
|
+
sendMediaState(_video: boolean, _screen: boolean): void;
|
|
2082
|
+
sendProfile(_name: string, _color: string): void;
|
|
2083
|
+
sendLeave(): void;
|
|
2084
|
+
connect(): Promise<void>;
|
|
2085
|
+
disconnect(): void;
|
|
2086
|
+
destroy(): void;
|
|
2087
|
+
}
|
|
2088
|
+
//#endregion
|
|
2089
|
+
//#region packages/provider/src/sync/BroadcastChannelSync.d.ts
|
|
2090
|
+
declare class BroadcastChannelSync extends EventEmitter {
|
|
2091
|
+
private readonly document;
|
|
2092
|
+
private readonly awareness;
|
|
2093
|
+
private readonly channelName;
|
|
2094
|
+
private channel;
|
|
2095
|
+
private docUpdateHandler;
|
|
2096
|
+
private awarenessUpdateHandler;
|
|
2097
|
+
private destroyed;
|
|
2098
|
+
constructor(document: Y.Doc, awareness: Awareness | null, channelName: string);
|
|
2099
|
+
/** Convenience factory using standard channel naming. */
|
|
2100
|
+
static forDoc(document: Y.Doc, docId: string, awareness?: Awareness | null): BroadcastChannelSync;
|
|
2101
|
+
/** Start syncing. Opens the BroadcastChannel and initiates a sync handshake. */
|
|
2102
|
+
connect(): void;
|
|
2103
|
+
/** Stop syncing and close the BroadcastChannel. */
|
|
2104
|
+
disconnect(): void;
|
|
2105
|
+
/** Disconnect and prevent reconnection. */
|
|
2106
|
+
destroy(): void;
|
|
2107
|
+
private broadcastUpdate;
|
|
2108
|
+
private broadcastAwareness;
|
|
2109
|
+
private sendSyncStep1;
|
|
2110
|
+
private broadcastQueryPeers;
|
|
2111
|
+
private handleMessage;
|
|
2112
|
+
private handleSyncMessage;
|
|
2113
|
+
private handleUpdateMessage;
|
|
2114
|
+
private handleAwarenessMessage;
|
|
2115
|
+
}
|
|
2116
|
+
//#endregion
|
|
2117
|
+
export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
|
package/package.json
CHANGED
|
@@ -442,19 +442,18 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
442
442
|
const childDoc = new Y.Doc({ guid: childId });
|
|
443
443
|
|
|
444
444
|
// Fire-and-forget: tell the server this child belongs to the parent.
|
|
445
|
-
// The child provider's own WebSocket handshake (TCP + TLS + auth) is
|
|
446
|
-
// slow enough that the server will have processed the registration
|
|
447
|
-
// before the child's first SyncStep1 arrives. In the rare race the
|
|
448
|
-
// child's built-in reconnect logic retries automatically.
|
|
449
445
|
this.registerSubdoc(childDoc);
|
|
450
446
|
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
447
|
+
// Reuse the parent's WebSocket for multiplexing — all children share a
|
|
448
|
+
// single TCP connection instead of each opening its own. The shared
|
|
449
|
+
// AbracadabraWS demuxes frames by documentName, so each child provider
|
|
450
|
+
// still syncs independently. Because we pass websocketProvider the
|
|
451
|
+
// child's manageSocket will be false, meaning destroy() detaches
|
|
452
|
+
// without tearing down the shared connection.
|
|
454
453
|
const childProvider = new AbracadabraProvider({
|
|
455
454
|
name: childId,
|
|
456
455
|
document: childDoc,
|
|
457
|
-
|
|
456
|
+
websocketProvider: this.configuration.websocketProvider,
|
|
458
457
|
token: this.configuration.token,
|
|
459
458
|
subdocLoading: this.subdocLoading,
|
|
460
459
|
disableOfflineStore: this.abracadabraConfig.disableOfflineStore,
|
|
@@ -465,6 +464,10 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
465
464
|
keystore: this.abracadabraConfig.keystore,
|
|
466
465
|
});
|
|
467
466
|
|
|
467
|
+
// Shared websocketProvider means manageSocket is false, so the base
|
|
468
|
+
// class constructor skips auto-attach. Attach explicitly.
|
|
469
|
+
childProvider.attach();
|
|
470
|
+
|
|
468
471
|
this.childProviders.set(childId, childProvider);
|
|
469
472
|
|
|
470
473
|
this.emit("subdocLoaded", { childId, provider: childProvider });
|
package/src/index.ts
CHANGED
|
@@ -24,3 +24,4 @@ export type { BackgroundSyncManagerOptions } from "./BackgroundSyncManager.ts";
|
|
|
24
24
|
export { BackgroundSyncPersistence } from "./BackgroundSyncPersistence.ts";
|
|
25
25
|
export type { DocSyncState } from "./BackgroundSyncPersistence.ts";
|
|
26
26
|
export * from "./webrtc/index.ts";
|
|
27
|
+
export { BroadcastChannelSync } from "./sync/BroadcastChannelSync.ts";
|
|
@@ -0,0 +1,235 @@
|
|
|
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 EventEmitter from "../EventEmitter.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cross-tab Y.js document and awareness sync via the BroadcastChannel API.
|
|
14
|
+
*
|
|
15
|
+
* Opens a BroadcastChannel per document, relays Y.js updates and awareness
|
|
16
|
+
* state between tabs on the same origin. No server, no WebRTC — just same-device
|
|
17
|
+
* multi-tab sync. Same-origin means same trust boundary, so no encryption needed.
|
|
18
|
+
*
|
|
19
|
+
* Uses the same y-protocols/sync encoding as `YjsDataChannel` for consistency.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CHANNEL_PREFIX = "abra:sync:";
|
|
23
|
+
|
|
24
|
+
/** Message type discriminators (first byte). */
|
|
25
|
+
const MSG = {
|
|
26
|
+
SYNC: 0x00,
|
|
27
|
+
UPDATE: 0x01,
|
|
28
|
+
AWARENESS: 0x02,
|
|
29
|
+
/** Request all tabs to respond with SyncStep1. */
|
|
30
|
+
QUERY_PEERS: 0x03,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export class BroadcastChannelSync extends EventEmitter {
|
|
34
|
+
private channel: BroadcastChannel | null = null;
|
|
35
|
+
private docUpdateHandler: ((update: Uint8Array, origin: any) => void) | null =
|
|
36
|
+
null;
|
|
37
|
+
private awarenessUpdateHandler:
|
|
38
|
+
| ((
|
|
39
|
+
changes: { added: number[]; updated: number[]; removed: number[] },
|
|
40
|
+
origin: any,
|
|
41
|
+
) => void)
|
|
42
|
+
| null = null;
|
|
43
|
+
private destroyed = false;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly document: Y.Doc,
|
|
47
|
+
private readonly awareness: Awareness | null,
|
|
48
|
+
private readonly channelName: string,
|
|
49
|
+
) {
|
|
50
|
+
super();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Convenience factory using standard channel naming. */
|
|
54
|
+
static forDoc(
|
|
55
|
+
document: Y.Doc,
|
|
56
|
+
docId: string,
|
|
57
|
+
awareness?: Awareness | null,
|
|
58
|
+
): BroadcastChannelSync {
|
|
59
|
+
return new BroadcastChannelSync(
|
|
60
|
+
document,
|
|
61
|
+
awareness ?? null,
|
|
62
|
+
`${CHANNEL_PREFIX}${docId}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Start syncing. Opens the BroadcastChannel and initiates a sync handshake. */
|
|
67
|
+
connect(): void {
|
|
68
|
+
if (this.destroyed || this.channel) return;
|
|
69
|
+
|
|
70
|
+
if (typeof globalThis.BroadcastChannel === "undefined") {
|
|
71
|
+
// Environment doesn't support BroadcastChannel (e.g. some Node.js builds).
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.channel = new BroadcastChannel(this.channelName);
|
|
76
|
+
this.channel.onmessage = (event) => this.handleMessage(event.data);
|
|
77
|
+
|
|
78
|
+
// Listen for local doc updates → broadcast to other tabs.
|
|
79
|
+
this.docUpdateHandler = (update: Uint8Array, origin: any) => {
|
|
80
|
+
if (origin === this) return; // Don't echo.
|
|
81
|
+
this.broadcastUpdate(update);
|
|
82
|
+
};
|
|
83
|
+
this.document.on("update", this.docUpdateHandler);
|
|
84
|
+
|
|
85
|
+
// Listen for local awareness changes → broadcast to other tabs.
|
|
86
|
+
if (this.awareness) {
|
|
87
|
+
this.awarenessUpdateHandler = (
|
|
88
|
+
{
|
|
89
|
+
added,
|
|
90
|
+
updated,
|
|
91
|
+
removed,
|
|
92
|
+
}: { added: number[]; updated: number[]; removed: number[] },
|
|
93
|
+
_origin: any,
|
|
94
|
+
) => {
|
|
95
|
+
const changedClients = added.concat(updated).concat(removed);
|
|
96
|
+
const update = encodeAwarenessUpdate(this.awareness!, changedClients);
|
|
97
|
+
this.broadcastAwareness(update);
|
|
98
|
+
};
|
|
99
|
+
this.awareness.on("update", this.awarenessUpdateHandler);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Announce presence — other tabs reply with SyncStep1.
|
|
103
|
+
this.broadcastQueryPeers();
|
|
104
|
+
|
|
105
|
+
// Send our SyncStep1 to trigger state exchange.
|
|
106
|
+
this.sendSyncStep1();
|
|
107
|
+
|
|
108
|
+
this.emit("connected");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stop syncing and close the BroadcastChannel. */
|
|
112
|
+
disconnect(): void {
|
|
113
|
+
if (this.docUpdateHandler) {
|
|
114
|
+
this.document.off("update", this.docUpdateHandler);
|
|
115
|
+
this.docUpdateHandler = null;
|
|
116
|
+
}
|
|
117
|
+
if (this.awarenessUpdateHandler && this.awareness) {
|
|
118
|
+
this.awareness.off("update", this.awarenessUpdateHandler);
|
|
119
|
+
this.awarenessUpdateHandler = null;
|
|
120
|
+
}
|
|
121
|
+
if (this.channel) {
|
|
122
|
+
this.channel.close();
|
|
123
|
+
this.channel = null;
|
|
124
|
+
}
|
|
125
|
+
this.emit("disconnected");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Disconnect and prevent reconnection. */
|
|
129
|
+
destroy(): void {
|
|
130
|
+
this.disconnect();
|
|
131
|
+
this.destroyed = true;
|
|
132
|
+
this.removeAllListeners();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Outgoing ────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
private broadcastUpdate(update: Uint8Array): void {
|
|
138
|
+
if (!this.channel) return;
|
|
139
|
+
const encoder = encoding.createEncoder();
|
|
140
|
+
encoding.writeVarUint(encoder, MSG.UPDATE);
|
|
141
|
+
encoding.writeVarUint8Array(encoder, update);
|
|
142
|
+
this.channel.postMessage(encoding.toUint8Array(encoder));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private broadcastAwareness(update: Uint8Array): void {
|
|
146
|
+
if (!this.channel) return;
|
|
147
|
+
const encoder = encoding.createEncoder();
|
|
148
|
+
encoding.writeVarUint(encoder, MSG.AWARENESS);
|
|
149
|
+
encoding.writeVarUint8Array(encoder, update);
|
|
150
|
+
this.channel.postMessage(encoding.toUint8Array(encoder));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private sendSyncStep1(): void {
|
|
154
|
+
if (!this.channel) return;
|
|
155
|
+
const encoder = encoding.createEncoder();
|
|
156
|
+
encoding.writeVarUint(encoder, MSG.SYNC);
|
|
157
|
+
syncProtocol.writeSyncStep1(encoder, this.document);
|
|
158
|
+
this.channel.postMessage(encoding.toUint8Array(encoder));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private broadcastQueryPeers(): void {
|
|
162
|
+
if (!this.channel) return;
|
|
163
|
+
const encoder = encoding.createEncoder();
|
|
164
|
+
encoding.writeVarUint(encoder, MSG.QUERY_PEERS);
|
|
165
|
+
this.channel.postMessage(encoding.toUint8Array(encoder));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Incoming ────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
private handleMessage(data: any): void {
|
|
171
|
+
if (!(data instanceof ArrayBuffer || data instanceof Uint8Array)) return;
|
|
172
|
+
const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
173
|
+
const decoder = decoding.createDecoder(buf);
|
|
174
|
+
const msgType = decoding.readVarUint(decoder);
|
|
175
|
+
|
|
176
|
+
switch (msgType) {
|
|
177
|
+
case MSG.SYNC:
|
|
178
|
+
this.handleSyncMessage(decoder);
|
|
179
|
+
break;
|
|
180
|
+
case MSG.UPDATE:
|
|
181
|
+
this.handleUpdateMessage(decoder);
|
|
182
|
+
break;
|
|
183
|
+
case MSG.AWARENESS:
|
|
184
|
+
this.handleAwarenessMessage(decoder);
|
|
185
|
+
break;
|
|
186
|
+
case MSG.QUERY_PEERS:
|
|
187
|
+
// Another tab joined — respond with our SyncStep1.
|
|
188
|
+
this.sendSyncStep1();
|
|
189
|
+
if (this.awareness) {
|
|
190
|
+
const update = encodeAwarenessUpdate(
|
|
191
|
+
this.awareness,
|
|
192
|
+
Array.from(this.awareness.getStates().keys()),
|
|
193
|
+
);
|
|
194
|
+
this.broadcastAwareness(update);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private handleSyncMessage(decoder: decoding.Decoder): void {
|
|
201
|
+
const encoder = encoding.createEncoder();
|
|
202
|
+
const syncMessageType = syncProtocol.readSyncMessage(
|
|
203
|
+
decoder,
|
|
204
|
+
encoder,
|
|
205
|
+
this.document,
|
|
206
|
+
this,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// If the sync protocol generated a response (SyncStep2), broadcast it.
|
|
210
|
+
if (encoding.length(encoder) > 0) {
|
|
211
|
+
const responseEncoder = encoding.createEncoder();
|
|
212
|
+
encoding.writeVarUint(responseEncoder, MSG.SYNC);
|
|
213
|
+
encoding.writeUint8Array(
|
|
214
|
+
responseEncoder,
|
|
215
|
+
encoding.toUint8Array(encoder),
|
|
216
|
+
);
|
|
217
|
+
this.channel?.postMessage(encoding.toUint8Array(responseEncoder));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
|
|
221
|
+
this.emit("synced");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private handleUpdateMessage(decoder: decoding.Decoder): void {
|
|
226
|
+
const update = decoding.readVarUint8Array(decoder);
|
|
227
|
+
Y.applyUpdate(this.document, update, this);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private handleAwarenessMessage(decoder: decoding.Decoder): void {
|
|
231
|
+
if (!this.awareness) return;
|
|
232
|
+
const update = decoding.readVarUint8Array(decoder);
|
|
233
|
+
applyAwarenessUpdate(this.awareness, update, this);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -6,8 +6,11 @@ import { SignalingSocket } from "./SignalingSocket.ts";
|
|
|
6
6
|
import { PeerConnection } from "./PeerConnection.ts";
|
|
7
7
|
import { YjsDataChannel } from "./YjsDataChannel.ts";
|
|
8
8
|
import { FileTransferChannel, FileTransferHandle } from "./FileTransferChannel.ts";
|
|
9
|
+
import { E2EEChannel, type E2EEIdentity } from "./E2EEChannel.ts";
|
|
10
|
+
import { KEY_EXCHANGE_CHANNEL } from "./DataChannelRouter.ts";
|
|
9
11
|
import {
|
|
10
12
|
DEFAULT_ICE_SERVERS,
|
|
13
|
+
CHANNEL_NAMES,
|
|
11
14
|
type AbracadabraWebRTCConfiguration,
|
|
12
15
|
type PeerInfo,
|
|
13
16
|
type PeerState,
|
|
@@ -30,10 +33,12 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
30
33
|
private peerConnections = new Map<string, PeerConnection>();
|
|
31
34
|
private yjsChannels = new Map<string, YjsDataChannel>();
|
|
32
35
|
private fileChannels = new Map<string, FileTransferChannel>();
|
|
36
|
+
private e2eeChannels = new Map<string, E2EEChannel>();
|
|
33
37
|
|
|
34
38
|
private readonly config: {
|
|
35
39
|
docId: string;
|
|
36
40
|
url: string;
|
|
41
|
+
signalingUrl: string | null;
|
|
37
42
|
token: string | (() => string) | (() => Promise<string>);
|
|
38
43
|
document: Y.Doc | null;
|
|
39
44
|
awareness: Awareness | null;
|
|
@@ -44,6 +49,7 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
44
49
|
enableAwarenessSync: boolean;
|
|
45
50
|
enableFileTransfer: boolean;
|
|
46
51
|
fileChunkSize: number;
|
|
52
|
+
e2ee: E2EEIdentity | null;
|
|
47
53
|
WebSocketPolyfill: any;
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -60,6 +66,7 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
60
66
|
this.config = {
|
|
61
67
|
docId: configuration.docId,
|
|
62
68
|
url: configuration.url,
|
|
69
|
+
signalingUrl: configuration.signalingUrl ?? null,
|
|
63
70
|
token: configuration.token,
|
|
64
71
|
document: doc,
|
|
65
72
|
awareness,
|
|
@@ -70,6 +77,7 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
70
77
|
enableAwarenessSync: configuration.enableAwarenessSync ?? !!awareness,
|
|
71
78
|
enableFileTransfer: configuration.enableFileTransfer ?? false,
|
|
72
79
|
fileChunkSize: configuration.fileChunkSize ?? 16384,
|
|
80
|
+
e2ee: configuration.e2ee ?? null,
|
|
73
81
|
WebSocketPolyfill: configuration.WebSocketPolyfill,
|
|
74
82
|
};
|
|
75
83
|
|
|
@@ -365,6 +373,12 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
365
373
|
private removePeer(peerId: string): void {
|
|
366
374
|
this.peers.delete(peerId);
|
|
367
375
|
|
|
376
|
+
const e2ee = this.e2eeChannels.get(peerId);
|
|
377
|
+
if (e2ee) {
|
|
378
|
+
e2ee.destroy();
|
|
379
|
+
this.e2eeChannels.delete(peerId);
|
|
380
|
+
}
|
|
381
|
+
|
|
368
382
|
const yjs = this.yjsChannels.get(peerId);
|
|
369
383
|
if (yjs) {
|
|
370
384
|
yjs.destroy();
|
|
@@ -447,8 +461,48 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
447
461
|
}
|
|
448
462
|
|
|
449
463
|
private attachDataHandlers(peerId: string, pc: PeerConnection): void {
|
|
464
|
+
// Set up E2EE if configured.
|
|
465
|
+
if (this.config.e2ee) {
|
|
466
|
+
const e2ee = new E2EEChannel(this.config.e2ee, this.config.docId);
|
|
467
|
+
this.e2eeChannels.set(peerId, e2ee);
|
|
468
|
+
pc.router.setEncryptor(e2ee);
|
|
469
|
+
|
|
470
|
+
// Listen for key-exchange messages on the router.
|
|
471
|
+
pc.router.on("channelMessage", async ({ name, data }: { name: string; data: any }) => {
|
|
472
|
+
if (name === KEY_EXCHANGE_CHANNEL) {
|
|
473
|
+
const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
474
|
+
await e2ee.handleKeyExchange(buf);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// When key-exchange channel opens, send our public key.
|
|
479
|
+
pc.router.on("channelOpen", ({ name, channel }: { name: string; channel: RTCDataChannel }) => {
|
|
480
|
+
if (name === KEY_EXCHANGE_CHANNEL) {
|
|
481
|
+
channel.send(e2ee.getKeyExchangeMessage());
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
e2ee.on("established", () => {
|
|
486
|
+
this.emit("e2eeEstablished", { peerId });
|
|
487
|
+
// Now that E2EE is ready, start Y.js sync (deferred).
|
|
488
|
+
this.startDataSync(peerId, pc);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
e2ee.on("error", (err: Error) => {
|
|
492
|
+
this.emit("e2eeFailed", { peerId, error: err });
|
|
493
|
+
});
|
|
494
|
+
} else {
|
|
495
|
+
// No E2EE — start data sync immediately.
|
|
496
|
+
this.startDataSync(peerId, pc);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private startDataSync(peerId: string, pc: PeerConnection): void {
|
|
450
501
|
// Attach Y.js sync.
|
|
451
502
|
if (this.config.document && this.config.enableDocSync) {
|
|
503
|
+
// Don't double-attach if already set up.
|
|
504
|
+
if (this.yjsChannels.has(peerId)) return;
|
|
505
|
+
|
|
452
506
|
const yjs = new YjsDataChannel(
|
|
453
507
|
this.config.document,
|
|
454
508
|
this.config.enableAwarenessSync ? this.config.awareness : null,
|
|
@@ -459,7 +513,7 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
459
513
|
}
|
|
460
514
|
|
|
461
515
|
// Attach file transfer.
|
|
462
|
-
if (this.config.enableFileTransfer) {
|
|
516
|
+
if (this.config.enableFileTransfer && !this.fileChannels.has(peerId)) {
|
|
463
517
|
const fc = new FileTransferChannel(pc.router, this.config.fileChunkSize);
|
|
464
518
|
|
|
465
519
|
fc.on("receiveStart", (meta: any) => {
|
|
@@ -485,6 +539,11 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
485
539
|
private async initiateConnection(peerId: string): Promise<void> {
|
|
486
540
|
const pc = this.createPeerConnection(peerId);
|
|
487
541
|
|
|
542
|
+
// Create key-exchange channel first if E2EE is enabled.
|
|
543
|
+
if (this.config.e2ee) {
|
|
544
|
+
pc.router.createChannel(KEY_EXCHANGE_CHANNEL, { ordered: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
488
547
|
// Create data channels (initiator creates them).
|
|
489
548
|
pc.router.createDefaultChannels({
|
|
490
549
|
enableDocSync: this.config.enableDocSync,
|
|
@@ -525,6 +584,14 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
525
584
|
// ── Private: URL Building ───────────────────────────────────────────────
|
|
526
585
|
|
|
527
586
|
private buildSignalingUrl(): string {
|
|
587
|
+
// Use explicit signalingUrl if provided.
|
|
588
|
+
if (this.config.signalingUrl) {
|
|
589
|
+
let sigBase = this.config.signalingUrl;
|
|
590
|
+
while (sigBase.endsWith("/")) sigBase = sigBase.slice(0, -1);
|
|
591
|
+
sigBase = sigBase.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
|
|
592
|
+
return `${sigBase}/ws/${encodeURIComponent(this.config.docId)}/signaling`;
|
|
593
|
+
}
|
|
594
|
+
|
|
528
595
|
let base = this.config.url;
|
|
529
596
|
|
|
530
597
|
// Remove trailing slashes.
|