@abraca/dabra 2.8.0 → 2.10.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.
@@ -1233,6 +1233,8 @@ var AbracadabraWS = class extends EventEmitter {
1233
1233
  this.identifier = 0;
1234
1234
  this.intervals = { connectionChecker: null };
1235
1235
  this.connectionAttempt = null;
1236
+ this.onlineListener = null;
1237
+ this.offlineListener = null;
1236
1238
  this.receivedOnOpenPayload = void 0;
1237
1239
  this.closeTries = 0;
1238
1240
  this.setConfiguration(configuration);
@@ -1251,8 +1253,39 @@ var AbracadabraWS = class extends EventEmitter {
1251
1253
  this.on("close", this.onClose.bind(this));
1252
1254
  this.on("message", this.onMessage.bind(this));
1253
1255
  this.intervals.connectionChecker = setInterval(this.checkConnection.bind(this), this.configuration.messageReconnectTimeout / 10);
1256
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1257
+ this.onlineListener = this.handleOnline.bind(this);
1258
+ this.offlineListener = this.handleOffline.bind(this);
1259
+ window.addEventListener("online", this.onlineListener);
1260
+ window.addEventListener("offline", this.offlineListener);
1261
+ }
1254
1262
  if (this.shouldConnect) this.connect();
1255
1263
  }
