@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/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
- constructor(document: Y.Doc, awareness: Awareness | null, router: DataChannelRouter);
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
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, 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, E2EOfflineStore, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, 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 };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -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
- // Each child gets its own WebSocket connection. Omitting
452
- // websocketProvider lets AbracadabraBaseProvider create one automatically
453
- // (manageSocket = true), so we do NOT call attach() manually.
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
- url: this.abracadabraConfig.url ?? this.configuration.websocketProvider?.url,
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 });
@@ -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
- console.log('[DEBUG] onClose event:', typeof event, JSON.stringify(event), 'code:', (event as any)?.code);
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 {