@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.
@@ -975,9 +975,20 @@ var EventEmitter = class {
975
975
  this.callbacks[event].push(fn);
976
976
  return this;
977
977
  }
978
+ once(event, fn) {
979
+ const wrapper = (...args) => {
980
+ this.off(event, wrapper);
981
+ fn.apply(this, args);
982
+ };
983
+ return this.on(event, wrapper);
984
+ }
978
985
  emit(event, ...args) {
979
986
  const callbacks = this.callbacks[event];
980
- if (callbacks) callbacks.forEach((callback) => callback.apply(this, args));
987
+ if (callbacks) for (const callback of callbacks) try {
988
+ callback.apply(this, args);
989
+ } catch (err) {
990
+ console.error(`[EventEmitter] Error in "${event}" listener:`, err);
991
+ }
981
992
  return this;
982
993
  }
983
994
  off(event, fn) {
@@ -1817,7 +1828,11 @@ var AbracadabraWS = class extends EventEmitter {
1817
1828
  this.resolveConnectionAttempt();
1818
1829
  this.lastMessageReceived = getUnixTime();
1819
1830
  const documentName = new IncomingMessage(event.data).peekVarString();
1820
- this.configuration.providerMap.get(documentName)?.onMessage(event);
1831
+ try {
1832
+ this.configuration.providerMap.get(documentName)?.onMessage(event);
1833
+ } catch (err) {
1834
+ console.error(`[AbracadabraWS] Provider onMessage error for "${documentName}":`, err);
1835
+ }
1821
1836
  }
1822
1837
  resolveConnectionAttempt() {
1823
1838
  if (this.connectionAttempt) {
@@ -2244,6 +2259,10 @@ var AwarenessError = class extends Error {
2244
2259
  }
2245
2260
  };
2246
2261
  var AbracadabraBaseProvider = class extends EventEmitter {
2262
+ /** Current WebSocket connection status. */
2263
+ get connectionStatus() {
2264
+ return this.configuration.websocketProvider.status;
2265
+ }
2247
2266
  constructor(configuration) {
2248
2267
  super();
2249
2268
  this.configuration = {
@@ -2762,7 +2781,9 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2762
2781
  this.document.on("subdocs", this.boundHandleYSubdocsChange);
2763
2782
  this.on("synced", () => this.flushPendingUpdates());
2764
2783
  this.restorePermissionSnapshot();
2765
- this.ready = this._initFromOfflineStore();
2784
+ this.ready = Promise.race([this._initFromOfflineStore(), new Promise((resolve) => setTimeout(resolve, 5e3))]).catch((err) => {
2785
+ this.emit("error", { message: `Offline store init failed: ${err?.message ?? err}` });
2786
+ });
2766
2787
  }
2767
2788
  /**
2768
2789
  * Extract the server hostname from the provider configuration.
@@ -2880,7 +2901,9 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2880
2901
  }
2881
2902
  const msg = parsed;
2882
2903
  if (msg.type === "auth_challenge" && msg.challenge && msg.expiresAt) {
2883
- this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch(() => null);
2904
+ this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch((err) => {
2905
+ this.emit("authenticationFailed", { reason: `Auth challenge error: ${err?.message ?? err}` });
2906
+ });
2884
2907
  return;
2885
2908
  }
2886
2909
  if (msg.type === "subdoc_registered" && msg.child_id && msg.parent_id) {
@@ -2922,6 +2945,14 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2922
2945
  createdAt: Date.now()
2923
2946
  });
2924
2947
  }
2948
+ /** Get a loaded child provider by ID, or null if not yet loaded. */
2949
+ getChild(childId) {
2950
+ return this.childProviders.get(childId) ?? null;
2951
+ }
2952
+ /** Check if a child provider is already loaded. */
2953
+ hasChild(childId) {
2954
+ return this.childProviders.has(childId);
2955
+ }
2925
2956
  /**
2926
2957
  * Create (or return cached) a child AbracadabraProvider for a given
2927
2958
  * child document id. Each child opens its own WebSocket connection because
@@ -3052,6 +3083,17 @@ var AbracadabraClient = class {
3052
3083
  get isAuthenticated() {
3053
3084
  return this._token !== null;
3054
3085
  }
3086
+ /** Check if the current JWT token is present and not expired. */
3087
+ isTokenValid() {
3088
+ if (!this._token) return false;
3089
+ try {
3090
+ const [, payload] = this._token.split(".");
3091
+ const { exp } = JSON.parse(atob(payload));
3092
+ return typeof exp === "number" && exp * 1e3 > Date.now();
3093
+ } catch {
3094
+ return false;
3095
+ }
3096
+ }
3055
3097
  /** Derives ws:// or wss:// URL from the http(s) base URL. */
3056
3098
  get wsUrl() {
3057
3099
  return this.baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + "/ws";
@@ -3145,12 +3187,7 @@ var AbracadabraClient = class {
3145
3187
  }
3146
3188
  /** Get the caller's key envelope for a document (for decrypting the DocKey). */
3147
3189
  async getMyKeyEnvelope(docId) {
3148
- try {
3149
- return await this.request("GET", `/docs/${encodeURIComponent(docId)}/key-envelope`);
3150
- } catch (e) {
3151
- if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3152
- throw e;
3153
- }
3190
+ return this.requestOrNull("GET", `/docs/${encodeURIComponent(docId)}/key-envelope`);
3154
3191
  }
3155
3192
  /** Upload key envelopes for a document (Owner only). */
3156
3193
  async uploadKeyEnvelopes(docId, opts) {
@@ -3158,12 +3195,7 @@ var AbracadabraClient = class {
3158
3195
  }
3159
3196
  /** Get the X25519 public key for a user. */
3160
3197
  async getUserX25519Key(userId) {
3161
- try {
3162
- return (await this.request("GET", `/users/${encodeURIComponent(userId)}/x25519-key`)).x25519_key;
3163
- } catch (e) {
3164
- if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3165
- throw e;
3166
- }
3198
+ return (await this.requestOrNull("GET", `/users/${encodeURIComponent(userId)}/x25519-key`))?.x25519_key ?? null;
3167
3199
  }
3168
3200
  /** List all non-revoked keys for a user (Owner/Admin or self). */
3169
3201
  async listUserKeys(userId) {
@@ -3241,14 +3273,15 @@ var AbracadabraClient = class {
3241
3273
  async listEffectivePermissions(docId) {
3242
3274
  try {
3243
3275
  return await this.request("GET", `/docs/${encodeURIComponent(docId)}/effective-permissions`);
3244
- } catch {
3245
- return {
3276
+ } catch (e) {
3277
+ if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return {
3246
3278
  permissions: (await this.listPermissions(docId)).map((p) => ({
3247
3279
  ...p,
3248
3280
  source: "direct"
3249
3281
  })),
3250
3282
  default_role: "viewer"
3251
3283
  };
3284
+ throw e;
3252
3285
  }
3253
3286
  }
3254
3287
  /** Grant or change a user's role on a document (requires Owner). */
@@ -3327,12 +3360,7 @@ var AbracadabraClient = class {
3327
3360
  }
3328
3361
  /** Get the hub space, or null if none is configured. */
3329
3362
  async getHubSpace() {
3330
- try {
3331
- return await this.request("GET", "/spaces/hub", { auth: false });
3332
- } catch (e) {
3333
- if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3334
- throw e;
3335
- }
3363
+ return this.requestOrNull("GET", "/spaces/hub", { auth: false });
3336
3364
  }
3337
3365
  /** Create a new space (auth required). */
3338
3366
  async createSpace(opts) {
@@ -3375,25 +3403,35 @@ var AbracadabraClient = class {
3375
3403
  if (auth && this._token) headers["Authorization"] = `Bearer ${this._token}`;
3376
3404
  const init = {
3377
3405
  method,
3378
- headers
3406
+ headers,
3407
+ signal: AbortSignal.timeout(3e4)
3379
3408
  };
3380
3409
  if (opts?.body !== void 0) {
3381
3410
  headers["Content-Type"] = "application/json";
3382
3411
  init.body = JSON.stringify(opts.body);
3383
3412
  }
3384
3413
  const res = await this._fetch(`${this.baseUrl}${path}`, init);
3385
- if (!res.ok) throw await this.toError(res);
3414
+ if (!res.ok) throw await this.toError(res, method, path);
3386
3415
  if (res.status === 204) return;
3387
3416
  return res.json();
3388
3417
  }
3389
- async toError(res) {
3418
+ async requestOrNull(method, path, opts) {
3419
+ try {
3420
+ return await this.request(method, path, opts);
3421
+ } catch (e) {
3422
+ if (typeof e === "object" && e !== null && "status" in e && e.status === 404) return null;
3423
+ throw e;
3424
+ }
3425
+ }
3426
+ async toError(res, method, path) {
3390
3427
  let message;
3391
3428
  try {
3392
3429
  message = (await res.json()).error ?? res.statusText;
3393
3430
  } catch {
3394
3431
  message = res.statusText;
3395
3432
  }
3396
- const err = new Error(message);
3433
+ const prefix = method && path ? `${method} ${path}: ` : "";
3434
+ const err = /* @__PURE__ */ new Error(`${prefix}${message} (${res.status})`);
3397
3435
  err.status = res.status;
3398
3436
  return err;
3399
3437
  }
@@ -7412,6 +7450,7 @@ var SearchIndex = class {
7412
7450
  if (!db) return [];
7413
7451
  const queryTrigrams = [...extractTrigrams(query)];
7414
7452
  if (queryTrigrams.length === 0) return [];
7453
+ const maxScoreEntries = limit * 10;
7415
7454
  return new Promise((resolve, reject) => {
7416
7455
  const tx = db.transaction("postings", "readonly");
7417
7456
  const postings = tx.objectStore("postings");
@@ -7421,7 +7460,10 @@ var SearchIndex = class {
7421
7460
  const req = postings.get(trigram);
7422
7461
  req.onsuccess = () => {
7423
7462
  const docIds = req.result ?? [];
7424
- for (const docId of docIds) scores.set(docId, (scores.get(docId) ?? 0) + 1);
7463
+ for (const docId of docIds) {
7464
+ scores.set(docId, (scores.get(docId) ?? 0) + 1);
7465
+ if (scores.size >= maxScoreEntries) break;
7466
+ }
7425
7467
  remaining--;
7426
7468
  if (remaining === 0) resolve([...scores.entries()].map(([docId, score]) => ({
7427
7469
  docId,
@@ -7479,7 +7521,7 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
7479
7521
  this.db = null;
7480
7522
  this.objectUrls = /* @__PURE__ */ new Map();
7481
7523
  this._notFound = /* @__PURE__ */ new Map();
7482
- this._flushing = false;
7524
+ this._flushPromise = null;
7483
7525
  this.origin = serverOrigin;
7484
7526
  this.client = client ?? null;
7485
7527
  this._onlineHandler = () => {
@@ -7621,6 +7663,20 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
7621
7663
  req.onerror = () => reject(req.error);
7622
7664
  });
7623
7665
  }
7666
+ /**
7667
+ * Revoke the in-memory object URL without touching the IDB cache.
7668
+ * The next call to getBlobUrl() will re-create a fresh URL from IDB.
7669
+ * Use this when an <img> @error fires — the blob data is fine, only
7670
+ * the object URL reference is stale.
7671
+ */
7672
+ invalidateUrl(docId, uploadId) {
7673
+ const key = this.blobKey(docId, uploadId);
7674
+ const url = this.objectUrls.get(key);
7675
+ if (url) {
7676
+ URL.revokeObjectURL(url);
7677
+ this.objectUrls.delete(key);
7678
+ }
7679
+ }
7624
7680
  /** Revoke the object URL and remove the blob from cache. */
7625
7681
  async evictBlob(docId, uploadId) {
7626
7682
  const key = this.blobKey(docId, uploadId);
@@ -7673,38 +7729,42 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
7673
7729
  * Entries that fail are marked with status "error" and left in the queue.
7674
7730
  */
7675
7731
  async flushQueue() {
7676
- if (this._flushing || !this.client) return;
7677
- this._flushing = true;
7732
+ if (this._flushPromise || !this.client) return;
7733
+ this._flushPromise = this._doFlush();
7678
7734
  try {
7679
- const pending = (await this.getQueue()).filter((e) => e.status === "pending");
7680
- for (const entry of pending) {
7681
- await this._updateQueueEntry(entry.id, { status: "uploading" });
7682
- this.emit("upload:started", {
7735
+ await this._flushPromise;
7736
+ } finally {
7737
+ this._flushPromise = null;
7738
+ }
7739
+ }
7740
+ async _doFlush() {
7741
+ if (!this.client) return;
7742
+ const pending = (await this.getQueue()).filter((e) => e.status === "pending");
7743
+ for (const entry of pending) {
7744
+ await this._updateQueueEntry(entry.id, { status: "uploading" });
7745
+ this.emit("upload:started", {
7746
+ ...entry,
7747
+ status: "uploading"
7748
+ });
7749
+ try {
7750
+ await this.client.upload(entry.docId, entry.file, entry.filename);
7751
+ await this._updateQueueEntry(entry.id, { status: "done" });
7752
+ this.emit("upload:done", {
7683
7753
  ...entry,
7684
- status: "uploading"
7754
+ status: "done"
7755
+ });
7756
+ } catch (err) {
7757
+ const message = err instanceof Error ? err.message : String(err);
7758
+ await this._updateQueueEntry(entry.id, {
7759
+ status: "error",
7760
+ error: message
7761
+ });
7762
+ this.emit("upload:error", {
7763
+ ...entry,
7764
+ status: "error",
7765
+ error: message
7685
7766
  });
7686
- try {
7687
- await this.client.upload(entry.docId, entry.file, entry.filename);
7688
- await this._updateQueueEntry(entry.id, { status: "done" });
7689
- this.emit("upload:done", {
7690
- ...entry,
7691
- status: "done"
7692
- });
7693
- } catch (err) {
7694
- const message = err instanceof Error ? err.message : String(err);
7695
- await this._updateQueueEntry(entry.id, {
7696
- status: "error",
7697
- error: message
7698
- });
7699
- this.emit("upload:error", {
7700
- ...entry,
7701
- status: "error",
7702
- error: message
7703
- });
7704
- }
7705
7767
  }
7706
- } finally {
7707
- this._flushing = false;
7708
7768
  }
7709
7769
  }
7710
7770
  async _updateQueueEntry(id, patch) {
@@ -7752,10 +7812,13 @@ const HKDF_INFO$1 = new TextEncoder().encode("abracadabra-dockey-v1");
7752
7812
  function fromBase64$1(b64) {
7753
7813
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
7754
7814
  }
7755
- var DocKeyManager = class {
7815
+ var DocKeyManager = class DocKeyManager {
7756
7816
  constructor() {
7757
7817
  this.cache = /* @__PURE__ */ new Map();
7758
7818
  }
7819
+ static {
7820
+ this.CACHE_TTL = 600 * 1e3;
7821
+ }
7759
7822
  /** Generate a new random AES-256-GCM document key. */
7760
7823
  static async generateDocKey() {
7761
7824
  return crypto.subtle.generateKey({
@@ -7769,7 +7832,7 @@ var DocKeyManager = class {
7769
7832
  */
7770
7833
  async getDocKey(docId, client, keystore) {
7771
7834
  const cached = this.cache.get(docId);
7772
- if (cached) return cached.key;
7835
+ if (cached && Date.now() - cached.fetchedAt < DocKeyManager.CACHE_TTL) return cached.key;
7773
7836
  const envelope = await client.getMyKeyEnvelope(docId);
7774
7837
  if (!envelope) return null;
7775
7838
  const x25519PrivKey = await keystore.getX25519PrivateKey();
@@ -7778,7 +7841,8 @@ var DocKeyManager = class {
7778
7841
  const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
7779
7842
  this.cache.set(docId, {
7780
7843
  key: docKey,
7781
- epoch: envelope.key_epoch
7844
+ epoch: envelope.key_epoch,
7845
+ fetchedAt: Date.now()
7782
7846
  });
7783
7847
  return docKey;
7784
7848
  } finally {
@@ -8252,6 +8316,7 @@ var BackgroundSyncManager = class extends EventEmitter {
8252
8316
  super();
8253
8317
  this.syncStates = /* @__PURE__ */ new Map();
8254
8318
  this._destroyed = false;
8319
+ this._initPromise = null;
8255
8320
  this.rootProvider = rootProvider;
8256
8321
  this.client = client;
8257
8322
  this.fileBlobStore = fileBlobStore ?? null;
@@ -8267,18 +8332,36 @@ var BackgroundSyncManager = class extends EventEmitter {
8267
8332
  this.persistence = new BackgroundSyncPersistence(serverOrigin);
8268
8333
  this.semaphore = new Semaphore(this.opts.concurrency);
8269
8334
  }
8335
+ /**
8336
+ * Load persisted sync states from IndexedDB into the in-memory map.
8337
+ * Called automatically by syncAll() / syncDoc(); safe to call concurrently.
8338
+ */
8339
+ async init() {
8340
+ if (!this._initPromise) this._initPromise = this._loadPersistedStates();
8341
+ return this._initPromise;
8342
+ }
8343
+ async _loadPersistedStates() {
8344
+ try {
8345
+ const states = await this.persistence.getAllStates();
8346
+ for (const state of states) this.syncStates.set(state.docId, state);
8347
+ } catch {}
8348
+ }
8270
8349
  /** Sync all documents in the root tree. */
8271
8350
  async syncAll() {
8272
8351
  if (this._destroyed) return;
8352
+ await this.init();
8273
8353
  const treeMap = this.rootProvider.document.getMap("doc-tree");
8274
8354
  const entries = Array.from(treeMap.entries());
8275
8355
  if (entries.length === 0) return;
8356
+ const updatedAtMap = /* @__PURE__ */ new Map();
8357
+ for (const [docId, v] of entries) updatedAtMap.set(docId, v?.updatedAt ?? v?.createdAt ?? 0);
8276
8358
  this._prefetchCovers(entries).catch(() => null);
8277
8359
  const queue = this._buildQueue(entries);
8278
- await Promise.all(queue.map((docId) => this._syncWithSemaphore(docId)));
8360
+ await Promise.all(queue.map((docId) => this._syncWithSemaphore(docId, updatedAtMap.get(docId) ?? 0)));
8279
8361
  }
8280
8362
  /** Sync a single document by ID. */
8281
8363
  async syncDoc(docId) {
8364
+ await this.init();
8282
8365
  const state = await this._doSyncDoc(docId);
8283
8366
  this.syncStates.set(docId, state);
8284
8367
  await this.persistence.setState(state).catch(() => null);
@@ -8329,8 +8412,16 @@ var BackgroundSyncManager = class extends EventEmitter {
8329
8412
  items.sort((a, b) => b.priority - a.priority);
8330
8413
  return items.map((i) => i.docId);
8331
8414
  }
8332
- async _syncWithSemaphore(docId) {
8415
+ async _syncWithSemaphore(docId, updatedAt) {
8333
8416
  if (this._destroyed) return;
8417
+ const existing = this.syncStates.get(docId);
8418
+ if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced >= updatedAt) {
8419
+ this.emit("stateChanged", {
8420
+ docId,
8421
+ state: existing
8422
+ });
8423
+ return;
8424
+ }
8334
8425
  await this.semaphore.acquire();
8335
8426
  try {
8336
8427
  const state = await this._doSyncDoc(docId);
@@ -8356,8 +8447,8 @@ var BackgroundSyncManager = class extends EventEmitter {
8356
8447
  docId,
8357
8448
  state: syncing
8358
8449
  });
8450
+ let isE2E = false;
8359
8451
  try {
8360
- let isE2E = false;
8361
8452
  try {
8362
8453
  isE2E = (await this.client.getDocEncryption(docId)).mode === "e2e";
8363
8454
  } catch {}
@@ -8370,7 +8461,7 @@ var BackgroundSyncManager = class extends EventEmitter {
8370
8461
  status: "error",
8371
8462
  lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
8372
8463
  error,
8373
- isE2E: false
8464
+ isE2E
8374
8465
  };
8375
8466
  }
8376
8467
  }
@@ -8503,6 +8594,7 @@ var SignalingSocket = class extends EventEmitter {
8503
8594
  this.connectionAttempt = null;
8504
8595
  this.localPeerId = null;
8505
8596
  this.isConnected = false;
8597
+ this._connectPromise = null;
8506
8598
  this.config = {
8507
8599
  url: configuration.url,
8508
8600
  token: configuration.token,
@@ -8522,6 +8614,15 @@ var SignalingSocket = class extends EventEmitter {
8522
8614
  }
8523
8615
  async connect() {
8524
8616
  if (this.isConnected) return;
8617
+ if (this._connectPromise) return this._connectPromise;
8618
+ this._connectPromise = this._doConnect();
8619
+ try {
8620
+ await this._connectPromise;
8621
+ } finally {
8622
+ this._connectPromise = null;
8623
+ }
8624
+ }
8625
+ async _doConnect() {
8525
8626
  if (this.cancelRetry) {
8526
8627
  this.cancelRetry();
8527
8628
  this.cancelRetry = void 0;
@@ -8835,11 +8936,12 @@ var DataChannelRouter = class extends EventEmitter {
8835
8936
  */
8836
8937
  async send(name, data) {
8837
8938
  const channel = this.channels.get(name);
8838
- if (!channel || channel.readyState !== "open") return;
8939
+ if (!channel || channel.readyState !== "open") return false;
8839
8940
  if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
8840
8941
  const encrypted = await this.encryptor.encrypt(data);
8841
8942
  channel.send(encrypted);
8842
8943
  } else channel.send(data);
8944
+ return true;
8843
8945
  }
8844
8946
  registerChannel(channel) {
8845
8947
  channel.binaryType = "arraybuffer";
@@ -9007,6 +9109,7 @@ var YjsDataChannel = class {
9007
9109
  this.document = document;
9008
9110
  this.awareness = awareness;
9009
9111
  this.router = router;
9112
+ this.isSynced = false;
9010
9113
  this.docUpdateHandler = null;
9011
9114
  this.awarenessUpdateHandler = null;
9012
9115
  this.channelOpenHandler = null;
@@ -9243,6 +9346,10 @@ var FileTransferChannel = class extends EventEmitter {
9243
9346
  try {
9244
9347
  meta = JSON.parse(json);
9245
9348
  } catch {
9349
+ this.emit("receiveError", {
9350
+ transferId: "unknown",
9351
+ error: "Malformed START message: invalid JSON"
9352
+ });
9246
9353
  return;
9247
9354
  }
9248
9355
  this.receives.set(meta.transferId, {
@@ -9949,7 +10056,9 @@ var ManualSignaling = class extends EventEmitter {
9949
10056
  type: "offer",
9950
10057
  sdp: offerBlob.sdp
9951
10058
  }));
9952
- for (const c of offerBlob.candidates) await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
10059
+ for (const c of offerBlob.candidates) try {
10060
+ await this.pc.addIceCandidate(new RTCIceCandidate(JSON.parse(c)));
10061
+ } catch {}
9953
10062
  const answer = await this.pc.createAnswer();
9954
10063
  await this.pc.setLocalDescription(answer);
9955
10064
  await gatheringComplete;