@abraca/dabra 1.0.3 → 1.0.5
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 -37
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +558 -38
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +165 -2
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +11 -8
- package/src/AbracadabraWS.ts +1 -1
- package/src/index.ts +1 -0
- package/src/sync/BroadcastChannelSync.ts +235 -0
- package/src/types.ts +2 -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
|
@@ -941,6 +941,8 @@ interface UserProfile {
|
|
|
941
941
|
email: string | null;
|
|
942
942
|
displayName: string | null;
|
|
943
943
|
role: string;
|
|
944
|
+
/** Account-level Ed25519 public key (base64url). Canonical user identity. */
|
|
945
|
+
publicKey: string | null;
|
|
944
946
|
}
|
|
945
947
|
interface DocumentMeta {
|
|
946
948
|
id: string;
|
|
@@ -1597,11 +1599,66 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1597
1599
|
private _walkXml;
|
|
1598
1600
|
}
|
|
1599
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
|
|
1600
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";
|
|
1601
1653
|
declare class DataChannelRouter extends EventEmitter {
|
|
1602
1654
|
private connection;
|
|
1603
1655
|
private channels;
|
|
1656
|
+
private encryptor;
|
|
1657
|
+
/** Channels excluded from encryption (key-exchange itself, awareness). */
|
|
1658
|
+
private plaintextChannels;
|
|
1604
1659
|
constructor(connection: RTCPeerConnection);
|
|
1660
|
+
/** Attach an E2EE encryptor. All channels (except key-exchange) will be encrypted. */
|
|
1661
|
+
setEncryptor(encryptor: E2EEChannel): void;
|
|
1605
1662
|
/** Create a named data channel (initiator side). */
|
|
1606
1663
|
createChannel(name: string, options?: RTCDataChannelInit): RTCDataChannel;
|
|
1607
1664
|
/** Create the standard set of channels for Abracadabra WebRTC. */
|
|
@@ -1610,8 +1667,21 @@ declare class DataChannelRouter extends EventEmitter {
|
|
|
1610
1667
|
enableAwareness: boolean;
|
|
1611
1668
|
enableFileTransfer: boolean;
|
|
1612
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;
|
|
1613
1678
|
getChannel(name: string): RTCDataChannel | null;
|
|
1614
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>;
|
|
1615
1685
|
private registerChannel;
|
|
1616
1686
|
close(): void;
|
|
1617
1687
|
destroy(): void;
|
|
@@ -1729,6 +1799,12 @@ interface AbracadabraWebRTCConfiguration {
|
|
|
1729
1799
|
docId: string;
|
|
1730
1800
|
/** Server base URL (http/https). Signaling URL derived automatically. */
|
|
1731
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;
|
|
1732
1808
|
/** JWT token or async token factory. */
|
|
1733
1809
|
token: string | (() => string) | (() => Promise<string>);
|
|
1734
1810
|
/** Optional Y.Doc to sync over data channels (hybrid mode). */
|
|
@@ -1751,6 +1827,12 @@ interface AbracadabraWebRTCConfiguration {
|
|
|
1751
1827
|
fileChunkSize?: number;
|
|
1752
1828
|
/** Auto-connect on construction. Default: true. */
|
|
1753
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;
|
|
1754
1836
|
/** WebSocket polyfill for signaling (e.g. for Node.js). */
|
|
1755
1837
|
WebSocketPolyfill?: any;
|
|
1756
1838
|
}
|
|
@@ -1809,6 +1891,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
|
|
|
1809
1891
|
private peerConnections;
|
|
1810
1892
|
private yjsChannels;
|
|
1811
1893
|
private fileChannels;
|
|
1894
|
+
private e2eeChannels;
|
|
1812
1895
|
private readonly config;
|
|
1813
1896
|
readonly peers: Map<string, PeerState>;
|
|
1814
1897
|
localPeerId: string | null;
|
|
@@ -1846,6 +1929,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
|
|
|
1846
1929
|
private removeAllPeers;
|
|
1847
1930
|
private createPeerConnection;
|
|
1848
1931
|
private attachDataHandlers;
|
|
1932
|
+
private startDataSync;
|
|
1849
1933
|
private initiateConnection;
|
|
1850
1934
|
private handleOffer;
|
|
1851
1935
|
private buildSignalingUrl;
|
|
@@ -1940,7 +2024,16 @@ declare class YjsDataChannel {
|
|
|
1940
2024
|
private awarenessUpdateHandler;
|
|
1941
2025
|
private channelOpenHandler;
|
|
1942
2026
|
private channelMessageHandler;
|
|
1943
|
-
|
|
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);
|
|
1944
2037
|
/** Start listening for Y.js updates and data channel messages. */
|
|
1945
2038
|
attach(): void;
|
|
1946
2039
|
/** Stop listening and clean up handlers. */
|
|
@@ -1951,4 +2044,74 @@ declare class YjsDataChannel {
|
|
|
1951
2044
|
private handleAwarenessMessage;
|
|
1952
2045
|
}
|
|
1953
2046
|
//#endregion
|
|
1954
|
-
|
|
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/AbracadabraWS.ts
CHANGED
|
@@ -512,7 +512,7 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
512
512
|
|
|
513
513
|
// Detect server-side rate-limit close (code 4429).
|
|
514
514
|
// `event` may be a CloseEvent (browser) with `.code`, or a raw number (ws/Node.js).
|
|
515
|
-
|
|
515
|
+
// `event` may be a CloseEvent (browser) with `.code`, or a raw number (ws/Node.js).
|
|
516
516
|
const isRateLimited = (event as any)?.code === 4429 || event === 4429;
|
|
517
517
|
this.emit("disconnect", { event });
|
|
518
518
|
if (isRateLimited) {
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -166,6 +166,8 @@ export interface UserProfile {
|
|
|
166
166
|
email: string | null;
|
|
167
167
|
displayName: string | null;
|
|
168
168
|
role: string;
|
|
169
|
+
/** Account-level Ed25519 public key (base64url). Canonical user identity. */
|
|
170
|
+
publicKey: string | null;
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
export interface DocumentMeta {
|