1264
+ /**
1265
+ * Whether the device currently believes it has network connectivity.
1266
+ *
1267
+ * Treats "unknown" as online: in Node and other non-browser environments
1268
+ * `navigator` (or `navigator.onLine`) is absent, and we must not gate
1269
+ * reconnection there — only the browser exposes a trustworthy signal.
1270
+ */
1271
+ get isOnline() {
1272
+ return typeof navigator === "undefined" || navigator.onLine !== false;
1273
+ }
1274
+ handleOnline() {
1275
+ if (this.shouldConnect && this.status !== WebSocketStatus.Connected) this.connect();
1276
+ }
1277
+ handleOffline() {
1278
+ if (this.cancelWebsocketRetry) {
1279
+ this.cancelWebsocketRetry();
1280
+ this.cancelWebsocketRetry = void 0;
1281
+ }
1282
+ try {
1283
+ this.webSocket?.close();
1284
+ this.messageQueue = [];
1285
+ } catch (e) {
1286
+ console.error(e);
1287
+ }
1288
+ }
1256
1289
  async onOpen(event) {
1257
1290
  this.status = WebSocketStatus.Connected;
1258
1291
  this.emit("status", { status: WebSocketStatus.Connected });
@@ -1282,6 +1315,10 @@ var AbracadabraWS = class extends EventEmitter {
1282
1315
  }
1283
1316
  async connect() {
1284
1317
  if (this.status === WebSocketStatus.Connected) return;
1318
+ if (!this.isOnline) {
1319
+ this.shouldConnect = true;
1320
+ return;
1321
+ }
1285
1322
  if (this.cancelWebsocketRetry) {
1286
1323
  this.cancelWebsocketRetry();
1287
1324
  this.cancelWebsocketRetry = void 0;
@@ -1446,7 +1483,7 @@ var AbracadabraWS = class extends EventEmitter {
1446
1483
  const isRateLimited = event?.code === 4429 || event === 4429;
1447
1484
  this.emit("disconnect", { event });
1448
1485
  if (isRateLimited) this.emit("rateLimited");
1449
- if (!this.cancelWebsocketRetry && this.shouldConnect) {
1486
+ if (!this.cancelWebsocketRetry && this.shouldConnect && this.isOnline) {
1450
1487
  const delay = isRateLimited ? 6e4 : this.configuration.delay;
1451
1488
  setTimeout(() => {
1452
1489
  this.connect();
@@ -1456,6 +1493,12 @@ var AbracadabraWS = class extends EventEmitter {
1456
1493
  destroy() {
1457
1494
  this.shouldConnect = false;
1458
1495
  this.emit("destroy");
1496
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
1497
+ if (this.onlineListener) window.removeEventListener("online", this.onlineListener);
1498
+ if (this.offlineListener) window.removeEventListener("offline", this.offlineListener);
1499
+ }
1500
+ this.onlineListener = null;
1501
+ this.offlineListener = null;
1459
1502
  clearInterval(this.intervals.connectionChecker);
1460
1503
  this.stopConnectionAttempt();
1461
1504
  this.disconnect();
@@ -15970,6 +16013,31 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
15970
16013
  function isValidDocId(id) {
15971
16014
  return UUID_RE.test(id);
15972
16015
  }
16016
+ /**
16017
+ * Consecutive transient failures before a doc is promoted from the calm
16018
+ * `retrying` status to a genuine `error`. Keeps a single timeout/network blip
16019
+ * from flashing a red error badge — the doc only "counts" as broken once it
16020
+ * has failed this many passes in a row.
16021
+ */
16022
+ const FAILURE_THRESHOLD = 3;
16023
+ /** Delay before a one-shot follow-up retry of stragglers after `syncAll()`. */
16024
+ const STRAGGLER_RETRY_DELAY_MS = 6e4;
16025
+ /**
16026
+ * Decide whether a sync failure is permanent (the server will never accept
16027
+ * this doc — deleted, forbidden, or an unparseable id) or merely transient
16028
+ * (timeout, dropped socket, momentary 5xx). Permanent failures are parked as
16029
+ * `unavailable` and never retried; transient ones go through `retrying`.
16030
+ *
16031
+ * Best-effort: inspects a numeric `status`/`code` if the thrown value carries
16032
+ * one, otherwise falls back to message keywords. Defaults to transient so we
16033
+ * never give up on a doc we're unsure about.
16034
+ */
16035
+ function isPermanentSyncError(err) {
16036
+ const status = typeof err === "object" && err !== null ? Number(err.status ?? err.statusCode ?? err.code) : NaN;
16037
+ if (status === 403 || status === 404 || status === 410 || status === 422) return true;
16038
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
16039
+ return /\b(403|404|410|422)\b/.test(msg) || msg.includes("forbidden") || msg.includes("not found") || msg.includes("does not exist") || msg.includes("unprocessable");
16040
+ }
15973
16041
  var BackgroundSyncManager = class extends EventEmitter {
15974
16042
  constructor(rootProvider, client, fileBlobStore, opts) {
15975
16043
  super();
@@ -15977,12 +16045,13 @@ var BackgroundSyncManager = class extends EventEmitter {
15977
16045
  this._warnedInvalidIds = /* @__PURE__ */ new Set();
15978
16046
  this._destroyed = false;
15979
16047
  this._initPromise = null;
16048
+ this._stragglerTimer = null;
15980
16049
  this.rootProvider = rootProvider;
15981
16050
  this.client = client;
15982
16051
  this.fileBlobStore = fileBlobStore ?? null;
15983
16052
  this.opts = {
15984
16053
  concurrency: opts?.concurrency ?? 2,
15985
- syncTimeout: opts?.syncTimeout ?? 15e3,
16054
+ syncTimeout: opts?.syncTimeout ?? 3e4,
15986
16055
  prefetchFiles: opts?.prefetchFiles ?? true,
15987
16056
  throttleMs: opts?.throttleMs ?? 200,
15988
16057
  maxRetries: opts?.maxRetries ?? 2
@@ -16051,6 +16120,28 @@ var BackgroundSyncManager = class extends EventEmitter {
16051
16120
  const retryWorkers = Array.from({ length: this.opts.concurrency }, () => retryNext());
16052
16121
  await Promise.all(retryWorkers);
16053
16122
  }
16123
+ if (failed.length > 0) this._scheduleStragglerRetry(failed.slice());
16124
+ }
16125
+ /**
16126
+ * Schedule a single delayed retry of the given docs. Replaces any pending
16127
+ * straggler timer so repeated `syncAll()` calls don't stack timers.
16128
+ */
16129
+ _scheduleStragglerRetry(docIds) {
16130
+ if (this._destroyed || docIds.length === 0) return;
16131
+ if (this._stragglerTimer) clearTimeout(this._stragglerTimer);
16132
+ this._stragglerTimer = setTimeout(() => {
16133
+ this._stragglerTimer = null;
16134
+ if (this._destroyed) return;
16135
+ (async () => {
16136
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
16137
+ for (const docId of docIds) {
16138
+ if (this._destroyed) return;
16139
+ const entry = treeMap.get(docId);
16140
+ const updatedAt = entry?.updatedAt ?? entry?.createdAt ?? 0;
16141
+ await this._syncWithSemaphore(docId, updatedAt);
16142
+ }
16143
+ })();
16144
+ }, STRAGGLER_RETRY_DELAY_MS);
16054
16145
  }
16055
16146
  /** Sync a single document by ID. */
16056
16147
  async syncDoc(docId) {
@@ -16107,6 +16198,10 @@ var BackgroundSyncManager = class extends EventEmitter {
16107
16198
  }
16108
16199
  destroy() {
16109
16200
  this._destroyed = true;
16201
+ if (this._stragglerTimer) {
16202
+ clearTimeout(this._stragglerTimer);
16203
+ this._stragglerTimer = null;
16204
+ }
16110
16205
  this.removeAllListeners();
16111
16206
  }
16112
16207
  /**
@@ -16128,18 +16223,20 @@ var BackgroundSyncManager = class extends EventEmitter {
16128
16223
  console.warn(`[BackgroundSyncManager] skipping non-UUID doc id "${docId}" in doc-tree — server would reject it (422). Likely a stale entry from an earlier seeder; remove it from the tree to silence this.`);
16129
16224
  }
16130
16225
  }
16131
- const items = filtered.map(([docId, v]) => {
16226
+ const items = [];
16227
+ for (const [docId, v] of filtered) {
16132
16228
  const state = this.syncStates.get(docId);
16229
+ if (state?.status === "unavailable") continue;
16133
16230
  const updatedAt = v?.updatedAt ?? v?.createdAt ?? 0;
16134
16231
  let priority;
16135
16232
  if (!state || state.status === "pending") priority = Number.MAX_SAFE_INTEGER - updatedAt;
16136
- else if (state.status === "error") priority = -1;
16233
+ else if (state.status === "error" || state.status === "retrying") priority = -1;
16137
16234
  else priority = updatedAt;
16138
- return {
16235
+ items.push({
16139
16236
  docId,
16140
16237
  priority
16141
- };
16142
- });
16238
+ });
16239
+ }
16143
16240
  items.sort((a, b) => b.priority - a.priority);
16144
16241
  return items.map((i) => i.docId);
16145
16242
  }
@@ -16147,6 +16244,13 @@ var BackgroundSyncManager = class extends EventEmitter {
16147
16244
  async _syncWithSemaphore(docId, updatedAt) {
16148
16245
  if (this._destroyed) return true;
16149
16246
  const existing = this.syncStates.get(docId);
16247
+ if (existing && existing.status === "unavailable") {
16248
+ this.emit("stateChanged", {
16249
+ docId,
16250
+ state: existing
16251
+ });
16252
+ return true;
16253
+ }
16150
16254
  if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced + 200 >= updatedAt) {
16151
16255
  this.emit("stateChanged", {
16152
16256
  docId,
@@ -16163,12 +16267,20 @@ var BackgroundSyncManager = class extends EventEmitter {
16163
16267
  docId,
16164
16268
  state
16165
16269
  });
16166
- return state.status !== "error";
16270
+ return state.status !== "error" && state.status !== "retrying";
16167
16271
  } finally {
16168
16272
  this.semaphore.release();
16169
16273
  }
16170
16274
  }
16171
16275
  async _doSyncDoc(docId) {
16276
+ if (!isValidDocId(docId)) return {
16277
+ docId,
16278
+ status: "unavailable",
16279
+ lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
16280
+ error: "Invalid document id (not a UUID)",
16281
+ consecutiveFailures: 0,
16282
+ isE2E: false
16283
+ };
16172
16284
  const syncing = {
16173
16285
  docId,
16174
16286
  status: "syncing",
@@ -16189,11 +16301,23 @@ var BackgroundSyncManager = class extends EventEmitter {
16189
16301
  else return await this._syncNonE2EDoc(docId);
16190
16302
  } catch (err) {
16191
16303
  const error = err instanceof Error ? err.message : String(err);
16304
+ const prev = this.syncStates.get(docId);
16305
+ const lastSynced = prev?.lastSynced ?? null;
16306
+ if (isPermanentSyncError(err)) return {
16307
+ docId,
16308
+ status: "unavailable",
16309
+ lastSynced,
16310
+ error,
16311
+ consecutiveFailures: 0,
16312
+ isE2E
16313
+ };
16314
+ const consecutiveFailures = (prev?.consecutiveFailures ?? 0) + 1;
16192
16315
  return {
16193
16316
  docId,
16194
- status: "error",
16195
- lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
16317
+ status: consecutiveFailures >= FAILURE_THRESHOLD ? "error" : "retrying",
16318
+ lastSynced,
16196
16319
  error,
16320
+ consecutiveFailures,
16197
16321
  isE2E
16198
16322
  };
16199
16323
  }
@@ -16206,6 +16330,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16206
16330
  docId,
16207
16331
  status: "synced",
16208
16332
  lastSynced: Date.now(),
16333
+ consecutiveFailures: 0,
16209
16334
  isE2E: false
16210
16335
  };
16211
16336
  }
@@ -16229,6 +16354,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16229
16354
  docId,
16230
16355
  status: "synced",
16231
16356
  lastSynced: Math.max(Date.now(), treeUpdatedAt),
16357
+ consecutiveFailures: 0,
16232
16358
  isE2E: false
16233
16359
  };
16234
16360
  } finally {
@@ -16243,6 +16369,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16243
16369
  docId,
16244
16370
  status: "skipped",
16245
16371
  lastSynced: null,
16372
+ consecutiveFailures: 0,
16246
16373
  isE2E: true
16247
16374
  };
16248
16375
  const childDoc = new yjs.Doc({ guid: docId });
@@ -16272,6 +16399,7 @@ var BackgroundSyncManager = class extends EventEmitter {
16272
16399
  docId,
16273
16400
  status: "synced",
16274
16401
  lastSynced: Math.max(Date.now(), treeUpdatedAt),
16402
+ consecutiveFailures: 0,
16275
16403
  isE2E: true
16276
16404
  };
16277
16405
  } finally {