@abraca/dabra 1.5.0 → 1.8.0

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.
@@ -2317,6 +2317,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2317
2317
  onAwarenessUpdate: () => null,
2318
2318
  onAwarenessChange: () => null,
2319
2319
  onStateless: () => null,
2320
+ onServerError: () => null,
2320
2321
  onUnsyncedChanges: () => null
2321
2322
  };
2322
2323
  this.isSynced = false;
@@ -2348,6 +2349,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2348
2349
  this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
2349
2350
  this.on("awarenessChange", this.configuration.onAwarenessChange);
2350
2351
  this.on("stateless", this.configuration.onStateless);
2352
+ this.on("serverError", this.configuration.onServerError);
2351
2353
  this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
2352
2354
  this.on("authenticated", this.configuration.onAuthenticated);
2353
2355
  this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
@@ -2463,6 +2465,19 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2463
2465
  if (state) this.emit("synced", { state });
2464
2466
  }
2465
2467
  receiveStateless(payload) {
2468
+ try {
2469
+ const parsed = JSON.parse(payload);
2470
+ if (parsed?.type === "error" && parsed.source && parsed.code) {
2471
+ const { source, code, message } = parsed;
2472
+ console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`);
2473
+ this.emit("serverError", {
2474
+ source,
2475
+ code,
2476
+ message: message ?? ""
2477
+ });
2478
+ return;
2479
+ }
2480
+ } catch {}
2466
2481
  this.emit("stateless", { payload });
2467
2482
  }
2468
2483
  async connect() {
@@ -2808,6 +2823,9 @@ function isValidDocId(id) {
2808
2823
  * refreshed from the server on every reconnect.
2809
2824
  */
2810
2825
  var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvider {
2826
+ static {
2827
+ this.MAX_CHILDREN = 20;
2828
+ }
2811
2829
  constructor(configuration) {
2812
2830
  const resolved = { ...configuration };
2813
2831
  const client = configuration.client ?? null;
@@ -2819,6 +2837,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2819
2837
  this.effectiveRole = null;
2820
2838
  this.childProviders = /* @__PURE__ */ new Map();
2821
2839
  this.pendingLoads = /* @__PURE__ */ new Map();
2840
+ this.childAccessTimes = /* @__PURE__ */ new Map();
2841
+ this.pinnedChildren = /* @__PURE__ */ new Set();
2822
2842
  this.boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
2823
2843
  this._client = client;
2824
2844
  this.abracadabraConfig = configuration;
@@ -3013,7 +3033,10 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3013
3033
  */
3014
3034
  loadChild(childId) {
3015
3035
  if (!isValidDocId(childId)) return Promise.reject(/* @__PURE__ */ new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`));
3016
- if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
3036
+ if (this.childProviders.has(childId)) {
3037
+ this.childAccessTimes.set(childId, Date.now());
3038
+ return Promise.resolve(this.childProviders.get(childId));
3039
+ }
3017
3040
  if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
3018
3041
  const load = this._doLoadChild(childId);
3019
3042
  this.pendingLoads.set(childId, load);
@@ -3038,6 +3061,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3038
3061
  });
3039
3062
  childProvider.attach();
3040
3063
  this.childProviders.set(childId, childProvider);
