@abraca/dabra 1.0.21 → 1.0.22
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 +302 -1
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +301 -2
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +148 -1
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +10 -1
- package/src/IdentityDoc.ts +497 -0
- package/src/index.ts +7 -0
package/dist/index.d.ts
CHANGED
|
@@ -621,6 +621,12 @@ interface AbracadabraProviderConfiguration extends Omit<AbracadabraBaseProviderC
|
|
|
621
621
|
keystore?: CryptoIdentityKeystore;
|
|
622
622
|
/** Shared WebSocket connection (use when multiplexing multiple root documents). */
|
|
623
623
|
websocketProvider?: AbracadabraWS;
|
|
624
|
+
/**
|
|
625
|
+
* When true, the IndexedDB offline store is NOT scoped by server origin.
|
|
626
|
+
* This allows a single document to be synced across multiple servers while
|
|
627
|
+
* sharing one local store. Used for the identity doc.
|
|
628
|
+
*/
|
|
629
|
+
serverAgnostic?: boolean;
|
|
624
630
|
}
|
|
625
631
|
/**
|
|
626
632
|
* AbracadabraProvider extends AbracadabraBaseProvider with:
|
|
@@ -2342,4 +2348,145 @@ declare class BroadcastChannelSync extends EventEmitter {
|
|
|
2342
2348
|
private handleAwarenessMessage;
|
|
2343
2349
|
}
|
|
2344
2350
|
//#endregion
|
|
2345
|
-
|
|
2351
|
+
//#region packages/provider/src/IdentityDoc.d.ts
|
|
2352
|
+
/**
|
|
2353
|
+
* Derives a deterministic UUID from an Ed25519 account-level public key.
|
|
2354
|
+
*
|
|
2355
|
+
* The result is a valid UUID v5-style string that any device sharing the
|
|
2356
|
+
* same identity can independently compute. The `abracadabra:identity:`
|
|
2357
|
+
* prefix prevents collisions with randomly generated doc UUIDs.
|
|
2358
|
+
*
|
|
2359
|
+
* @param publicKeyB64 Base64url-encoded Ed25519 public key (32 bytes).
|
|
2360
|
+
*/
|
|
2361
|
+
declare function deriveIdentityDocId(publicKeyB64: string): string;
|
|
2362
|
+
interface IdentityProfile {
|
|
2363
|
+
username?: string;
|
|
2364
|
+
displayName?: string;
|
|
2365
|
+
colorName?: string;
|
|
2366
|
+
neutralColorName?: string;
|
|
2367
|
+
locale?: string;
|
|
2368
|
+
avatarUrl?: string;
|
|
2369
|
+
}
|
|
2370
|
+
interface IdentityServerEntry {
|
|
2371
|
+
label: string;
|
|
2372
|
+
hubDocId?: string;
|
|
2373
|
+
entryDocId?: string;
|
|
2374
|
+
defaultRole?: string;
|
|
2375
|
+
spacesEnabled?: boolean;
|
|
2376
|
+
addedAt: number;
|
|
2377
|
+
}
|
|
2378
|
+
interface IdentitySpaceEntry {
|
|
2379
|
+
id: string;
|
|
2380
|
+
name: string;
|
|
2381
|
+
type: "local" | "remote";
|
|
2382
|
+
serverUrl: string | null;
|
|
2383
|
+
docId: string;
|
|
2384
|
+
remoteSpaceId?: string;
|
|
2385
|
+
visibility: "public" | "private" | "invite";
|
|
2386
|
+
isHub: boolean;
|
|
2387
|
+
order: number;
|
|
2388
|
+
lastSyncedAt?: number;
|
|
2389
|
+
createdAt: number;
|
|
2390
|
+
}
|
|
2391
|
+
interface IdentityDocConfiguration {
|
|
2392
|
+
/** Base64url-encoded Ed25519 account public key. */
|
|
2393
|
+
publicKey: string;
|
|
2394
|
+
/**
|
|
2395
|
+
* Trusted server URL for identity doc sync.
|
|
2396
|
+
* Only this server (plus the local server) will receive the identity doc.
|
|
2397
|
+
*/
|
|
2398
|
+
syncServerUrl?: string;
|
|
2399
|
+
/** Local Tauri server URL for on-device persistence. */
|
|
2400
|
+
localServerUrl?: string;
|
|
2401
|
+
/**
|
|
2402
|
+
* JWT token or async factory for authenticating with sync servers.
|
|
2403
|
+
* When using multiple servers, provide a factory that returns the correct
|
|
2404
|
+
* token for each.
|
|
2405
|
+
*/
|
|
2406
|
+
token?: string | (() => string) | (() => Promise<string>);
|
|
2407
|
+
/** Per-server token factories keyed by base URL. */
|
|
2408
|
+
tokens?: Record<string, string | (() => string) | (() => Promise<string>)>;
|
|
2409
|
+
/**
|
|
2410
|
+
* WebRTC configuration for P2P identity sync.
|
|
2411
|
+
* When provided, enables E2EE peer-to-peer sync using signaling from
|
|
2412
|
+
* any connected server.
|
|
2413
|
+
*/
|
|
2414
|
+
webrtc?: {
|
|
2415
|
+
/** Server URL to use for signaling (any connected server works). */signalingServerUrl: string; /** Token for the signaling server. */
|
|
2416
|
+
token: string | (() => string) | (() => Promise<string>); /** E2EE identity for the data channel. */
|
|
2417
|
+
e2ee?: E2EEIdentity; /** ICE servers. */
|
|
2418
|
+
iceServers?: RTCIceServer[];
|
|
2419
|
+
};
|
|
2420
|
+
/** Disable IndexedDB offline store. */
|
|
2421
|
+
disableOfflineStore?: boolean;
|
|
2422
|
+
/** Auto-connect on construction. Default: true. */
|
|
2423
|
+
autoConnect?: boolean;
|
|
2424
|
+
/** Additional provider config passed through to each AbracadabraProvider. */
|
|
2425
|
+
providerDefaults?: Partial<AbracadabraProviderConfiguration>;
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Manages a Y.Doc dedicated to user identity that syncs across trusted
|
|
2429
|
+
* targets only: a designated sync server, a local Tauri server, and/or
|
|
2430
|
+
* WebRTC P2P.
|
|
2431
|
+
*
|
|
2432
|
+
* The Y.Doc contains cross-device settings (profile, servers, spaces,
|
|
2433
|
+
* plugins, preferences). Each sync target gets its own
|
|
2434
|
+
* AbracadabraProvider sharing the same Y.Doc, with `serverAgnostic: true`
|
|
2435
|
+
* so they all use one IndexedDB store.
|
|
2436
|
+
*/
|
|
2437
|
+
declare class IdentityDocProvider extends EventEmitter {
|
|
2438
|
+
readonly docId: string;
|
|
2439
|
+
readonly document: Y.Doc;
|
|
2440
|
+
private providers;
|
|
2441
|
+
private websockets;
|
|
2442
|
+
private webrtc;
|
|
2443
|
+
private config;
|
|
2444
|
+
private _destroyed;
|
|
2445
|
+
constructor(configuration: IdentityDocConfiguration);
|
|
2446
|
+
connect(): void;
|
|
2447
|
+
private _connectToServer;
|
|
2448
|
+
private _connectWebRTC;
|
|
2449
|
+
get profileMap(): Y.Map<string>;
|
|
2450
|
+
get serversMap(): Y.Map<Y.Map<any>>;
|
|
2451
|
+
get spacesArray(): Y.Array<Y.Map<any>>;
|
|
2452
|
+
get pluginsMap(): Y.Map<any>;
|
|
2453
|
+
get preferencesMap(): Y.Map<any>;
|
|
2454
|
+
getProfile(): IdentityProfile;
|
|
2455
|
+
setProfile(profile: Partial<IdentityProfile>): void;
|
|
2456
|
+
getServer(url: string): IdentityServerEntry | undefined;
|
|
2457
|
+
setServer(url: string, entry: IdentityServerEntry): void;
|
|
2458
|
+
removeServer(url: string): void;
|
|
2459
|
+
getServers(): Map<string, IdentityServerEntry>;
|
|
2460
|
+
getSpaces(): IdentitySpaceEntry[];
|
|
2461
|
+
addSpace(space: IdentitySpaceEntry): void;
|
|
2462
|
+
removeSpace(spaceId: string): void;
|
|
2463
|
+
updateSpace(spaceId: string, updates: Partial<IdentitySpaceEntry>): void;
|
|
2464
|
+
getExternalPlugins(): Y.Array<Y.Map<any>>;
|
|
2465
|
+
getDisabledBuiltins(): Y.Array<string>;
|
|
2466
|
+
getPreference<T = any>(key: string): T | undefined;
|
|
2467
|
+
setPreference(key: string, value: any): void;
|
|
2468
|
+
/**
|
|
2469
|
+
* Observe deep changes on a specific top-level map.
|
|
2470
|
+
* Returns an unsubscribe function.
|
|
2471
|
+
*/
|
|
2472
|
+
observe(mapName: "profile" | "servers" | "plugins" | "preferences", callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void): () => void;
|
|
2473
|
+
/**
|
|
2474
|
+
* Observe changes to the spaces array.
|
|
2475
|
+
* Returns an unsubscribe function.
|
|
2476
|
+
*/
|
|
2477
|
+
observeSpaces(callback: (event: Y.YArrayEvent<Y.Map<any>>, transaction: Y.Transaction) => void): () => void;
|
|
2478
|
+
/**
|
|
2479
|
+
* Returns true if the identity doc has no profile data yet (first use).
|
|
2480
|
+
* Call this to decide whether to run migration from localStorage.
|
|
2481
|
+
*/
|
|
2482
|
+
isEmpty(): boolean;
|
|
2483
|
+
/**
|
|
2484
|
+
* Update the sync server URL at runtime (e.g. when user changes their
|
|
2485
|
+
* designated sync server in settings).
|
|
2486
|
+
*/
|
|
2487
|
+
setSyncServer(url: string | null): void;
|
|
2488
|
+
getProvider(key: string): AbracadabraProvider | undefined;
|
|
2489
|
+
destroy(): void;
|
|
2490
|
+
}
|
|
2491
|
+
//#endregion
|
|
2492
|
+
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, DevicePairingChannel, type DevicePairingConfig, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, 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, deriveIdentityDocId, 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
|
@@ -66,6 +66,13 @@ export interface AbracadabraProviderConfiguration
|
|
|
66
66
|
|
|
67
67
|
/** Shared WebSocket connection (use when multiplexing multiple root documents). */
|
|
68
68
|
websocketProvider?: AbracadabraWS;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* When true, the IndexedDB offline store is NOT scoped by server origin.
|
|
72
|
+
* This allows a single document to be synced across multiple servers while
|
|
73
|
+
* sharing one local store. Used for the identity doc.
|
|
74
|
+
*/
|
|
75
|
+
serverAgnostic?: boolean;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** Validate that a string is a UUID acceptable by the server's DocId parser. */
|
|
@@ -134,7 +141,9 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
134
141
|
this.abracadabraConfig = configuration;
|
|
135
142
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
136
143
|
|
|
137
|
-
const serverOrigin =
|
|
144
|
+
const serverOrigin = configuration.serverAgnostic
|
|
145
|
+
? undefined
|
|
146
|
+
: AbracadabraProvider.deriveServerOrigin(configuration, client);
|
|
138
147
|
this.offlineStore = configuration.disableOfflineStore
|
|
139
148
|
? null
|
|
140
149
|
: new OfflineStore(configuration.name, serverOrigin);
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
3
|
+
import EventEmitter from "./EventEmitter.ts";
|
|
4
|
+
import { AbracadabraProvider } from "./AbracadabraProvider.ts";
|
|
5
|
+
import type { AbracadabraProviderConfiguration } from "./AbracadabraProvider.ts";
|
|
6
|
+
import { AbracadabraWS } from "./AbracadabraWS.ts";
|
|
7
|
+
import { AbracadabraWebRTC } from "./webrtc/AbracadabraWebRTC.ts";
|
|
8
|
+
import type { E2EEIdentity } from "./webrtc/E2EEChannel.ts";
|
|
9
|
+
|
|
10
|
+
// ── Identity Doc ID Derivation ─────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Derives a deterministic UUID from an Ed25519 account-level public key.
|
|
14
|
+
*
|
|
15
|
+
* The result is a valid UUID v5-style string that any device sharing the
|
|
16
|
+
* same identity can independently compute. The `abracadabra:identity:`
|
|
17
|
+
* prefix prevents collisions with randomly generated doc UUIDs.
|
|
18
|
+
*
|
|
19
|
+
* @param publicKeyB64 Base64url-encoded Ed25519 public key (32 bytes).
|
|
20
|
+
*/
|
|
21
|
+
export function deriveIdentityDocId(publicKeyB64: string): string {
|
|
22
|
+
const hash = sha256(
|
|
23
|
+
new TextEncoder().encode(`abracadabra:identity:${publicKeyB64}`),
|
|
24
|
+
);
|
|
25
|
+
const bytes = new Uint8Array(hash.buffer, hash.byteOffset, 16);
|
|
26
|
+
// Set UUID version 5 + variant 1 bits.
|
|
27
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
28
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
29
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
|
|
30
|
+
"",
|
|
31
|
+
);
|
|
32
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export interface IdentityProfile {
|
|
38
|
+
username?: string;
|
|
39
|
+
displayName?: string;
|
|
40
|
+
colorName?: string;
|
|
41
|
+
neutralColorName?: string;
|
|
42
|
+
locale?: string;
|
|
43
|
+
avatarUrl?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IdentityServerEntry {
|
|
47
|
+
label: string;
|
|
48
|
+
hubDocId?: string;
|
|
49
|
+
entryDocId?: string;
|
|
50
|
+
defaultRole?: string;
|
|
51
|
+
spacesEnabled?: boolean;
|
|
52
|
+
addedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IdentitySpaceEntry {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
type: "local" | "remote";
|
|
59
|
+
serverUrl: string | null;
|
|
60
|
+
docId: string;
|
|
61
|
+
remoteSpaceId?: string;
|
|
62
|
+
visibility: "public" | "private" | "invite";
|
|
63
|
+
isHub: boolean;
|
|
64
|
+
order: number;
|
|
65
|
+
lastSyncedAt?: number;
|
|
66
|
+
createdAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IdentityDocConfiguration {
|
|
70
|
+
/** Base64url-encoded Ed25519 account public key. */
|
|
71
|
+
publicKey: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Trusted server URL for identity doc sync.
|
|
75
|
+
* Only this server (plus the local server) will receive the identity doc.
|
|
76
|
+
*/
|
|
77
|
+
syncServerUrl?: string;
|
|
78
|
+
|
|
79
|
+
/** Local Tauri server URL for on-device persistence. */
|
|
80
|
+
localServerUrl?: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* JWT token or async factory for authenticating with sync servers.
|
|
84
|
+
* When using multiple servers, provide a factory that returns the correct
|
|
85
|
+
* token for each.
|
|
86
|
+
*/
|
|
87
|
+
token?: string | (() => string) | (() => Promise<string>);
|
|
88
|
+
|
|
89
|
+
/** Per-server token factories keyed by base URL. */
|
|
90
|
+
tokens?: Record<string, string | (() => string) | (() => Promise<string>)>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* WebRTC configuration for P2P identity sync.
|
|
94
|
+
* When provided, enables E2EE peer-to-peer sync using signaling from
|
|
95
|
+
* any connected server.
|
|
96
|
+
*/
|
|
97
|
+
webrtc?: {
|
|
98
|
+
/** Server URL to use for signaling (any connected server works). */
|
|
99
|
+
signalingServerUrl: string;
|
|
100
|
+
/** Token for the signaling server. */
|
|
101
|
+
token: string | (() => string) | (() => Promise<string>);
|
|
102
|
+
/** E2EE identity for the data channel. */
|
|
103
|
+
e2ee?: E2EEIdentity;
|
|
104
|
+
/** ICE servers. */
|
|
105
|
+
iceServers?: RTCIceServer[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Disable IndexedDB offline store. */
|
|
109
|
+
disableOfflineStore?: boolean;
|
|
110
|
+
|
|
111
|
+
/** Auto-connect on construction. Default: true. */
|
|
112
|
+
autoConnect?: boolean;
|
|
113
|
+
|
|
114
|
+
/** Additional provider config passed through to each AbracadabraProvider. */
|
|
115
|
+
providerDefaults?: Partial<AbracadabraProviderConfiguration>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── IdentityDocProvider ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Manages a Y.Doc dedicated to user identity that syncs across trusted
|
|
122
|
+
* targets only: a designated sync server, a local Tauri server, and/or
|
|
123
|
+
* WebRTC P2P.
|
|
124
|
+
*
|
|
125
|
+
* The Y.Doc contains cross-device settings (profile, servers, spaces,
|
|
126
|
+
* plugins, preferences). Each sync target gets its own
|
|
127
|
+
* AbracadabraProvider sharing the same Y.Doc, with `serverAgnostic: true`
|
|
128
|
+
* so they all use one IndexedDB store.
|
|
129
|
+
*/
|
|
130
|
+
export class IdentityDocProvider extends EventEmitter {
|
|
131
|
+
public readonly docId: string;
|
|
132
|
+
public readonly document: Y.Doc;
|
|
133
|
+
|
|
134
|
+
private providers = new Map<string, AbracadabraProvider>();
|
|
135
|
+
private websockets = new Map<string, AbracadabraWS>();
|
|
136
|
+
private webrtc: AbracadabraWebRTC | null = null;
|
|
137
|
+
private config: IdentityDocConfiguration;
|
|
138
|
+
private _destroyed = false;
|
|
139
|
+
|
|
140
|
+
constructor(configuration: IdentityDocConfiguration) {
|
|
141
|
+
super();
|
|
142
|
+
this.config = configuration;
|
|
143
|
+
this.docId = deriveIdentityDocId(configuration.publicKey);
|
|
144
|
+
this.document = new Y.Doc({ guid: this.docId });
|
|
145
|
+
|
|
146
|
+
if (configuration.autoConnect !== false) {
|
|
147
|
+
this.connect();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Connection Management ────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
connect(): void {
|
|
154
|
+
if (this._destroyed) return;
|
|
155
|
+
|
|
156
|
+
const targets: Array<{ url: string; key: string }> = [];
|
|
157
|
+
|
|
158
|
+
if (this.config.localServerUrl) {
|
|
159
|
+
targets.push({ url: this.config.localServerUrl, key: "local" });
|
|
160
|
+
}
|
|
161
|
+
if (this.config.syncServerUrl) {
|
|
162
|
+
targets.push({ url: this.config.syncServerUrl, key: "sync" });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const { url, key } of targets) {
|
|
166
|
+
if (this.providers.has(key)) continue;
|
|
167
|
+
this._connectToServer(key, url);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (this.config.webrtc && !this.webrtc) {
|
|
171
|
+
this._connectWebRTC();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private _connectToServer(key: string, serverUrl: string): void {
|
|
176
|
+
const token =
|
|
177
|
+
this.config.tokens?.[serverUrl] ?? this.config.token ?? "";
|
|
178
|
+
|
|
179
|
+
const wsUrl = serverUrl
|
|
180
|
+
.replace(/^http/, "ws")
|
|
181
|
+
.replace(/\/$/, "")
|
|
182
|
+
.concat("/ws");
|
|
183
|
+
|
|
184
|
+
const ws = new AbracadabraWS({ url: wsUrl, WebSocketPolyfill: undefined as any });
|
|
185
|
+
this.websockets.set(key, ws);
|
|
186
|
+
|
|
187
|
+
const provider = new AbracadabraProvider({
|
|
188
|
+
name: this.docId,
|
|
189
|
+
document: this.document,
|
|
190
|
+
websocketProvider: ws,
|
|
191
|
+
token,
|
|
192
|
+
serverAgnostic: true,
|
|
193
|
+
disableOfflineStore: key !== "local" && key !== "sync"
|
|
194
|
+
? true
|
|
195
|
+
: this.config.disableOfflineStore ?? false,
|
|
196
|
+
...this.config.providerDefaults,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
provider.on("synced", () => this.emit("synced", { server: key }));
|
|
200
|
+
provider.on("status", (data: any) =>
|
|
201
|
+
this.emit("status", { server: key, ...data }),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
this.providers.set(key, provider);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private _connectWebRTC(): void {
|
|
208
|
+
const rtcConfig = this.config.webrtc;
|
|
209
|
+
if (!rtcConfig) return;
|
|
210
|
+
|
|
211
|
+
this.webrtc = new AbracadabraWebRTC({
|
|
212
|
+
docId: this.docId,
|
|
213
|
+
url: rtcConfig.signalingServerUrl,
|
|
214
|
+
token: rtcConfig.token,
|
|
215
|
+
document: this.document,
|
|
216
|
+
enableDocSync: true,
|
|
217
|
+
enableAwarenessSync: false,
|
|
218
|
+
enableFileTransfer: false,
|
|
219
|
+
e2ee: rtcConfig.e2ee,
|
|
220
|
+
iceServers: rtcConfig.iceServers,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Y.Doc Schema Accessors ───────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
get profileMap(): Y.Map<string> {
|
|
227
|
+
return this.document.getMap("profile");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get serversMap(): Y.Map<Y.Map<any>> {
|
|
231
|
+
return this.document.getMap("servers");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
get spacesArray(): Y.Array<Y.Map<any>> {
|
|
235
|
+
return this.document.getArray("spaces");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
get pluginsMap(): Y.Map<any> {
|
|
239
|
+
return this.document.getMap("plugins");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
get preferencesMap(): Y.Map<any> {
|
|
243
|
+
return this.document.getMap("preferences");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Profile ──────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
getProfile(): IdentityProfile {
|
|
249
|
+
const m = this.profileMap;
|
|
250
|
+
return {
|
|
251
|
+
username: m.get("username"),
|
|
252
|
+
displayName: m.get("displayName"),
|
|
253
|
+
colorName: m.get("colorName"),
|
|
254
|
+
neutralColorName: m.get("neutralColorName"),
|
|
255
|
+
locale: m.get("locale"),
|
|
256
|
+
avatarUrl: m.get("avatarUrl"),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
setProfile(profile: Partial<IdentityProfile>): void {
|
|
261
|
+
const m = this.profileMap;
|
|
262
|
+
this.document.transact(() => {
|
|
263
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
264
|
+
if (value !== undefined) {
|
|
265
|
+
m.set(key, value);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Servers ──────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
getServer(url: string): IdentityServerEntry | undefined {
|
|
274
|
+
const entry = this.serversMap.get(url);
|
|
275
|
+
if (!entry) return undefined;
|
|
276
|
+
return {
|
|
277
|
+
label: entry.get("label") ?? url,
|
|
278
|
+
hubDocId: entry.get("hubDocId"),
|
|
279
|
+
entryDocId: entry.get("entryDocId"),
|
|
280
|
+
defaultRole: entry.get("defaultRole"),
|
|
281
|
+
spacesEnabled: entry.get("spacesEnabled"),
|
|
282
|
+
addedAt: entry.get("addedAt") ?? 0,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
setServer(url: string, entry: IdentityServerEntry): void {
|
|
287
|
+
const m = this.serversMap;
|
|
288
|
+
this.document.transact(() => {
|
|
289
|
+
let yEntry = m.get(url);
|
|
290
|
+
if (!yEntry) {
|
|
291
|
+
yEntry = new Y.Map();
|
|
292
|
+
m.set(url, yEntry);
|
|
293
|
+
}
|
|
294
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
295
|
+
if (value !== undefined) {
|
|
296
|
+
yEntry.set(key, value);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
removeServer(url: string): void {
|
|
303
|
+
this.serversMap.delete(url);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
getServers(): Map<string, IdentityServerEntry> {
|
|
307
|
+
const result = new Map<string, IdentityServerEntry>();
|
|
308
|
+
this.serversMap.forEach((entry, url) => {
|
|
309
|
+
result.set(url, {
|
|
310
|
+
label: entry.get("label") ?? url,
|
|
311
|
+
hubDocId: entry.get("hubDocId"),
|
|
312
|
+
entryDocId: entry.get("entryDocId"),
|
|
313
|
+
defaultRole: entry.get("defaultRole"),
|
|
314
|
+
spacesEnabled: entry.get("spacesEnabled"),
|
|
315
|
+
addedAt: entry.get("addedAt") ?? 0,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Spaces ───────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
getSpaces(): IdentitySpaceEntry[] {
|
|
324
|
+
const result: IdentitySpaceEntry[] = [];
|
|
325
|
+
this.spacesArray.forEach((yMap) => {
|
|
326
|
+
result.push({
|
|
327
|
+
id: yMap.get("id"),
|
|
328
|
+
name: yMap.get("name"),
|
|
329
|
+
type: yMap.get("type") ?? "remote",
|
|
330
|
+
serverUrl: yMap.get("serverUrl") ?? null,
|
|
331
|
+
docId: yMap.get("docId"),
|
|
332
|
+
remoteSpaceId: yMap.get("remoteSpaceId"),
|
|
333
|
+
visibility: yMap.get("visibility") ?? "private",
|
|
334
|
+
isHub: yMap.get("isHub") ?? false,
|
|
335
|
+
order: yMap.get("order") ?? 0,
|
|
336
|
+
lastSyncedAt: yMap.get("lastSyncedAt"),
|
|
337
|
+
createdAt: yMap.get("createdAt") ?? 0,
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
addSpace(space: IdentitySpaceEntry): void {
|
|
344
|
+
const yMap = new Y.Map();
|
|
345
|
+
this.document.transact(() => {
|
|
346
|
+
for (const [key, value] of Object.entries(space)) {
|
|
347
|
+
if (value !== undefined) {
|
|
348
|
+
yMap.set(key, value);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
this.spacesArray.push([yMap]);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
removeSpace(spaceId: string): void {
|
|
356
|
+
const arr = this.spacesArray;
|
|
357
|
+
for (let i = 0; i < arr.length; i++) {
|
|
358
|
+
if (arr.get(i).get("id") === spaceId) {
|
|
359
|
+
arr.delete(i, 1);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
updateSpace(spaceId: string, updates: Partial<IdentitySpaceEntry>): void {
|
|
366
|
+
const arr = this.spacesArray;
|
|
367
|
+
for (let i = 0; i < arr.length; i++) {
|
|
368
|
+
const yMap = arr.get(i);
|
|
369
|
+
if (yMap.get("id") === spaceId) {
|
|
370
|
+
this.document.transact(() => {
|
|
371
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
372
|
+
if (value !== undefined) {
|
|
373
|
+
yMap.set(key, value);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Plugins ──────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
getExternalPlugins(): Y.Array<Y.Map<any>> {
|
|
385
|
+
let arr = this.pluginsMap.get("external") as Y.Array<Y.Map<any>> | undefined;
|
|
386
|
+
if (!arr) {
|
|
387
|
+
arr = new Y.Array();
|
|
388
|
+
this.pluginsMap.set("external", arr);
|
|
389
|
+
}
|
|
390
|
+
return arr;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
getDisabledBuiltins(): Y.Array<string> {
|
|
394
|
+
let arr = this.pluginsMap.get("disabledBuiltins") as Y.Array<string> | undefined;
|
|
395
|
+
if (!arr) {
|
|
396
|
+
arr = new Y.Array();
|
|
397
|
+
this.pluginsMap.set("disabledBuiltins", arr);
|
|
398
|
+
}
|
|
399
|
+
return arr;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Preferences ──────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
getPreference<T = any>(key: string): T | undefined {
|
|
405
|
+
return this.preferencesMap.get(key);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
setPreference(key: string, value: any): void {
|
|
409
|
+
this.preferencesMap.set(key, value);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Observation ──────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Observe deep changes on a specific top-level map.
|
|
416
|
+
* Returns an unsubscribe function.
|
|
417
|
+
*/
|
|
418
|
+
observe(
|
|
419
|
+
mapName: "profile" | "servers" | "plugins" | "preferences",
|
|
420
|
+
callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void,
|
|
421
|
+
): () => void {
|
|
422
|
+
const map = this.document.getMap(mapName);
|
|
423
|
+
map.observeDeep(callback);
|
|
424
|
+
return () => map.unobserveDeep(callback);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Observe changes to the spaces array.
|
|
429
|
+
* Returns an unsubscribe function.
|
|
430
|
+
*/
|
|
431
|
+
observeSpaces(
|
|
432
|
+
callback: (event: Y.YArrayEvent<Y.Map<any>>, transaction: Y.Transaction) => void,
|
|
433
|
+
): () => void {
|
|
434
|
+
const arr = this.spacesArray;
|
|
435
|
+
arr.observe(callback);
|
|
436
|
+
return () => arr.unobserve(callback);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Migration ────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Returns true if the identity doc has no profile data yet (first use).
|
|
443
|
+
* Call this to decide whether to run migration from localStorage.
|
|
444
|
+
*/
|
|
445
|
+
isEmpty(): boolean {
|
|
446
|
+
return this.profileMap.size === 0 && this.serversMap.size === 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Update the sync server URL at runtime (e.g. when user changes their
|
|
453
|
+
* designated sync server in settings).
|
|
454
|
+
*/
|
|
455
|
+
setSyncServer(url: string | null): void {
|
|
456
|
+
// Tear down existing sync provider if any.
|
|
457
|
+
const existing = this.providers.get("sync");
|
|
458
|
+
if (existing) {
|
|
459
|
+
existing.destroy();
|
|
460
|
+
this.providers.delete("sync");
|
|
461
|
+
}
|
|
462
|
+
const existingWs = this.websockets.get("sync");
|
|
463
|
+
if (existingWs) {
|
|
464
|
+
existingWs.destroy();
|
|
465
|
+
this.websockets.delete("sync");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (url) {
|
|
469
|
+
this.config = { ...this.config, syncServerUrl: url };
|
|
470
|
+
this._connectToServer("sync", url);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
getProvider(key: string): AbracadabraProvider | undefined {
|
|
475
|
+
return this.providers.get(key);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
destroy(): void {
|
|
479
|
+
if (this._destroyed) return;
|
|
480
|
+
this._destroyed = true;
|
|
481
|
+
|
|
482
|
+
for (const [, provider] of this.providers) {
|
|
483
|
+
provider.destroy();
|
|
484
|
+
}
|
|
485
|
+
for (const [, ws] of this.websockets) {
|
|
486
|
+
ws.destroy();
|
|
487
|
+
}
|
|
488
|
+
if (this.webrtc) {
|
|
489
|
+
(this.webrtc as any).disconnect?.();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
this.providers.clear();
|
|
493
|
+
this.websockets.clear();
|
|
494
|
+
this.webrtc = null;
|
|
495
|
+
this.document.destroy();
|
|
496
|
+
}
|
|
497
|
+
}
|