@abraca/dabra 1.0.14 → 1.0.15

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.
@@ -1005,9 +1005,20 @@ var EventEmitter = class {
1005
1005
  this.callbacks[event].push(fn);
1006
1006
  return this;
1007
1007
  }
1008
+ once(event, fn) {
1009
+ const wrapper = (...args) => {
1010
+ this.off(event, wrapper);
1011
+ fn.apply(this, args);
1012
+ };
1013
+ return this.on(event, wrapper);
1014
+ }
1008
1015
  emit(event, ...args) {
1009
1016
  const callbacks = this.callbacks[event];
1010
- if (callbacks) callbacks.forEach((callback) => callback.apply(this, args));
1017
+ if (callbacks) for (const callback of callbacks) try {
1018
+ callback.apply(this, args);
1019
+ } catch (err) {
1020
+ console.error(`[EventEmitter] Error in "${event}" listener:`, err);
1021
+ }
1011
1022
  return this;
1012
1023
  }
1013
1024
  off(event, fn) {
@@ -1847,7 +1858,11 @@ var AbracadabraWS = class extends EventEmitter {
1847
1858
  this.resolveConnectionAttempt();
1848
1859
  this.lastMessageReceived = getUnixTime();
1849
1860
  const documentName = new IncomingMessage(event.data).peekVarString();
1850
- this.configuration.providerMap.get(documentName)?.onMessage(event);
1861
+ try {
1862
+ this.configuration.providerMap.get(documentName)?.onMessage(event);
1863
+ } catch (err) {
1864
+ console.error(`[AbracadabraWS] Provider onMessage error for "${documentName}":`, err);
1865
+ }
1851
1866
  }
1852
1867
  resolveConnectionAttempt() {
1853
1868
  if (this.connectionAttempt) {
@@ -2274,6 +2289,10 @@ var AwarenessError = class extends Error {
2274
2289
  }
2275
2290
  };
2276
2291
  var AbracadabraBaseProvider = class extends EventEmitter {
2292
+ /** Current WebSocket connection status. */
2293
+ get connectionStatus() {
2294
+ return this.configuration.websocketProvider.status;
2295
+ }
2277
2296
  constructor(configuration) {
2278
2297
  super();
2279
2298
  this.configuration = {
@@ -2792,7 +2811,9 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2792
2811
  this.document.on("subdocs", this.boundHandleYSubdocsChange);
2793
2812
  this.on("synced", () => this.flushPendingUpdates());
2794
2813
  this.restorePermissionSnapshot();
2795
- this.ready = this._initFromOfflineStore();
2814
+ this.ready = Promise.race([this._initFromOfflineStore(), new Promise((resolve) => setTimeout(resolve, 5e3))]).catch((err) => {
2815
+ this.emit("error", { message: `Offline store init failed: ${err?.message ?? err}` });
2816
+ });
2796
2817
  }
2797
2818
  /**
2798
2819
  * Extract the server hostname from the provider configuration.
@@ -2910,7 +2931,9 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2910
2931
  }
2911
2932
  const msg = parsed;
2912
2933
  if (msg.type === "auth_challenge" && msg.challenge && msg.expiresAt) {
2913
- this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch(() => null);
2934
+ this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch((err) => {
2935
+ this.emit("authenticationFailed", { reason: `Auth challenge error: ${err?.message ?? err}` });
2936
+ });
2914
2937
  return;
2915
2938
  }
2916
2939
  if (msg.type === "subdoc_registered" && msg.child_id && msg.parent_id) {
@@ -2952,6 +2975,14 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2952
2975
  createdAt: Date.now()
2953
2976
  });
2954
2977
  }
2978
+ /** Get a loaded child provider by ID, or null if not yet loaded. */
2979
+ getChild(childId) {
2980
+ return this.childProviders.get(childId) ?? null;
2981
+ }
2982
+ /** Check if a child provider is already loaded. */
2983
+ hasChild(childId) {
2984
+ return this.childProviders.has(childId);
2985
+ }
2955
2986
  /**
2956
2987
  * Create (or return cached) a child AbracadabraProvider for a given
2957
2988
  * child document id. Each child opens its own WebSocket connection because
@@ -3082,6 +3113,17 @@ var AbracadabraClient = class {
3082
3113
  get isAuthenticated() {
3083
3114
  return this._token !== null;
3084
3115
  }
3116
+ /** Check if the current JWT token is present and not expired. */
3117
+ isTokenValid() {
3118
+ if (!this._token) return false;
3119
+ try {
3120
+ const [, payload] = this._token.split(".");
3121
+ const { exp } = JSON.parse(atob(payload));
3122
+ return typeof exp === "number" && exp * 1e3 > Date.now();
3123
+ } catch {
3124
+ return false;
3125
+ }
3126
+ }
3085
3127
  /** Derives ws:// or wss:// URL from the http(s) base URL. */
3086
3128
  get wsUrl() {
3087
3129
  return this.baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + "/ws";
@@ -3175,12 +3217,7 @@ var AbracadabraClient = class {
3175
3217
  }
3176
3218
  /** Get the caller's key envelope for a document (for decrypting the DocKey). */
3177
3219
  async getMyKeyEnvelope(docId) {
3178
- try {
3179
- return await this.request("GET", `/docs/${encodeURIComponent(docId)}/key-envelope`);
3180
- } catch (e) {
3181
- if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3182
- throw e;
3183
- }
3220
+ return this.requestOrNull("GET", `/docs/${encodeURIComponent(docId)}/key-envelope`);
3184
3221
  }
3185
3222
  /** Upload key envelopes for a document (Owner only). */
3186
3223
  async uploadKeyEnvelopes(docId, opts) {
@@ -3188,12 +3225,7 @@ var AbracadabraClient = class {
3188
3225
  }
3189
3226
  /** Get the X25519 public key for a user. */
3190
3227
  async getUserX25519Key(userId) {
3191
- try {
3192
- return (await this.request("GET", `/users/${encodeURIComponent(userId)}/x25519-key`)).x25519_key;
3193
- } catch (e) {
3194
- if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3195
- throw e;
3196
- }
3228
+ return (await this.requestOrNull("GET", `/users/${encodeURIComponent(userId)}/x25519-key`))?.x25519_key ?? null;
3197
3229
  }
3198
3230
  /** List all non-revoked keys for a user (Owner/Admin or self). */
3199
3231
  async listUserKeys(userId) {
@@ -3271,14 +3303,15 @@ var AbracadabraClient = class {
3271
3303
  async listEffectivePermissions(docId) {
3272
3304
  try {
3273
3305
  return await this.request("GET", `/docs/${encodeURIComponent(docId)}/effective-permissions`);
3274
- } catch {
3275
- return {
3306
+ } catch (e) {
3307
+ if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return {
3276
3308
  permissions: (await this.listPermissions(docId)).map((p) => ({
3277
3309
  ...p,
3278
3310
  source: "direct"
3279
3311
  })),
3280
3312
  default_role: "viewer"
3281
3313
  };
3314
+ throw e;
3282
3315
  }
3283
3316
  }
3284
3317
  /** Grant or change a user's role on a document (requires Owner). */
@@ -3357,12 +3390,7 @@ var AbracadabraClient = class {
3357
3390
  }
3358
3391
  /** Get the hub space, or null if none is configured. */
3359
3392
  async getHubSpace() {
3360
- try {
3361
- return await this.request("GET", "/spaces/hub", { auth: false });
3362
- } catch (e) {
3363
- if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3364
- throw e;
3365
- }
3393
+ return this.requestOrNull("GET", "/spaces/hub", { auth: false });
3366
3394
  }
3367
3395
  /** Create a new space (auth required). */
3368
3396
  async createSpace(opts) {
@@ -3405,25 +3433,35 @@ var AbracadabraClient = class {
3405
3433
  if (auth && this._token) headers["Authorization"] = `Bearer ${this._token}`;
3406
3434
  const init = {
3407
3435
  method,
3408
- headers
3436
+ headers,
3437
+ signal: AbortSignal.timeout(3e4)
3409
3438
  };
3410
3439
  if (opts?.body !== void 0) {
3411
3440
  headers["Content-Type"] = "application/json";
3412
3441
  init.body = JSON.stringify(opts.body);
3413
3442
  }
3414
3443
  const res = await this._fetch(`${this.baseUrl}${path}`, init);
3415
- if (!res.ok) throw await this.toError(res);
3444
+ if (!res.ok) throw await this.toError(res, method, path);
3416
3445
  if (res.status === 204) return;
3417
3446
  return res.json();
3418
3447
  }
3419
- async toError(res) {
3448
+ async requestOrNull(method, path, opts) {
3449
+ try {
3450
+ return await this.request(method, path, opts);
3451
+ } catch (e) {
3452
+ if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3453
+ throw e;
3454
+ }
3455
+ }
3456
+ async toError(res, method, path) {
3420
3457
  let message;
3421
3458
  try {
3422
3459
  message = (await res.json()).error ?? res.statusText;
3423
3460
  } catch {
3424
3461
  message = res.statusText;
3425
3462
  }
3426
- const err = new Error(message);
3463
+ const prefix = method && path ? `${method} ${path}: ` : "";
3464
+ const err = /* @__PURE__ */ new Error(`${prefix}${message} (${res.status})`);
3427
3465
  err.status = res.status;
3428
3466
  return err;
3429
3467
  }
@@ -7442,6 +7480,7 @@ var SearchIndex = class {
7442
7480
  if (!db) return [];
7443
7481
  const queryTrigrams = [...extractTrigrams(query)];
7444
7482
  if (queryTrigrams.length === 0) return [];
7483
+ const maxScoreEntries = limit * 10;
7445
7484
  return new Promise((resolve, reject) => {
7446
7485
  const tx = db.transaction("postings", "readonly");
7447
7486
  const postings = tx.objectStore("postings");
@@ -7451,7 +7490,10 @@ var SearchIndex = class {
7451
7490
  const req = postings.get(trigram);
7452
7491
  req.onsuccess = () => {
7453
7492
  const docIds = req.result ?? [];
7454
- for (const docId of docIds) scores.set(docId, (scores.get(docId) ?? 0) + 1);
7493
+ for (const docId of docIds) {
7494
+ scores.set(docId, (scores.get(docId) ?? 0) + 1);
7495
+ if (scores.size >= maxScoreEntries) break;
7496
+ }
7455
7497
  remaining--;
7456
7498
  if (remaining === 0) resolve([...scores.entries()].map(([docId, score]) => ({
7457
7499
  docId,
@@ -7509,7 +7551,7 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
7509
7551
  this.db = null;
7510
7552
  this.objectUrls = /* @__PURE__ */ new Map();
7511
7553
  this._notFound = /* @__PURE__ */ new Map();
7512
- this._flushing = false;
7554
+ this._flushPromise = null;
7513
7555
  this.origin = serverOrigin;
7514
7556
  this.client = client ?? null;
7515
7557
  this._onlineHandler = () => {
@@ -7651,6 +7693,20 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
7651
7693
  req.onerror = () => reject(req.error);
7652
7694
  });
7653
7695
  }
7696
+ /**
7697
+ * Revoke the in-memory object URL without touching the IDB cache.
7698
+ * The next call to getBlobUrl() will re-create a fresh URL from IDB.
7699
+ * Use this when an <img> @error fires — the blob data is fine, only
7700
+ * the object URL reference is stale.
7701
+ */
7702
+ invalidateUrl(docId, uploadId) {
7703
+ const key = this.blobKey(docId, uploadId);
7704
+ const url = this.objectUrls.get(key);
7705
+ if (url) {
7706
+ URL.revokeObjectURL(url);
7707
+ this.objectUrls.delete(key);
7708
+ }
7709
+ }
7654
7710
  /** Revoke the object URL and remove the blob from cache. */
7655
7711
  async evictBlob(docId, uploadId) {
7656
7712
  const key = this.blobKey(docId, uploadId);
@@ -7703,38 +7759,42 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
7703
7759
  * Entries that fail are marked with status "error" and left in the queue.
7704
7760
  */
7705
7761
  async flushQueue() {
7706
- if (this._flushing || !this.client) return;
7707
- this._flushing = true;
7762
+ if (this._flushPromise || !this.client) return;
7763
+ this._flushPromise = this._doFlush();
7708
7764
  try {
7709
- const pending = (await this.getQueue()).filter((e) => e.status === "pending");
7710
- for (const entry of pending) {
7711
- await this._updateQueueEntry(entry.id, { status: "uploading" });
7712
- this.emit("upload:started", {
7765
+ await this._flushPromise;
7766
+ } finally {
7767
+ this._flushPromise = null;
7768
+ }
7769
+ }
7770
+ async _doFlush() {
7771
+ if (!this.client) return;
7772
+ const pending = (await this.getQueue()).filter((e) => e.status === "pending");
7773
+ for (const entry of pending) {
7774
+ await this._updateQueueEntry(entry.id, { status: "uploading" });
7775
+ this.emit("upload:started", {
7776
+ ...entry,
7777
+ status: "uploading"
7778
+ });
7779
+ try {
7780
+ await this.client.upload(entry.docId, entry.file, entry.filename);
7781
+ await this._updateQueueEntry(entry.id, { status: "done" });
7782
+ this.emit("upload:done", {
7713
7783
  ...entry,
7714
- status: "uploading"
7784
+ status: "done"
7785
+ });
7786
+ } catch (err) {
7787
+ const message = err instanceof Error ? err.message : String(err);
7788
+ await this._updateQueueEntry(entry.id, {
7789
+ status: "error",
7790
+ error: message
7791
+ });
7792
+ this.emit("upload:error", {
7793
+ ...entry,
7794
+ status: "error",
7795
+ error: message
7715
7796
  });
7716
- try {
7717
- await this.client.upload(entry.docId, entry.file, entry.filename);
7718
- await this._updateQueueEntry(entry.id, { status: "done" });
7719
- this.emit("upload:done", {
7720
- ...entry,
7721
- status: "done"
7722
- });
7723
- } catch (err) {
7724
- const message = err instanceof Error ? err.message : String(err);
7725
- await this._updateQueueEntry(entry.id, {
7726
- status: "error",
7727
- error: message
7728
- });
7729
- this.emit("upload:error", {
7730
- ...entry,
7731
- status: "error",
7732
- error: message
7733
- });
7734
- }
7735
7797
  }
7736
- } finally {
7737
- this._flushing = false;
7738
7798
  }
7739
7799
  }
7740
7800
  async _updateQueueEntry(id, patch) {
@@ -7782,10 +7842,13 @@ const HKDF_INFO$1 = new TextEncoder().encode("abracadabra-dockey-v1");
7782
7842
  function fromBase64$1(b64) {
7783
7843
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
7784
7844
  }
7785
- var DocKeyManager = class {
7845
+ var DocKeyManager = class DocKeyManager {
7786
7846
  constructor() {
7787
7847
  this.cache = /* @__PURE__ */ new Map();
7788
7848
  }
7849
+ static {
7850
+ this.CACHE_TTL = 600 * 1e3;
7851
+ }
7789
7852
  /** Generate a new random AES-256-GCM document key. */
7790
7853
  static async generateDocKey() {
7791
7854
  return crypto.subtle.generateKey({
@@ -7799,7 +7862,7 @@ var DocKeyManager = class {
7799
7862
  */
7800
7863
  async getDocKey(docId, client, keystore) {
7801
7864
  const cached = this.cache.get(docId);
7802
- if (cached) return cached.key;
7865
+ if (cached && Date.now() - cached.fetchedAt < DocKeyManager.CACHE_TTL) return cached.key;
7803
7866
  const envelope = await client.getMyKeyEnvelope(docId);
7804
7867
  if (!envelope) return null;
7805
7868
  const x25519PrivKey = await keystore.getX25519PrivateKey();
@@ -7808,7 +7871,8 @@ var DocKeyManager = class {
7808
7871
  const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
7809
7872
  this.cache.set(docId, {
7810
7873
  key: docKey,
7811
- epoch: envelope.key_epoch
7874
+ epoch: envelope.key_epoch,
7875
+ fetchedAt: Date.now()
7812
7876
  });
7813
7877
  return docKey;
7814
7878
  } finally {
@@ -8304,6 +8368,7 @@ var BackgroundSyncManager = class extends EventEmitter {
8304
8368
  super();
8305
8369
  this.syncStates = /* @__PURE__ */ new Map();
8306
8370
  this._destroyed = false;
8371
+ this._initPromise = null;
8307
8372
  this.rootProvider = rootProvider;
8308
8373
  this.client = client;
8309
8374
  this.fileBlobStore = fileBlobStore ?? null;
@@ -8319,18 +8384,36 @@ var BackgroundSyncManager = class extends EventEmitter {
8319
8384
  this.persistence = new BackgroundSyncPersistence(serverOrigin);
8320
8385
  this.semaphore = new Semaphore(this.opts.concurrency);
8321
8386
  }
8387
+ /**
8388
+ * Load persisted sync states from IndexedDB into the in-memory map.
8389
+ * Called automatically by syncAll() / syncDoc(); safe to call concurrently.
8390
+ */
8391
+ async init() {
8392
+ if (!this._initPromise) this._initPromise = this._loadPersistedStates();
8393
+ return this._initPromise;
8394
+ }
8395
+ async _loadPersistedStates() {
8396
+ try {
8397
+ const states = await this.persistence.getAllStates();
8398
+ for (const state of states) this.syncStates.set(state.docId, state);
8399
+ } catch {}
8400
+ }
8322
8401
  /** Sync all documents in the root tree. */
8323
8402
  async syncAll() {
8324
8403
  if (this._destroyed) return;
8404
+ await this.init();
8325
8405
  const treeMap = this.rootProvider.document.getMap("doc-tree");
8326
8406
  const entries = Array.from(treeMap.entries());
8327
8407
  if (entries.length === 0) return;
8408
+ const updatedAtMap = /* @__PURE__ */ new Map();
8409
+ for (const [docId, v] of entries) updatedAtMap.set(docId, v?.updatedAt ?? v?.createdAt ?? 0);
8328
8410
  this._prefetchCovers(entries).catch(() => null);
8329
8411
  const queue = this._buildQueue(entries);
8330
- await Promise.all(queue.map((docId) => this._syncWithSemaphore(docId)));
8412
+ await Promise.all(queue.map((docId) => this._syncWithSemaphore(docId, updatedAtMap.get(docId) ?? 0)));
8331
8413
  }
8332
8414
  /** Sync a single document by ID. */
8333
8415
  async syncDoc(docId) {
8416
+ await this.init();
8334
8417
  const state = await this._doSyncDoc(docId);
8335
8418
  this.syncStates.set(docId, state);
8336
8419
  await this.persistence.setState(state).catch(() => null);
@@ -8381,8 +8464,16 @@ var BackgroundSyncManager = class extends EventEmitter {
8381
8464
  items.sort((a, b) => b.priority - a.priority);
8382
8465
  return items.map((i) => i.docId);
8383
8466
  }
8384
- async _syncWithSemaphore(docId) {
8467
+ async _syncWithSemaphore(docId, updatedAt) {
8385
8468
  if (this._destroyed) return;
8469
+ const existing = this.syncStates.get(docId);
8470
+ if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced >= updatedAt) {
8471
+ this.emit("stateChanged", {
8472
+ docId,
8473
+ state: existing
8474
+ });
8475
+ return;
8476
+ }
8386
8477
  await this.semaphore.acquire();
8387
8478
  try {
8388
8479
  const state = await this._doSyncDoc(docId);
@@ -8408,8 +8499,8 @@ var BackgroundSyncManager = class extends EventEmitter {
8408
8499
  docId,
8409
8500
  state: syncing
8410
8501
  });
8502
+ let isE2E = false;
8411
8503
  try {
8412
- let isE2E = false;
8413
8504
  try {
8414
8505
  isE2E = (await this.client.getDocEncryption(docId)).mode === "e2e";
8415
8506
  } catch {}
@@ -8422,7 +8513,7 @@ var BackgroundSyncManager = class extends EventEmitter {
8422
8513
  status: "error",
8423
8514
  lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
8424
8515
  error,
8425
- isE2E: false
8516
+ isE2E
8426
8517
  };
8427
8518
  }
8428
8519
  }
@@ -8555,6 +8646,7 @@ var SignalingSocket = class extends EventEmitter {
8555
8646
  this.connectionAttempt = null;
8556
8647
  this.localPeerId = null;
8557
8648
  this.isConnected = false;
8649
+ this._connectPromise = null;
8558
8650
  this.config = {
8559
8651
  url: configuration.url,
8560
8652
  token: configuration.token,
@@ -8574,6 +8666,15 @@ var SignalingSocket = class extends EventEmitter {
8574
8666
  }
8575
8667
  async connect() {
8576
8668
  if (this.isConnected) return;
8669
+ if (this._connectPromise) return this._connectPromise;
8670
+ this._connectPromise = this._doConnect();
8671
+ try {
8672
+ await this._connectPromise;
8673
+ } finally {
8674
+ this._connectPromise = null;
8675
+ }
8676
+ }
8677
+ async _doConnect() {
8577
8678
  if (this.cancelRetry) {
8578
8679
  this.cancelRetry();
8579
8680
  this.cancelRetry = void 0;
@@ -8887,11 +8988,12 @@ var DataChannelRouter = class extends EventEmitter {
8887
8988
  */
8888
8989
  async send(name, data) {
8889
8990
  const channel = this.channels.get(name);
8890
- if (!channel || channel.readyState !== "open") return;
8991
+ if (!channel || channel.readyState !== "open") return false;
8891
8992
  if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
8892
8993
  const encrypted = await this.encryptor.encrypt(data);
8893
8994
  channel.send(encrypted);
8894
8995
  } else channel.send(data);
8996
+ return true;
8895
8997
  }
8896
8998
  registerChannel(channel) {
8897
8999
  channel.binaryType = "arraybuffer";
@@ -9059,6 +9161,7 @@ var YjsDataChannel = class {
9059
9161
  this.document = document;
9060
9162
  this.awareness = awareness;
9061
9163
  this.router = router;
9164
+ this.isSynced = false;
9062
9165
  this.docUpdateHandler = null;
9063
9166
  this.awarenessUpdateHandler = null;
9064
9167
  this.channelOpenHandler = null;
@@ -9295,6 +9398,10 @@ var FileTransferChannel = class extends EventEmitter {
9295
9398
  try {
9296
9399
  meta = JSON.parse(json);
9297
9400
  } catch {
9401
+ this.emit("receiveError", {
9402
+ transferId: "unknown",
9403
+ error: "Malformed START message: invalid JSON"
9404
+ });
9298
9405
  return;
9299
9406
  }
9300
9407
  this.receives.set(meta.transferId, {
@@ -10001,7 +10108,9 @@ var ManualSignaling = class extends EventEmitter {
10001
10108
  type: "offer",
10002
10109
  sdp: offerBlob.sdp
10003
10110
  }));
10004
- for (const c of offerBlob.candidates) await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
10111
+ for (const c of offerBlob.candidates) try {
10112
+ await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
10113
+ } catch {}
10005
10114
  const answer = await this.pc.createAnswer();
10006
10115
  await this.pc.setLocalDescription(answer);
10007
10116
  await gatheringComplete;