3064
+ this.childAccessTimes.set(childId, Date.now());
3065
+ this.evictLRU();
3041
3066
  this.emit("subdocLoaded", {
3042
3067
  childId,
3043
3068
  provider: childProvider
@@ -3049,6 +3074,44 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3049
3074
  if (provider) {
3050
3075
  provider.destroy();
3051
3076
  this.childProviders.delete(childId);
3077
+ this.childAccessTimes.delete(childId);
3078
+ this.pinnedChildren.delete(childId);
3079
+ }
3080
+ }
3081
+ /**
3082
+ * Mark a child as pinned so LRU eviction will not remove it.
3083
+ * Use this when a document is actively being viewed by the user.
3084
+ */
3085
+ pinChild(childId) {
3086
+ this.pinnedChildren.add(childId);
3087
+ }
3088
+ /**
3089
+ * Unpin a child, allowing LRU eviction to reclaim it when capacity is exceeded.
3090
+ */
3091
+ unpinChild(childId) {
3092
+ this.pinnedChildren.delete(childId);
3093
+ this.evictLRU();
3094
+ }
3095
+ /**
3096
+ * Evict least-recently-used unpinned child providers until the cache is
3097
+ * at or below MAX_CHILDREN.
3098
+ */
3099
+ evictLRU() {
3100
+ if (this.childProviders.size <= AbracadabraProvider.MAX_CHILDREN) return;
3101
+ const evictable = [];
3102
+ for (const [id] of this.childProviders) {
3103
+ if (this.pinnedChildren.has(id)) continue;
3104
+ evictable.push({
3105
+ id,
3106
+ accessTime: this.childAccessTimes.get(id) ?? 0
3107
+ });
3108
+ }
3109
+ evictable.sort((a, b) => a.accessTime - b.accessTime);
3110
+ let toEvict = this.childProviders.size - AbracadabraProvider.MAX_CHILDREN;
3111
+ for (const entry of evictable) {
3112
+ if (toEvict <= 0) break;
3113
+ this.unloadChild(entry.id);
3114
+ toEvict--;
3052
3115
  }
3053
3116
  }
3054
3117
  /** Return all currently-loaded child providers. */
@@ -3108,6 +3171,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3108
3171
  const childIds = [...this.childProviders.keys()];
3109
3172
  for (const provider of this.childProviders.values()) provider.destroy();
3110
3173
  this.childProviders.clear();
3174
+ this.childAccessTimes.clear();
3175
+ this.pinnedChildren.clear();
3111
3176
  const wsProviderMap = this.configuration.websocketProvider?.configuration?.providerMap;
3112
3177
  if (wsProviderMap) for (const childId of childIds) wsProviderMap.delete(childId);
3113
3178
  this.offlineStore?.destroy();
@@ -10906,35 +10971,57 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10906
10971
  * This propagates "last edited" timestamps to all peers via the root CRDT,
10907
10972
  * without requiring any server-side changes.
10908
10973
  *
10909
- * Limitation: at least one client must have the child doc open after an edit
10910
- * for the timestamp to propagate (eventually consistent).
10974
+ * A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
10975
+ * on the root doc during rapid typing.
10911
10976
  */
10912
10977
  /**
10913
- * Attach an observer that writes `updatedAt: Date.now()` to the root
10914
- * doc-tree entry for `childDocId` whenever the child doc receives a
10915
- * non-offline update.
10978
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
10979
+ * `childDocId` whenever the child doc receives a non-offline update.
10916
10980
  *
10917
- * @param treeMap The root doc's "doc-tree" Y.Map.
10918
- * @param childDocId The child document's UUID (key in treeMap).
10919
- * @param childDoc The child Y.Doc to observe.
10981
+ * Writes are throttled: the first qualifying update records the timestamp;
10982
+ * a trailing-edge timer flushes it to the tree map after `throttleMs`.
10983
+ *
10984
+ * @param treeMap The root doc's "doc-tree" Y.Map.
10985
+ * @param childDocId The child document's UUID (key in treeMap).
10986
+ * @param childDoc The child Y.Doc to observe.
10920
10987
  * @param offlineStore The child provider's OfflineStore (used to detect
10921
10988
  * offline-replay origins and skip them). Pass null when
10922
10989
  * the offline store is disabled.
10923
- * @returns Cleanup function call on provider destroy.
10924
- */
10925
- function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore) {
10926
- function handler(update, origin) {
10927
- if (offlineStore !== null && origin === offlineStore) return;
10990
+ * @param options Optional config. `throttleMs` controls the write
10991
+ * interval (default 5000).
10992
+ * @returns Cleanup function call on provider destroy. Flushes
10993
+ * any pending write before detaching.
10994
+ */
10995
+ function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore, options) {
10996
+ const throttleMs = options?.throttleMs ?? 5e3;
10997
+ let latestTs = 0;
10998
+ let timer = null;
10999
+ function flush() {
11000
+ if (latestTs === 0) return;
11001
+ const ts = latestTs;
11002
+ latestTs = 0;
11003
+ timer = null;
10928
11004
  const raw = treeMap.get(childDocId);
10929
11005
  if (!raw) return;
10930
11006
  const entry = raw instanceof yjs.Map ? raw.toJSON() : raw;
10931
11007
  treeMap.set(childDocId, {
10932
11008
  ...entry,
10933
- updatedAt: Date.now()
11009
+ updatedAt: ts
10934
11010
  });
10935
11011
  }
11012
+ function handler(_update, origin) {
11013
+ if (offlineStore !== null && origin === offlineStore) return;
11014
+ latestTs = Date.now();
11015
+ if (timer === null) timer = setTimeout(flush, throttleMs);
11016
+ }
10936
11017
  childDoc.on("update", handler);
10937
- return () => childDoc.off("update", handler);
11018
+ return () => {
11019
+ childDoc.off("update", handler);
11020
+ if (timer !== null) {
11021
+ clearTimeout(timer);
11022
+ flush();
11023
+ }
11024
+ };
10938
11025
  }
10939
11026
 
10940
11027
  //#endregion
@@ -11204,7 +11291,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11204
11291
  async _syncWithSemaphore(docId, updatedAt) {
11205
11292
  if (this._destroyed) return true;
11206
11293
  const existing = this.syncStates.get(docId);
11207
- if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced >= updatedAt) {
11294
+ if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced + 200 >= updatedAt) {
11208
11295
  this.emit("stateChanged", {
11209
11296
  docId,
11210
11297
  state: existing
@@ -11256,6 +11343,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11256
11343
  }
11257
11344
  }
11258
11345
  async _syncNonE2EDoc(docId) {
11346
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11259
11347
  const alreadyCached = this.rootProvider.children.has(docId);
11260
11348
  if (!alreadyCached) {
11261
11349
  if ((this.rootProvider.configuration?.websocketProvider?.configuration?.providerMap)?.has(docId)) return {
@@ -11266,27 +11354,33 @@ var BackgroundSyncManager = class extends EventEmitter {
11266
11354
  };
11267
11355
  }
11268
11356
  const childProvider = await this.rootProvider.loadChild(docId);
11269
- await childProvider.ready;
11270
- await this._waitForSynced(childProvider);
11271
- {
11272
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11273
- this.emit("docSynced", {
11357
+ try {
11358
+ await childProvider.ready;
11359
+ await this._waitForSynced(childProvider);
11360
+ {
11361
+ const treeEntry = treeMap.get(docId);
11362
+ this.emit("docSynced", {
11363
+ docId,
11364
+ document: childProvider.document,
11365
+ label: treeEntry?.label ?? "",
11366
+ meta: treeEntry?.meta
11367
+ });
11368
+ }
11369
+ if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11370
+ const treeEntry = treeMap.get(docId);
11371
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
11372
+ return {
11274
11373
  docId,
11275
- document: childProvider.document,
11276
- label: treeEntry?.label ?? "",
11277
- meta: treeEntry?.meta
11278
- });
11374
+ status: "synced",
11375
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11376
+ isE2E: false
11377
+ };
11378
+ } finally {
11379
+ if (!alreadyCached) this.rootProvider.unloadChild(docId);
11279
11380
  }
11280
- if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11281
- if (!alreadyCached) this.rootProvider.unloadChild(docId);
11282
- return {
11283
- docId,
11284
- status: "synced",
11285
- lastSynced: Date.now(),
11286
- isE2E: false
11287
- };
11288
11381
  }
11289
11382
  async _syncE2EDoc(docId) {
11383
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11290
11384
  const docKeyManager = this.rootProvider.abracadabraConfig?.docKeyManager;
11291
11385
  const keystore = this.rootProvider.abracadabraConfig?.keystore;
11292
11386
  if (!docKeyManager || !keystore) return {
@@ -11307,7 +11401,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11307
11401
  await childProvider.ready;
11308
11402
  await this._waitForSynced(childProvider);
11309
11403
  {
11310
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11404
+ const treeEntry = treeMap.get(docId);
11311
11405
  this.emit("docSynced", {
11312
11406
  docId,
11313
11407
  document: childDoc,
@@ -11316,10 +11410,12 @@ var BackgroundSyncManager = class extends EventEmitter {
11316
11410
  });
11317
11411
  }
11318
11412
  if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childDoc).catch(() => null);
11413
+ const treeEntry = treeMap.get(docId);
11414
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
11319
11415
  return {
11320
11416
  docId,
11321
11417
  status: "synced",
11322
- lastSynced: Date.now(),
11418
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11323
11419
  isE2E: true
11324
11420
  };
11325
11421
  } finally {
@@ -13095,8 +13191,12 @@ var DevicePairingChannel = class DevicePairingChannel extends EventEmitter {
13095
13191
  /**
13096
13192
  * Approve the pending pairing request. Calls `client.addKey()` to
13097
13193
  * register Device B's public key, then notifies Device B.
13194
+ *
13195
+ * @param client Authenticated REST client.
13196
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
13197
+ * Sent to Device B so it can adopt the master's identity doc.
13098
13198
  */
13099
- async approve(client) {
13199
+ async approve(client, masterPublicKey) {
13100
13200
  if (this.role !== "approver") return {
13101
13201
  success: false,
13102
13202
  error: "Only the approver can approve"
@@ -13116,7 +13216,10 @@ var DevicePairingChannel = class DevicePairingChannel extends EventEmitter {
13116
13216
  deviceName: req.deviceName,
13117
13217
  x25519Key: req.x25519Key
13118
13218
  });
13119
- this.sendMessage({ type: "pair-approved" });
13219
+ this.sendMessage({
13220
+ type: "pair-approved",
13221
+ masterPublicKey
13222
+ });
13120
13223
  this._pendingRequest = null;
13121
13224
  this.emit("pairingComplete", { success: true });
13122
13225
  return { success: true };
@@ -13139,8 +13242,12 @@ var DevicePairingChannel = class DevicePairingChannel extends EventEmitter {
13139
13242
  * Approve via server-side device invite. Creates a single-use invite code
13140
13243
  * and sends it to Device B over the E2EE channel. Device B redeems it
13141
13244
  * independently via HTTP — Device A can go offline after this.
13245
+ *
13246
+ * @param client Authenticated REST client.
13247
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
13248
+ * Sent to Device B so it can adopt the master's identity doc.
13142
13249
  */
13143
- async approveWithInvite(client) {
13250
+ async approveWithInvite(client, masterPublicKey) {
13144
13251
  if (this.role !== "approver") return {
13145
13252
  success: false,
13146
13253
  error: "Only the approver can approve"
@@ -13157,7 +13264,8 @@ var DevicePairingChannel = class DevicePairingChannel extends EventEmitter {
13157
13264
  const { code } = await client.createDeviceInvite();
13158
13265
  this.sendMessage({
13159
13266
  type: "pair-invite-code",
13160
- code
13267
+ code,
13268
+ masterPublicKey
13161
13269
  });
13162
13270
  this._pendingRequest = null;
13163
13271
  this.emit("pairingComplete", { success: true });
@@ -13328,7 +13436,7 @@ var DevicePairingChannel = class DevicePairingChannel extends EventEmitter {
13328
13436
  break;
13329
13437
  case "pair-approved":
13330
13438
  if (this.role !== "requester") return;
13331
- this.emit("approved");
13439
+ this.emit("approved", { masterPublicKey: msg.masterPublicKey });
13332
13440
  this.emit("pairingComplete", { success: true });
13333
13441
  break;
13334
13442
  case "pair-rejected":
@@ -13341,7 +13449,7 @@ var DevicePairingChannel = class DevicePairingChannel extends EventEmitter {
13341
13449
  break;
13342
13450
  case "pair-invite-code":
13343
13451
  if (this.role !== "requester") return;
13344
- this.emit("inviteCode", msg.code);
13452
+ this.emit("inviteCode", msg.code, msg.masterPublicKey);
13345
13453
  break;
13346
13454
  }
13347
13455
  }
@@ -13868,6 +13976,19 @@ var IdentityDocProvider = class extends EventEmitter {
13868
13976
  return this.profileMap.size === 0 && this.serversMap.size === 0;
13869
13977
  }
13870
13978
  /**
13979
+ * Enable WebRTC P2P sync at runtime.
13980
+ * Use this for claimed/passkey users where E2EE identity derivation
13981
+ * was deferred to avoid biometric prompts on page load.
13982
+ */
13983
+ enableWebRTC(webrtcConfig) {
13984
+ if (this._destroyed || this.webrtc) return;
13985
+ this.config = {
13986
+ ...this.config,
13987
+ webrtc: webrtcConfig
13988
+ };
13989
+ this._connectWebRTC();
13990
+ }
13991
+ /**
13871
13992
  * Update the sync server URL at runtime (e.g. when user changes their
13872
13993
  * designated sync server in settings).
13873
13994
  */
@@ -14054,6 +14175,319 @@ var DeviceRegistrationService = class {
14054
14175
  }
14055
14176
  };
14056
14177
 
14178
+ //#endregion
14179
+ //#region packages/provider/src/ChatClient.ts
14180
+ const DEFAULT_TIMEOUT_MS$1 = 1e4;
14181
+ /**
14182
+ * Typed client for the Abracadabra chat feature.
14183
+ *
14184
+ * Wraps a connected provider (or base provider) and translates JSON envelopes
14185
+ * on the stateless channel into typed method calls and events.
14186
+ *
14187
+ * Events emitted:
14188
+ * - `message` → ChatMessage (new message broadcast)
14189
+ * - `typing` → ChatTypingEvent (typing indicator broadcast)
14190
+ * - `readReceipt` → ChatReadReceipt (mark_read broadcast)
14191
+ */
14192
+ var ChatClient = class extends EventEmitter {
14193
+ constructor(provider, options) {
14194
+ super();
14195
+ this.pending = /* @__PURE__ */ new Map();
14196
+ this.provider = provider;
14197
+ this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS$1;
14198
+ this.boundOnStateless = (data) => this.handleStateless(data.payload);
14199
+ this.provider.on("stateless", this.boundOnStateless);
14200
+ }
14201
+ /** Stop listening for chat messages. Does not disconnect the underlying provider. */
14202
+ destroy() {
14203
+ this.provider.off("stateless", this.boundOnStateless);
14204
+ for (const queue of this.pending.values()) for (const p of queue) {
14205
+ clearTimeout(p.timer);
14206
+ p.reject(/* @__PURE__ */ new Error("ChatClient destroyed"));
14207
+ }
14208
+ this.pending.clear();
14209
+ this.removeAllListeners();
14210
+ }
14211
+ /** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
14212
+ sendMessage(input) {
14213
+ this.provider.sendStateless(JSON.stringify({
14214
+ type: "chat:send",
14215
+ channel: input.channel,
14216
+ content: input.content,
14217
+ ...input.sender_name !== void 0 ? { sender_name: input.sender_name } : {}
14218
+ }));
14219
+ }
14220
+ /** Fetch historical messages for a channel. Resolves with the server response. */
14221
+ getHistory(input) {
14222
+ const promise = this.enqueue("chat:history");
14223
+ this.provider.sendStateless(JSON.stringify({
14224
+ type: "chat:history",
14225
+ channel: input.channel,
14226
+ ...input.before !== void 0 ? { before: input.before } : {},
14227
+ ...input.limit !== void 0 ? { limit: input.limit } : {}
14228
+ }));
14229
+ return promise;
14230
+ }
14231
+ /** Broadcast a typing indicator on a channel. */
14232
+ sendTyping(channel) {
14233
+ this.provider.sendStateless(JSON.stringify({
14234
+ type: "chat:typing",
14235
+ channel
14236
+ }));
14237
+ }
14238
+ /** List the current user's channels (ordered by last activity). */
14239
+ listChannels() {
14240
+ const promise = this.enqueue("chat:channels");
14241
+ this.provider.sendStateless(JSON.stringify({ type: "chat:channels" }));
14242
+ return promise;
14243
+ }
14244
+ /** Mark a channel read up to `timestamp` (unix ms). */
14245
+ markRead(channel, timestamp) {
14246
+ this.provider.sendStateless(JSON.stringify({
14247
+ type: "chat:mark_read",
14248
+ channel,
14249
+ timestamp
14250
+ }));
14251
+ }
14252
+ /** Fetch per-user read cursors for a channel. */
14253
+ getReadCursors(channel) {
14254
+ const promise = this.enqueue("chat:read_cursors");
14255
+ this.provider.sendStateless(JSON.stringify({
14256
+ type: "chat:read_cursors",
14257
+ channel
14258
+ }));
14259
+ return promise;
14260
+ }
14261
+ onMessage(fn) {
14262
+ return this.on("message", fn);
14263
+ }
14264
+ onTyping(fn) {
14265
+ return this.on("typing", fn);
14266
+ }
14267
+ onReadReceipt(fn) {
14268
+ return this.on("readReceipt", fn);
14269
+ }
14270
+ enqueue(type) {
14271
+ return new Promise((resolve, reject) => {
14272
+ const entry = {
14273
+ resolve,
14274
+ reject,
14275
+ timer: setTimeout(() => {
14276
+ this.removePending(type, entry);
14277
+ reject(/* @__PURE__ */ new Error(`ChatClient: timeout waiting for ${type} response`));
14278
+ }, this.responseTimeoutMs)
14279
+ };
14280
+ const queue = this.pending.get(type) ?? [];
14281
+ queue.push(entry);
14282
+ this.pending.set(type, queue);
14283
+ });
14284
+ }
14285
+ removePending(type, entry) {
14286
+ const queue = this.pending.get(type);
14287
+ if (!queue) return;
14288
+ const idx = queue.indexOf(entry);
14289
+ if (idx >= 0) queue.splice(idx, 1);
14290
+ if (queue.length === 0) this.pending.delete(type);
14291
+ }
14292
+ resolveNext(type, value) {
14293
+ const queue = this.pending.get(type);
14294
+ if (!queue || queue.length === 0) return false;
14295
+ const next = queue.shift();
14296
+ if (queue.length === 0) this.pending.delete(type);
14297
+ clearTimeout(next.timer);
14298
+ next.resolve(value);
14299
+ return true;
14300
+ }
14301
+ handleStateless(payload) {
14302
+ let parsed;
14303
+ try {
14304
+ parsed = JSON.parse(payload);
14305
+ } catch {
14306
+ return;
14307
+ }
14308
+ const type = parsed?.type;
14309
+ if (typeof type !== "string" || !type.startsWith("chat:")) return;
14310
+ switch (type) {
14311
+ case "chat:message": {
14312
+ const { type: _t, ...rest } = parsed;
14313
+ this.emit("message", rest);
14314
+ break;
14315
+ }
14316
+ case "chat:history":
14317
+ this.resolveNext("chat:history", {
14318
+ channel: parsed.channel,
14319
+ messages: parsed.messages ?? []
14320
+ });
14321
+ break;
14322
+ case "chat:channels":
14323
+ this.resolveNext("chat:channels", { channels: parsed.channels ?? [] });
14324
+ break;
14325
+ case "chat:typing":
14326
+ this.emit("typing", {
14327
+ channel: parsed.channel,
14328
+ sender_id: parsed.sender_id,
14329
+ sender_name: parsed.sender_name ?? null
14330
+ });
14331
+ break;
14332
+ case "chat:read_receipt":
14333
+ this.emit("readReceipt", {
14334
+ channel: parsed.channel,
14335
+ user_id: parsed.user_id,
14336
+ last_read_at: parsed.last_read_at
14337
+ });
14338
+ break;
14339
+ case "chat:read_cursors":
14340
+ this.resolveNext("chat:read_cursors", {
14341
+ channel: parsed.channel,
14342
+ cursors: parsed.cursors ?? []
14343
+ });
14344
+ break;
14345
+ default: break;
14346
+ }
14347
+ }
14348
+ };
14349
+
14350
+ //#endregion
14351
+ //#region packages/provider/src/NotificationsClient.ts
14352
+ const DEFAULT_TIMEOUT_MS = 1e4;
14353
+ /**
14354
+ * Typed client for the Abracadabra notifications feature.
14355
+ *
14356
+ * Emits:
14357
+ * - `new` → NotificationRecord (incoming notify:new broadcast)
14358
+ * - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
14359
+ */
14360
+ var NotificationsClient = class extends EventEmitter {
14361
+ constructor(provider, options) {
14362
+ super();
14363
+ this.pending = /* @__PURE__ */ new Map();
14364
+ this.provider = provider;
14365
+ this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
14366
+ this.boundOnStateless = (data) => this.handleStateless(data.payload);
14367
+ this.provider.on("stateless", this.boundOnStateless);
14368
+ }
14369
+ destroy() {
14370
+ this.provider.off("stateless", this.boundOnStateless);
14371
+ for (const queue of this.pending.values()) for (const p of queue) {
14372
+ clearTimeout(p.timer);
14373
+ p.reject(/* @__PURE__ */ new Error("NotificationsClient destroyed"));
14374
+ }
14375
+ this.pending.clear();
14376
+ this.removeAllListeners();
14377
+ }
14378
+ /**
14379
+ * Create a notification targeting a specific recipient. Requires elevated role
14380
+ * (service or admin); a `server:error` event with code `forbidden` is emitted
14381
+ * by the underlying provider if the caller lacks permission.
14382
+ */
14383
+ create(input) {
14384
+ this.provider.sendStateless(JSON.stringify({
14385
+ type: "notify:create",
14386
+ recipient_id: input.recipient_id,
14387
+ ...input.notification_type !== void 0 ? { notification_type: input.notification_type } : {},
14388
+ title: input.title,
14389
+ ...input.body !== void 0 ? { body: input.body } : {},
14390
+ ...input.icon !== void 0 ? { icon: input.icon } : {},
14391
+ ...input.link !== void 0 ? { link: input.link } : {},
14392
+ ...input.source_id !== void 0 ? { source_id: input.source_id } : {}
14393
+ }));
14394
+ }
14395
+ /** Fetch notification history for the current user. */
14396
+ fetch(input = {}) {
14397
+ const promise = this.enqueue("notify:history");
14398
+ this.provider.sendStateless(JSON.stringify({
14399
+ type: "notify:fetch",
14400
+ ...input.before !== void 0 ? { before: input.before } : {},
14401
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
14402
+ ...input.unread_only !== void 0 ? { unread_only: input.unread_only } : {}
14403
+ }));
14404
+ return promise;
14405
+ }
14406
+ /** Mark a single notification, or a batch, as read. */
14407
+ markRead(target) {
14408
+ const body = { type: "notify:mark_read" };
14409
+ if ("id" in target) body.id = target.id;
14410
+ else body.ids = target.ids;
14411
+ this.provider.sendStateless(JSON.stringify(body));
14412
+ }
14413
+ /** Mark every notification for the current user as read. */
14414
+ markAllRead() {
14415
+ this.provider.sendStateless(JSON.stringify({ type: "notify:mark_all_read" }));
14416
+ }
14417
+ /** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
14418
+ markReadBySource(sourceId) {
14419
+ this.provider.sendStateless(JSON.stringify({
14420
+ type: "notify:mark_read_by_source",
14421
+ source_id: sourceId
14422
+ }));
14423
+ }
14424
+ onNew(fn) {
14425
+ return this.on("new", fn);
14426
+ }
14427
+ onReadUpdate(fn) {
14428
+ return this.on("readUpdate", fn);
14429
+ }
14430
+ enqueue(type) {
14431
+ return new Promise((resolve, reject) => {
14432
+ const entry = {
14433
+ resolve,
14434
+ reject,
14435
+ timer: setTimeout(() => {
14436
+ this.removePending(type, entry);
14437
+ reject(/* @__PURE__ */ new Error(`NotificationsClient: timeout waiting for ${type} response`));
14438
+ }, this.responseTimeoutMs)
14439
+ };
14440
+ const queue = this.pending.get(type) ?? [];
14441
+ queue.push(entry);
14442
+ this.pending.set(type, queue);
14443
+ });
14444
+ }
14445
+ removePending(type, entry) {
14446
+ const queue = this.pending.get(type);
14447
+ if (!queue) return;
14448
+ const idx = queue.indexOf(entry);
14449
+ if (idx >= 0) queue.splice(idx, 1);
14450
+ if (queue.length === 0) this.pending.delete(type);
14451
+ }
14452
+ resolveNext(type, value) {
14453
+ const queue = this.pending.get(type);
14454
+ if (!queue || queue.length === 0) return false;
14455
+ const next = queue.shift();
14456
+ if (queue.length === 0) this.pending.delete(type);
14457
+ clearTimeout(next.timer);
14458
+ next.resolve(value);
14459
+ return true;
14460
+ }
14461
+ handleStateless(payload) {
14462
+ let parsed;
14463
+ try {
14464
+ parsed = JSON.parse(payload);
14465
+ } catch {
14466
+ return;
14467
+ }
14468
+ const type = parsed?.type;
14469
+ if (typeof type !== "string" || !type.startsWith("notify:")) return;
14470
+ switch (type) {
14471
+ case "notify:new": {
14472
+ const { type: _t, ...rest } = parsed;
14473
+ this.emit("new", rest);
14474
+ break;
14475
+ }
14476
+ case "notify:history":
14477
+ this.resolveNext("notify:history", { notifications: parsed.notifications ?? [] });
14478
+ break;
14479
+ case "notify:read_update": {
14480
+ const update = { recipient_id: parsed.recipient_id };
14481
+ if (parsed.ids !== void 0) update.ids = parsed.ids;
14482
+ if (parsed.all !== void 0) update.all = parsed.all;
14483
+ this.emit("readUpdate", update);
14484
+ break;
14485
+ }
14486
+ default: break;
14487
+ }
14488
+ }
14489
+ };
14490
+
14057
14491
  //#endregion
14058
14492
  exports.AbracadabraBaseProvider = AbracadabraBaseProvider;
14059
14493
  exports.AbracadabraClient = AbracadabraClient;
@@ -14066,6 +14500,7 @@ exports.BackgroundSyncManager = BackgroundSyncManager;
14066
14500
  exports.BackgroundSyncPersistence = BackgroundSyncPersistence;
14067
14501
  exports.BroadcastChannelSync = BroadcastChannelSync;
14068
14502
  exports.CHANNEL_NAMES = CHANNEL_NAMES;
14503
+ exports.ChatClient = ChatClient;
14069
14504
  exports.ConnectionTimeout = ConnectionTimeout;
14070
14505
  exports.CryptoIdentityKeystore = CryptoIdentityKeystore;
14071
14506
  exports.DEFAULT_FILE_CHUNK_SIZE = DEFAULT_FILE_CHUNK_SIZE;
@@ -14091,6 +14526,7 @@ exports.KEY_EXCHANGE_CHANNEL = KEY_EXCHANGE_CHANNEL;
14091
14526
  exports.ManualSignaling = ManualSignaling;
14092
14527
  exports.MessageTooBig = MessageTooBig;
14093
14528
  exports.MessageType = MessageType;
14529
+ exports.NotificationsClient = NotificationsClient;
14094
14530
  exports.OfflineStore = OfflineStore;
14095
14531
  exports.PeerConnection = PeerConnection;
14096
14532
  exports.ResetConnection = ResetConnection;