@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/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: any;
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 _flushing;
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<void>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -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: any = {
153
+ intervals: { forceSync: ReturnType<typeof setInterval> | null } = {
148
154
  forceSync: null,
149
155
  };
150
156
 
@@ -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
- try {
206
- return await this.request<{ encrypted_key: string; key_epoch: number }>(
207
- "GET",
208
- `/docs/${encodeURIComponent(docId)}/key-envelope`,
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
- try {
229
- const res = await this.request<{ x25519_key: string }>(
230
- "GET",
231
- `/users/${encodeURIComponent(userId)}/x25519-key`,
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
- const perms = await this.listPermissions(docId);
371
- return {
372
- permissions: perms.map((p) => ({ ...p, source: "direct" as const })),
373
- default_role: "viewer",
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
- try {
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 toError(res: Response): Promise<Error> {
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 err = new Error(message);
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 = this._initFromOfflineStore();
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(() => null);
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
@@ -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
- this.configuration.providerMap.get(documentName)?.onMessage(event);
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(queue.map((docId) => this._syncWithSemaphore(docId)));
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(docId: string): Promise<void> {
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: false,
338
+ isE2E,
244
339
  };
245
340
  }
246
341
  }
@@ -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) return cached.key;
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);
@@ -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
- callbacks.forEach((callback) => callback.apply(this, args));
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;