@abraca/dabra 1.0.14 → 1.0.16
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 +218 -67
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +218 -67
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +46 -3
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +7 -1
- package/src/AbracadabraClient.ts +52 -42
- package/src/AbracadabraProvider.ts +19 -2
- package/src/AbracadabraWS.ts +5 -1
- package/src/BackgroundSyncManager.ts +99 -4
- package/src/DocKeyManager.ts +6 -3
- package/src/EventEmitter.ts +16 -1
- package/src/FileBlobStore.ts +41 -22
- package/src/OfflineStore.ts +22 -0
- package/src/SearchIndex.ts +3 -0
- package/src/webrtc/DataChannelRouter.ts +3 -2
- package/src/webrtc/FileTransferChannel.ts +1 -0
- package/src/webrtc/ManualSignaling.ts +5 -1
- package/src/webrtc/SignalingSocket.ts +12 -0
- package/src/webrtc/YjsDataChannel.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ declare class EventEmitter {
|
|
|
126
126
|
[key: string]: Function[];
|
|
127
127
|
};
|
|
128
128
|
on(event: string, fn: Function): this;
|
|
129
|
+
once(event: string, fn: Function): this;
|
|
129
130
|
protected emit(event: string, ...args: any): this;
|
|
130
131
|
off(event: string, fn?: Function): this;
|
|
131
132
|
removeAllListeners(): void;
|
|
@@ -186,6 +187,11 @@ declare class OfflineStore {
|
|
|
186
187
|
removeSubdocFromQueue(childId: string): Promise<void>;
|
|
187
188
|
getMeta(key: string): Promise<string | null>;
|
|
188
189
|
setMeta(key: string, value: string): Promise<void>;
|
|
190
|
+
/**
|
|
191
|
+
* Clear all stored data (updates, snapshots, state vectors, subdoc queue).
|
|
192
|
+
* The database itself is kept but emptied.
|
|
193
|
+
*/
|
|
194
|
+
clearAll(): Promise<void>;
|
|
189
195
|
destroy(): void;
|
|
190
196
|
}
|
|
191
197
|
//#endregion
|
|
@@ -256,6 +262,8 @@ declare class AbracadabraClient {
|
|
|
256
262
|
get token(): string | null;
|
|
257
263
|
set token(value: string | null);
|
|
258
264
|
get isAuthenticated(): boolean;
|
|
265
|
+
/** Check if the current JWT token is present and not expired. */
|
|
266
|
+
isTokenValid(): boolean;
|
|
259
267
|
/** Derives ws:// or wss:// URL from the http(s) base URL. */
|
|
260
268
|
get wsUrl(): string;
|
|
261
269
|
/** Register a new user with password. */
|
|
@@ -427,6 +435,7 @@ declare class AbracadabraClient {
|
|
|
427
435
|
*/
|
|
428
436
|
getIceServers(): Promise<RTCIceServer[]>;
|
|
429
437
|
private request;
|
|
438
|
+
private requestOrNull;
|
|
430
439
|
private toError;
|
|
431
440
|
private loadPersistedToken;
|
|
432
441
|
private persistToken;
|
|
@@ -508,6 +517,7 @@ declare class CryptoIdentityKeystore {
|
|
|
508
517
|
//#region packages/provider/src/DocKeyManager.d.ts
|
|
509
518
|
declare class DocKeyManager {
|
|
510
519
|
private cache;
|
|
520
|
+
private static readonly CACHE_TTL;
|
|
511
521
|
/** Generate a new random AES-256-GCM document key. */
|
|
512
522
|
static generateDocKey(): Promise<CryptoKey>;
|
|
513
523
|
/**
|
|
@@ -655,6 +665,10 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
655
665
|
* If offline the event is queued in IndexedDB and replayed on reconnect.
|
|
656
666
|
*/
|
|
657
667
|
private registerSubdoc;
|
|
668
|
+
/** Get a loaded child provider by ID, or null if not yet loaded. */
|
|
669
|
+
getChild(childId: string): AbracadabraProvider | null;
|
|
670
|
+
/** Check if a child provider is already loaded. */
|
|
671
|
+
hasChild(childId: string): boolean;
|
|
658
672
|
/**
|
|
659
673
|
* Create (or return cached) a child AbracadabraProvider for a given
|
|
660
674
|
* child document id. Each child opens its own WebSocket connection because
|
|
@@ -1239,10 +1253,14 @@ declare class AbracadabraBaseProvider extends EventEmitter {
|
|
|
1239
1253
|
isSynced: boolean;
|
|
1240
1254
|
unsyncedChanges: number;
|
|
1241
1255
|
isAuthenticated: boolean;
|
|
1256
|
+
/** Current WebSocket connection status. */
|
|
1257
|
+
get connectionStatus(): WebSocketStatus;
|
|
1242
1258
|
authorizedScope: AuthorizedScope | undefined;
|
|
1243
1259
|
manageSocket: boolean;
|
|
1244
1260
|
private _isAttached;
|
|
1245
|
-
intervals:
|
|
1261
|
+
intervals: {
|
|
1262
|
+
forceSync: ReturnType<typeof setInterval> | null;
|
|
1263
|
+
};
|
|
1246
1264
|
constructor(configuration: AbracadabraBaseProviderConfiguration);
|
|
1247
1265
|
boundDocumentUpdateHandler: (update: Uint8Array, origin: any) => void;
|
|
1248
1266
|
boundAwarenessUpdateHandler: ({
|
|
@@ -1375,7 +1393,7 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1375
1393
|
private readonly _notFound;
|
|
1376
1394
|
private static readonly NOT_FOUND_TTL;
|
|
1377
1395
|
/** Prevents concurrent flush runs. */
|
|
1378
|
-
private
|
|
1396
|
+
private _flushPromise;
|
|
1379
1397
|
private readonly _onlineHandler;
|
|
1380
1398
|
constructor(serverOrigin: string, client?: AbracadabraClient | null);
|
|
1381
1399
|
private getDb;
|
|
@@ -1410,6 +1428,13 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1410
1428
|
}>>;
|
|
1411
1429
|
/** Revoke all object URLs and clear the entire blob cache from IDB. */
|
|
1412
1430
|
clearAllBlobs(): Promise<void>;
|
|
1431
|
+
/**
|
|
1432
|
+
* Revoke the in-memory object URL without touching the IDB cache.
|
|
1433
|
+
* The next call to getBlobUrl() will re-create a fresh URL from IDB.
|
|
1434
|
+
* Use this when an <img> @error fires — the blob data is fine, only
|
|
1435
|
+
* the object URL reference is stale.
|
|
1436
|
+
*/
|
|
1437
|
+
invalidateUrl(docId: string, uploadId: string): void;
|
|
1413
1438
|
/** Revoke the object URL and remove the blob from cache. */
|
|
1414
1439
|
evictBlob(docId: string, uploadId: string): Promise<void>;
|
|
1415
1440
|
/**
|
|
@@ -1426,6 +1451,7 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1426
1451
|
* Entries that fail are marked with status "error" and left in the queue.
|
|
1427
1452
|
*/
|
|
1428
1453
|
flushQueue(): Promise<void>;
|
|
1454
|
+
private _doFlush;
|
|
1429
1455
|
private _updateQueueEntry;
|
|
1430
1456
|
destroy(): void;
|
|
1431
1457
|
}
|
|
@@ -1593,7 +1619,14 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1593
1619
|
private readonly semaphore;
|
|
1594
1620
|
private readonly syncStates;
|
|
1595
1621
|
private _destroyed;
|
|
1622
|
+
private _initPromise;
|
|
1596
1623
|
constructor(rootProvider: AbracadabraProvider, client: AbracadabraClient, fileBlobStore?: FileBlobStore | null, opts?: BackgroundSyncManagerOptions);
|
|
1624
|
+
/**
|
|
1625
|
+
* Load persisted sync states from IndexedDB into the in-memory map.
|
|
1626
|
+
* Called automatically by syncAll() / syncDoc(); safe to call concurrently.
|
|
1627
|
+
*/
|
|
1628
|
+
init(): Promise<void>;
|
|
1629
|
+
private _loadPersistedStates;
|
|
1597
1630
|
/** Sync all documents in the root tree. */
|
|
1598
1631
|
syncAll(): Promise<void>;
|
|
1599
1632
|
/** Sync a single document by ID. */
|
|
@@ -1606,6 +1639,13 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1606
1639
|
* @returns Cleanup function to stop the periodic sync.
|
|
1607
1640
|
*/
|
|
1608
1641
|
startPeriodicSync(intervalMs?: number): () => void;
|
|
1642
|
+
/**
|
|
1643
|
+
* Clear all offline document data and sync state.
|
|
1644
|
+
* Opens each document's OfflineStore and clears its contents, then
|
|
1645
|
+
* resets the background sync persistence. After calling this, all
|
|
1646
|
+
* documents will need to be re-synced.
|
|
1647
|
+
*/
|
|
1648
|
+
clearAllSyncedData(): Promise<void>;
|
|
1609
1649
|
destroy(): void;
|
|
1610
1650
|
/**
|
|
1611
1651
|
* Build a priority-sorted list of doc IDs:
|
|
@@ -1720,7 +1760,7 @@ declare class DataChannelRouter extends EventEmitter {
|
|
|
1720
1760
|
* Send data on a named channel, encrypting if E2EE is active.
|
|
1721
1761
|
* Falls back to plaintext if no encryptor is set or for exempt channels.
|
|
1722
1762
|
*/
|
|
1723
|
-
send(name: string, data: Uint8Array): Promise<
|
|
1763
|
+
send(name: string, data: Uint8Array): Promise<boolean>;
|
|
1724
1764
|
private registerChannel;
|
|
1725
1765
|
close(): void;
|
|
1726
1766
|
destroy(): void;
|
|
@@ -2008,7 +2048,9 @@ declare class SignalingSocket extends EventEmitter {
|
|
|
2008
2048
|
isConnected: boolean;
|
|
2009
2049
|
constructor(configuration: SignalingSocketConfiguration);
|
|
2010
2050
|
private getToken;
|
|
2051
|
+
private _connectPromise;
|
|
2011
2052
|
connect(): Promise<void>;
|
|
2053
|
+
private _doConnect;
|
|
2012
2054
|
private createConnection;
|
|
2013
2055
|
private handleMessage;
|
|
2014
2056
|
private sendRaw;
|
|
@@ -2059,6 +2101,7 @@ declare class YjsDataChannel {
|
|
|
2059
2101
|
private readonly document;
|
|
2060
2102
|
private readonly awareness;
|
|
2061
2103
|
private readonly router;
|
|
2104
|
+
isSynced: boolean;
|
|
2062
2105
|
private docUpdateHandler;
|
|
2063
2106
|
private awarenessUpdateHandler;
|
|
2064
2107
|
private channelOpenHandler;
|
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
onStatusParameters,
|
|
30
30
|
onSyncedParameters,
|
|
31
31
|
onUnsyncedChangesParameters,
|
|
32
|
+
WebSocketStatus,
|
|
32
33
|
} from "./types.ts";
|
|
33
34
|
|
|
34
35
|
export type AbracadabraBaseProviderConfiguration = Required<
|
|
@@ -137,6 +138,11 @@ export class AbracadabraBaseProvider extends EventEmitter {
|
|
|
137
138
|
|
|
138
139
|
isAuthenticated = false;
|
|
139
140
|
|
|
141
|
+
/** Current WebSocket connection status. */
|
|
142
|
+
get connectionStatus(): WebSocketStatus {
|
|
143
|
+
return this.configuration.websocketProvider.status;
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
authorizedScope: AuthorizedScope | undefined = undefined;
|
|
141
147
|
|
|
142
148
|
// @internal
|
|
@@ -144,7 +150,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
|
|
|
144
150
|
|
|
145
151
|
private _isAttached = false;
|
|
146
152
|
|
|
147
|
-
intervals:
|
|
153
|
+
intervals: { forceSync: ReturnType<typeof setInterval> | null } = {
|
|
148
154
|
forceSync: null,
|
|
149
155
|
};
|
|
150
156
|
|
package/src/AbracadabraClient.ts
CHANGED
|
@@ -78,6 +78,18 @@ export class AbracadabraClient {
|
|
|
78
78
|
return this._token !== null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Check if the current JWT token is present and not expired. */
|
|
82
|
+
isTokenValid(): boolean {
|
|
83
|
+
if (!this._token) return false;
|
|
84
|
+
try {
|
|
85
|
+
const [, payload] = this._token.split(".");
|
|
86
|
+
const { exp } = JSON.parse(atob(payload));
|
|
87
|
+
return typeof exp === "number" && exp * 1000 > Date.now();
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
/** Derives ws:// or wss:// URL from the http(s) base URL. */
|
|
82
94
|
get wsUrl(): string {
|
|
83
95
|
return this.baseUrl
|
|
@@ -202,17 +214,10 @@ export class AbracadabraClient {
|
|
|
202
214
|
|
|
203
215
|
/** Get the caller's key envelope for a document (for decrypting the DocKey). */
|
|
204
216
|
async getMyKeyEnvelope(docId: string): Promise<{ encrypted_key: string; key_epoch: number } | null> {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
);
|
|
210
|
-
} catch (e: unknown) {
|
|
211
|
-
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
throw e;
|
|
215
|
-
}
|
|
217
|
+
return this.requestOrNull<{ encrypted_key: string; key_epoch: number }>(
|
|
218
|
+
"GET",
|
|
219
|
+
`/docs/${encodeURIComponent(docId)}/key-envelope`,
|
|
220
|
+
);
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
/** Upload key envelopes for a document (Owner only). */
|
|
@@ -225,18 +230,11 @@ export class AbracadabraClient {
|
|
|
225
230
|
|
|
226
231
|
/** Get the X25519 public key for a user. */
|
|
227
232
|
async getUserX25519Key(userId: string): Promise<string | null> {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return res.x25519_key;
|
|
234
|
-
} catch (e: unknown) {
|
|
235
|
-
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
throw e;
|
|
239
|
-
}
|
|
233
|
+
const res = await this.requestOrNull<{ x25519_key: string }>(
|
|
234
|
+
"GET",
|
|
235
|
+
`/users/${encodeURIComponent(userId)}/x25519-key`,
|
|
236
|
+
);
|
|
237
|
+
return res?.x25519_key ?? null;
|
|
240
238
|
}
|
|
241
239
|
|
|
242
240
|
/** List all non-revoked keys for a user (Owner/Admin or self). */
|
|
@@ -365,13 +363,16 @@ export class AbracadabraClient {
|
|
|
365
363
|
"GET",
|
|
366
364
|
`/docs/${encodeURIComponent(docId)}/effective-permissions`,
|
|
367
365
|
);
|
|
368
|
-
} catch {
|
|
369
|
-
// Fallback for older servers that don't support this endpoint
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
366
|
+
} catch (e: unknown) {
|
|
367
|
+
// Fallback for older servers that don't support this endpoint (404 only)
|
|
368
|
+
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
369
|
+
const perms = await this.listPermissions(docId);
|
|
370
|
+
return {
|
|
371
|
+
permissions: perms.map((p) => ({ ...p, source: "direct" as const })),
|
|
372
|
+
default_role: "viewer",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
throw e;
|
|
375
376
|
}
|
|
376
377
|
}
|
|
377
378
|
|
|
@@ -508,14 +509,7 @@ export class AbracadabraClient {
|
|
|
508
509
|
|
|
509
510
|
/** Get the hub space, or null if none is configured. */
|
|
510
511
|
async getHubSpace(): Promise<SpaceMeta | null> {
|
|
511
|
-
|
|
512
|
-
return await this.request<SpaceMeta>("GET", "/spaces/hub", { auth: false });
|
|
513
|
-
} catch (e: unknown) {
|
|
514
|
-
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
515
|
-
return null;
|
|
516
|
-
}
|
|
517
|
-
throw e;
|
|
518
|
-
}
|
|
512
|
+
return this.requestOrNull<SpaceMeta>("GET", "/spaces/hub", { auth: false });
|
|
519
513
|
}
|
|
520
514
|
|
|
521
515
|
/** Create a new space (auth required). */
|
|
@@ -588,7 +582,7 @@ export class AbracadabraClient {
|
|
|
588
582
|
headers["Authorization"] = `Bearer ${this._token}`;
|
|
589
583
|
}
|
|
590
584
|
|
|
591
|
-
const init: RequestInit = { method, headers };
|
|
585
|
+
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) };
|
|
592
586
|
|
|
593
587
|
if (opts?.body !== undefined) {
|
|
594
588
|
headers["Content-Type"] = "application/json";
|
|
@@ -598,7 +592,7 @@ export class AbracadabraClient {
|
|
|
598
592
|
const res = await this._fetch(`${this.baseUrl}${path}`, init);
|
|
599
593
|
|
|
600
594
|
if (!res.ok) {
|
|
601
|
-
throw await this.toError(res);
|
|
595
|
+
throw await this.toError(res, method, path);
|
|
602
596
|
}
|
|
603
597
|
|
|
604
598
|
// 204 No Content
|
|
@@ -609,7 +603,22 @@ export class AbracadabraClient {
|
|
|
609
603
|
return res.json() as Promise<T>;
|
|
610
604
|
}
|
|
611
605
|
|
|
612
|
-
private async
|
|
606
|
+
private async requestOrNull<T>(
|
|
607
|
+
method: string,
|
|
608
|
+
path: string,
|
|
609
|
+
opts?: { body?: unknown; auth?: boolean },
|
|
610
|
+
): Promise<T | null> {
|
|
611
|
+
try {
|
|
612
|
+
return await this.request<T>(method, path, opts);
|
|
613
|
+
} catch (e: unknown) {
|
|
614
|
+
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
throw e;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private async toError(res: Response, method?: string, path?: string): Promise<Error> {
|
|
613
622
|
let message: string;
|
|
614
623
|
try {
|
|
615
624
|
const body = await res.json() as { error?: string };
|
|
@@ -617,7 +626,8 @@ export class AbracadabraClient {
|
|
|
617
626
|
} catch {
|
|
618
627
|
message = res.statusText;
|
|
619
628
|
}
|
|
620
|
-
const
|
|
629
|
+
const prefix = method && path ? `${method} ${path}: ` : "";
|
|
630
|
+
const err = new Error(`${prefix}${message} (${res.status})`);
|
|
621
631
|
(err as any).status = res.status;
|
|
622
632
|
return err;
|
|
623
633
|
}
|
|
@@ -155,7 +155,12 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
155
155
|
this.restorePermissionSnapshot();
|
|
156
156
|
|
|
157
157
|
// Pre-populate the Y.Doc from the local snapshot so the UI works offline.
|
|
158
|
-
this.ready =
|
|
158
|
+
this.ready = Promise.race([
|
|
159
|
+
this._initFromOfflineStore(),
|
|
160
|
+
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
161
|
+
]).catch((err) => {
|
|
162
|
+
this.emit("error", { message: `Offline store init failed: ${err?.message ?? err}` });
|
|
163
|
+
});
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
// ── Server origin derivation ──────────────────────────────────────────────
|
|
@@ -337,7 +342,9 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
337
342
|
|
|
338
343
|
// Intercept auth_challenge before subdoc handling.
|
|
339
344
|
if (msg.type === "auth_challenge" && msg.challenge && msg.expiresAt) {
|
|
340
|
-
this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch(() =>
|
|
345
|
+
this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch((err) => {
|
|
346
|
+
this.emit("authenticationFailed", { reason: `Auth challenge error: ${err?.message ?? err}` });
|
|
347
|
+
});
|
|
341
348
|
return;
|
|
342
349
|
}
|
|
343
350
|
|
|
@@ -407,6 +414,16 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
407
414
|
}
|
|
408
415
|
}
|
|
409
416
|
|
|
417
|
+
/** Get a loaded child provider by ID, or null if not yet loaded. */
|
|
418
|
+
getChild(childId: string): AbracadabraProvider | null {
|
|
419
|
+
return this.childProviders.get(childId) ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Check if a child provider is already loaded. */
|
|
423
|
+
hasChild(childId: string): boolean {
|
|
424
|
+
return this.childProviders.has(childId);
|
|
425
|
+
}
|
|
426
|
+
|
|
410
427
|
/**
|
|
411
428
|
* Create (or return cached) a child AbracadabraProvider for a given
|
|
412
429
|
* child document id. Each child opens its own WebSocket connection because
|
package/src/AbracadabraWS.ts
CHANGED
|
@@ -409,7 +409,11 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
409
409
|
const message = new IncomingMessage(event.data);
|
|
410
410
|
const documentName = message.peekVarString();
|
|
411
411
|
|
|
412
|
-
|
|
412
|
+
try {
|
|
413
|
+
this.configuration.providerMap.get(documentName)?.onMessage(event);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.error(`[AbracadabraWS] Provider onMessage error for "${documentName}":`, err);
|
|
416
|
+
}
|
|
413
417
|
}
|
|
414
418
|
|
|
415
419
|
resolveConnectionAttempt() {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
BackgroundSyncPersistence,
|
|
28
28
|
type DocSyncState,
|
|
29
29
|
} from "./BackgroundSyncPersistence.ts";
|
|
30
|
+
import { OfflineStore } from "./OfflineStore.ts";
|
|
30
31
|
import EventEmitter from "./EventEmitter.ts";
|
|
31
32
|
import { E2EAbracadabraProvider } from "./E2EAbracadabraProvider.ts";
|
|
32
33
|
|
|
@@ -79,6 +80,7 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
79
80
|
private readonly syncStates = new Map<string, DocSyncState>();
|
|
80
81
|
|
|
81
82
|
private _destroyed = false;
|
|
83
|
+
private _initPromise: Promise<void> | null = null;
|
|
82
84
|
|
|
83
85
|
constructor(
|
|
84
86
|
rootProvider: AbracadabraProvider,
|
|
@@ -108,15 +110,45 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
108
110
|
|
|
109
111
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Load persisted sync states from IndexedDB into the in-memory map.
|
|
115
|
+
* Called automatically by syncAll() / syncDoc(); safe to call concurrently.
|
|
116
|
+
*/
|
|
117
|
+
async init(): Promise<void> {
|
|
118
|
+
if (!this._initPromise) {
|
|
119
|
+
this._initPromise = this._loadPersistedStates();
|
|
120
|
+
}
|
|
121
|
+
return this._initPromise;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async _loadPersistedStates(): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
const states = await this.persistence.getAllStates();
|
|
127
|
+
for (const state of states) {
|
|
128
|
+
this.syncStates.set(state.docId, state);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// IDB unavailable — proceed with empty map (full sync fallback)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
/** Sync all documents in the root tree. */
|
|
112
136
|
async syncAll(): Promise<void> {
|
|
113
137
|
if (this._destroyed) return;
|
|
114
138
|
|
|
139
|
+
await this.init();
|
|
140
|
+
|
|
115
141
|
const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
|
|
116
142
|
const entries = Array.from(treeMap.entries()) as Array<[string, any]>;
|
|
117
143
|
|
|
118
144
|
if (entries.length === 0) return;
|
|
119
145
|
|
|
146
|
+
// Build updatedAt lookup for skip-unchanged logic
|
|
147
|
+
const updatedAtMap = new Map<string, number>();
|
|
148
|
+
for (const [docId, v] of entries) {
|
|
149
|
+
updatedAtMap.set(docId, v?.updatedAt ?? v?.createdAt ?? 0);
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
// Pre-cache cover images immediately (fire-and-forget)
|
|
121
153
|
this._prefetchCovers(entries).catch(() => null);
|
|
122
154
|
|
|
@@ -124,11 +156,16 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
124
156
|
const queue = this._buildQueue(entries);
|
|
125
157
|
|
|
126
158
|
// Sync all docs respecting concurrency limit
|
|
127
|
-
await Promise.all(
|
|
159
|
+
await Promise.all(
|
|
160
|
+
queue.map((docId) =>
|
|
161
|
+
this._syncWithSemaphore(docId, updatedAtMap.get(docId) ?? 0),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
128
164
|
}
|
|
129
165
|
|
|
130
166
|
/** Sync a single document by ID. */
|
|
131
167
|
async syncDoc(docId: string): Promise<DocSyncState> {
|
|
168
|
+
await this.init();
|
|
132
169
|
const state = await this._doSyncDoc(docId);
|
|
133
170
|
this.syncStates.set(docId, state);
|
|
134
171
|
await this.persistence.setState(state).catch(() => null);
|
|
@@ -155,6 +192,48 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
155
192
|
return () => clearInterval(handle);
|
|
156
193
|
}
|
|
157
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Clear all offline document data and sync state.
|
|
197
|
+
* Opens each document's OfflineStore and clears its contents, then
|
|
198
|
+
* resets the background sync persistence. After calling this, all
|
|
199
|
+
* documents will need to be re-synced.
|
|
200
|
+
*/
|
|
201
|
+
async clearAllSyncedData(): Promise<void> {
|
|
202
|
+
// Collect doc IDs from both in-memory state and the tree
|
|
203
|
+
const docIds = new Set<string>(this.syncStates.keys());
|
|
204
|
+
const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
|
|
205
|
+
for (const docId of treeMap.keys()) {
|
|
206
|
+
docIds.add(docId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Derive server origin the same way the provider does
|
|
210
|
+
let serverOrigin: string | undefined;
|
|
211
|
+
try {
|
|
212
|
+
serverOrigin = new URL((this.client as any).baseUrl ?? "").hostname;
|
|
213
|
+
} catch {}
|
|
214
|
+
|
|
215
|
+
// Clear each document's offline store contents
|
|
216
|
+
const clearPromises = Array.from(docIds).map(async (docId) => {
|
|
217
|
+
try {
|
|
218
|
+
const store = new OfflineStore(docId, serverOrigin);
|
|
219
|
+
await store.clearAll();
|
|
220
|
+
store.destroy();
|
|
221
|
+
} catch {
|
|
222
|
+
// Ignore per-doc failures
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
await Promise.all(clearPromises);
|
|
226
|
+
|
|
227
|
+
// Clear background sync persistence
|
|
228
|
+
for (const docId of docIds) {
|
|
229
|
+
await this.persistence.deleteState(docId).catch(() => null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Reset in-memory state
|
|
233
|
+
this.syncStates.clear();
|
|
234
|
+
this._initPromise = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
158
237
|
destroy(): void {
|
|
159
238
|
this._destroyed = true;
|
|
160
239
|
this.removeAllListeners();
|
|
@@ -194,8 +273,24 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
194
273
|
return items.map((i) => i.docId);
|
|
195
274
|
}
|
|
196
275
|
|
|
197
|
-
private async _syncWithSemaphore(
|
|
276
|
+
private async _syncWithSemaphore(
|
|
277
|
+
docId: string,
|
|
278
|
+
updatedAt: number,
|
|
279
|
+
): Promise<void> {
|
|
198
280
|
if (this._destroyed) return;
|
|
281
|
+
|
|
282
|
+
// Skip if already synced and doc hasn't changed since last sync
|
|
283
|
+
const existing = this.syncStates.get(docId);
|
|
284
|
+
if (
|
|
285
|
+
existing &&
|
|
286
|
+
existing.status === "synced" &&
|
|
287
|
+
existing.lastSynced !== null &&
|
|
288
|
+
existing.lastSynced >= updatedAt
|
|
289
|
+
) {
|
|
290
|
+
this.emit("stateChanged", { docId, state: existing });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
199
294
|
await this.semaphore.acquire();
|
|
200
295
|
try {
|
|
201
296
|
const state = await this._doSyncDoc(docId);
|
|
@@ -218,9 +313,9 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
218
313
|
this.syncStates.set(docId, syncing);
|
|
219
314
|
this.emit("stateChanged", { docId, state: syncing });
|
|
220
315
|
|
|
316
|
+
let isE2E = false;
|
|
221
317
|
try {
|
|
222
318
|
// Check encryption type
|
|
223
|
-
let isE2E = false;
|
|
224
319
|
try {
|
|
225
320
|
const enc = await this.client.getDocEncryption(docId);
|
|
226
321
|
isE2E = enc.mode === "e2e";
|
|
@@ -240,7 +335,7 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
240
335
|
status: "error",
|
|
241
336
|
lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
|
|
242
337
|
error,
|
|
243
|
-
isE2E
|
|
338
|
+
isE2E,
|
|
244
339
|
};
|
|
245
340
|
}
|
|
246
341
|
}
|
package/src/DocKeyManager.ts
CHANGED
|
@@ -18,7 +18,8 @@ function fromBase64(b64: string): Uint8Array {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export class DocKeyManager {
|
|
21
|
-
private cache = new Map<string, { key: CryptoKey; epoch: number }>();
|
|
21
|
+
private cache = new Map<string, { key: CryptoKey; epoch: number; fetchedAt: number }>();
|
|
22
|
+
private static readonly CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
|
22
23
|
|
|
23
24
|
/** Generate a new random AES-256-GCM document key. */
|
|
24
25
|
static async generateDocKey(): Promise<CryptoKey> {
|
|
@@ -39,7 +40,9 @@ export class DocKeyManager {
|
|
|
39
40
|
keystore: CryptoIdentityKeystore,
|
|
40
41
|
): Promise<CryptoKey | null> {
|
|
41
42
|
const cached = this.cache.get(docId);
|
|
42
|
-
if (cached)
|
|
43
|
+
if (cached && Date.now() - cached.fetchedAt < DocKeyManager.CACHE_TTL) {
|
|
44
|
+
return cached.key;
|
|
45
|
+
}
|
|
43
46
|
|
|
44
47
|
const envelope = await client.getMyKeyEnvelope(docId);
|
|
45
48
|
if (!envelope) return null;
|
|
@@ -48,7 +51,7 @@ export class DocKeyManager {
|
|
|
48
51
|
try {
|
|
49
52
|
const wrapped = fromBase64(envelope.encrypted_key);
|
|
50
53
|
const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
|
|
51
|
-
this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch });
|
|
54
|
+
this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch, fetchedAt: Date.now() });
|
|
52
55
|
return docKey;
|
|
53
56
|
} finally {
|
|
54
57
|
x25519PrivKey.fill(0);
|
package/src/EventEmitter.ts
CHANGED
|
@@ -13,11 +13,26 @@ export default class EventEmitter {
|
|
|
13
13
|
return this;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
17
|
+
public once(event: string, fn: Function): this {
|
|
18
|
+
const wrapper = (...args: any[]) => {
|
|
19
|
+
this.off(event, wrapper);
|
|
20
|
+
fn.apply(this, args);
|
|
21
|
+
};
|
|
22
|
+
return this.on(event, wrapper);
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
protected emit(event: string, ...args: any): this {
|
|
17
26
|
const callbacks = this.callbacks[event];
|
|
18
27
|
|
|
19
28
|
if (callbacks) {
|
|
20
|
-
|
|
29
|
+
for (const callback of callbacks) {
|
|
30
|
+
try {
|
|
31
|
+
callback.apply(this, args);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(`[EventEmitter] Error in "${event}" listener:`, err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
return this;
|