cross-tab-worker-databus 0.11.0 → 0.20.7

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/dist/centrifuge.js +1 -1
  3. package/dist/centrifuge.shared.worker.js +4 -2
  4. package/dist/centrifuge.shared.worker.js.map +2 -2
  5. package/dist/centrifuge.worker.js +4 -2
  6. package/dist/centrifuge.worker.js.map +2 -2
  7. package/dist/{chunk-NX76TAV3.js → chunk-LPS4XOK4.js} +152 -20
  8. package/dist/{chunk-NX76TAV3.js.map → chunk-LPS4XOK4.js.map} +2 -2
  9. package/dist/cjs/centrifuge.cjs +151 -19
  10. package/dist/cjs/centrifuge.cjs.map +2 -2
  11. package/dist/cjs/hooks.cjs +4 -1
  12. package/dist/cjs/hooks.cjs.map +2 -2
  13. package/dist/cjs/index.cjs +301 -68
  14. package/dist/cjs/index.cjs.map +2 -2
  15. package/dist/cjs/vue.cjs +7 -1
  16. package/dist/cjs/vue.cjs.map +2 -2
  17. package/dist/core/data-bus.d.ts +32 -0
  18. package/dist/core/data-bus.d.ts.map +1 -1
  19. package/dist/core/publication.d.ts.map +1 -1
  20. package/dist/core/replay-persistence.d.ts.map +1 -1
  21. package/dist/core/trace.d.ts +4 -1
  22. package/dist/core/trace.d.ts.map +1 -1
  23. package/dist/hooks.d.ts.map +1 -1
  24. package/dist/hooks.js +4 -1
  25. package/dist/hooks.js.map +2 -2
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +151 -50
  29. package/dist/index.js.map +2 -2
  30. package/dist/vue.d.ts.map +1 -1
  31. package/dist/vue.js +7 -1
  32. package/dist/vue.js.map +2 -2
  33. package/dist/websocket.d.ts.map +1 -1
  34. package/docs/README.md +1 -0
  35. package/docs/api.md +10 -3
  36. package/docs/configuration.md +21 -1
  37. package/docs/release-checklist.md +22 -0
  38. package/docs/roadmap.md +136 -3
  39. package/docs/zh/README.md +1 -0
  40. package/docs/zh/api.md +10 -3
  41. package/docs/zh/configuration.md +21 -1
  42. package/docs/zh/release-checklist.md +22 -0
  43. package/docs/zh/roadmap.md +137 -2
  44. package/package.json +3 -1
@@ -28,13 +28,16 @@ module.exports = __toCommonJS(hooks_exports);
28
28
  var import_react = require("react");
29
29
  function useCrossTabDataBus(create, deps = []) {
30
30
  const [bus, setBus] = (0, import_react.useState)(null);
31
+ const lifecycleGeneration = (0, import_react.useRef)(0);
31
32
  (0, import_react.useEffect)(() => {
33
+ const generation = ++lifecycleGeneration.current;
32
34
  const instance = create();
35
+ if (generation !== lifecycleGeneration.current) return;
33
36
  setBus(instance);
34
37
  void instance.ready().catch(() => {
35
38
  });
36
39
  return () => {
37
- setBus(null);
40
+ if (generation === lifecycleGeneration.current) setBus(null);
38
41
  void instance.stop();
39
42
  };
40
43
  }, deps);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/hooks.ts"],
4
- "sourcesContent": ["/**\n * React hooks adapter for cross-tab-worker-databus.\n *\n * A thin, transport-agnostic bridge between the imperative CrossTabDataBus\n * API and React component lifecycles. React is an optional peer dependency \u2014\n * this module is a separate entry point so consumers who don't use React\n * never load it.\n *\n * - `useCrossTabDataBus` owns the bus lifecycle: created on mount, stopped on\n * unmount. It is StrictMode-safe: the double-invoked effect exercises the\n * same stop/recreate path as BFCache suspend/resume.\n * - `useCrossTabSubscription` attaches a message handler with automatic\n * cleanup; the handler is read through a ref, so you can pass inline\n * closures without resubscribing on every render.\n * - `useCrossTabStatus` mirrors `bus.onStatus()` into React state.\n */\nimport { useEffect, useRef, useState } from 'react';\nimport type { DependencyList } from 'react';\nimport type { CrossTabDataBus } from './core/data-bus';\nimport type { DataBusMessage, WorkerStatus } from './core/types';\n\n/**\n * Create a CrossTabDataBus for the component's lifetime.\n *\n * @param create Factory invoked once per effect run. Return a fresh bus \u2014\n * do not share a bus instance between effects, or StrictMode's\n * mount \u2192 stop \u2192 mount cycle will stop the shared instance out from\n * under the second mount.\n * @param deps Re-create the bus when these change (default: create once).\n * @returns The active bus, or `null` before the first effect has run (SSR\n * and the initial render).\n */\nexport function useCrossTabDataBus<TConfig, TData>(\n create: () => CrossTabDataBus<TConfig, TData>,\n deps: DependencyList = []\n): CrossTabDataBus<TConfig, TData> | null {\n const [bus, setBus] = useState<CrossTabDataBus<TConfig, TData> | null>(null);\n useEffect(() => {\n const instance = create();\n setBus(instance);\n void instance.ready().catch(() => {});\n return () => {\n setBus(null);\n void instance.stop();\n };\n // The factory is intentionally not a dependency: callers pass an inline\n // closure and key recreation through `deps` instead.\n }, deps);\n return bus;\n}\n\n/**\n * Subscribe to `topic` for the component's lifetime. The handler is read\n * through a ref on each delivery, so inline closures are safe without\n * unsubscribing/resubscribing on re-renders.\n *\n * When `bus` is null (not yet created) the subscription is queued until the\n * bus appears \u2014 the bus itself queues it until the transport is ready.\n */\nexport function useCrossTabSubscription<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null,\n topic: string,\n handler: (message: DataBusMessage<TData>) => void\n): void {\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n if (!bus) return;\n return bus.subscribe(topic, message => handlerRef.current(message));\n }, [bus, topic]);\n}\n\n/**\n * Mirror the bus connection status into React state. Reports the live value\n * via `onStatus` and reads the current value synchronously whenever `bus`\n * changes identity.\n */\nexport function useCrossTabStatus<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null\n): WorkerStatus {\n const [status, setStatus] = useState<WorkerStatus>('connecting');\n useEffect(() => {\n if (!bus) {\n setStatus('connecting');\n return;\n }\n setStatus(bus.getStatus());\n return bus.onStatus(setStatus);\n }, [bus]);\n return status;\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBA,mBAA4C;AAgBrC,SAAS,mBACd,QACA,OAAuB,CAAC,GACgB;AACxC,QAAM,CAAC,KAAK,MAAM,QAAI,uBAAiD,IAAI;AAC3E,8BAAU,MAAM;AACd,UAAM,WAAW,OAAO;AACxB,WAAO,QAAQ;AACf,SAAK,SAAS,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACpC,WAAO,MAAM;AACX,aAAO,IAAI;AACX,WAAK,SAAS,KAAK;AAAA,IACrB;AAAA,EAGF,GAAG,IAAI;AACP,SAAO;AACT;AAUO,SAAS,wBACd,KACA,OACA,SACM;AACN,QAAM,iBAAa,qBAAO,OAAO;AACjC,aAAW,UAAU;AACrB,8BAAU,MAAM;AACd,QAAI,CAAC,IAAK;AACV,WAAO,IAAI,UAAU,OAAO,aAAW,WAAW,QAAQ,OAAO,CAAC;AAAA,EACpE,GAAG,CAAC,KAAK,KAAK,CAAC;AACjB;AAOO,SAAS,kBACd,KACc;AACd,QAAM,CAAC,QAAQ,SAAS,QAAI,uBAAuB,YAAY;AAC/D,8BAAU,MAAM;AACd,QAAI,CAAC,KAAK;AACR,gBAAU,YAAY;AACtB;AAAA,IACF;AACA,cAAU,IAAI,UAAU,CAAC;AACzB,WAAO,IAAI,SAAS,SAAS;AAAA,EAC/B,GAAG,CAAC,GAAG,CAAC;AACR,SAAO;AACT;",
4
+ "sourcesContent": ["/**\n * React hooks adapter for cross-tab-worker-databus.\n *\n * A thin, transport-agnostic bridge between the imperative CrossTabDataBus\n * API and React component lifecycles. React is an optional peer dependency \u2014\n * this module is a separate entry point so consumers who don't use React\n * never load it.\n *\n * - `useCrossTabDataBus` owns the bus lifecycle: created on mount, stopped on\n * unmount. It is StrictMode-safe: the double-invoked effect exercises the\n * same stop/recreate path as BFCache suspend/resume.\n * - `useCrossTabSubscription` attaches a message handler with automatic\n * cleanup; the handler is read through a ref, so you can pass inline\n * closures without resubscribing on every render.\n * - `useCrossTabStatus` mirrors `bus.onStatus()` into React state.\n */\nimport { useEffect, useRef, useState } from 'react';\nimport type { DependencyList } from 'react';\nimport type { CrossTabDataBus } from './core/data-bus';\nimport type { DataBusMessage, WorkerStatus } from './core/types';\n\n/**\n * Create a CrossTabDataBus for the component's lifetime.\n *\n * @param create Factory invoked once per effect run. Return a fresh bus \u2014\n * do not share a bus instance between effects, or StrictMode's\n * mount \u2192 stop \u2192 mount cycle will stop the shared instance out from\n * under the second mount.\n * @param deps Re-create the bus when these change (default: create once).\n * @returns The active bus, or `null` before the first effect has run (SSR\n * and the initial render).\n */\nexport function useCrossTabDataBus<TConfig, TData>(\n create: () => CrossTabDataBus<TConfig, TData>,\n deps: DependencyList = []\n): CrossTabDataBus<TConfig, TData> | null {\n const [bus, setBus] = useState<CrossTabDataBus<TConfig, TData> | null>(null);\n const lifecycleGeneration = useRef(0);\n useEffect(() => {\n const generation = ++lifecycleGeneration.current;\n const instance = create();\n if (generation !== lifecycleGeneration.current) return;\n setBus(instance);\n void instance.ready().catch(() => {});\n return () => {\n if (generation === lifecycleGeneration.current) setBus(null);\n void instance.stop();\n };\n // The factory is intentionally not a dependency: callers pass an inline\n // closure and key recreation through `deps` instead.\n }, deps);\n return bus;\n}\n\n/**\n * Subscribe to `topic` for the component's lifetime. The handler is read\n * through a ref on each delivery, so inline closures are safe without\n * unsubscribing/resubscribing on re-renders.\n *\n * When `bus` is null (not yet created) the subscription is queued until the\n * bus appears \u2014 the bus itself queues it until the transport is ready.\n */\nexport function useCrossTabSubscription<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null,\n topic: string,\n handler: (message: DataBusMessage<TData>) => void\n): void {\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n if (!bus) return;\n return bus.subscribe(topic, message => handlerRef.current(message));\n }, [bus, topic]);\n}\n\n/**\n * Mirror the bus connection status into React state. Reports the live value\n * via `onStatus` and reads the current value synchronously whenever `bus`\n * changes identity.\n */\nexport function useCrossTabStatus<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null\n): WorkerStatus {\n const [status, setStatus] = useState<WorkerStatus>('connecting');\n useEffect(() => {\n if (!bus) {\n setStatus('connecting');\n return;\n }\n setStatus(bus.getStatus());\n return bus.onStatus(setStatus);\n }, [bus]);\n return status;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBA,mBAA4C;AAgBrC,SAAS,mBACd,QACA,OAAuB,CAAC,GACgB;AACxC,QAAM,CAAC,KAAK,MAAM,QAAI,uBAAiD,IAAI;AAC3E,QAAM,0BAAsB,qBAAO,CAAC;AACpC,8BAAU,MAAM;AACd,UAAM,aAAa,EAAE,oBAAoB;AACzC,UAAM,WAAW,OAAO;AACxB,QAAI,eAAe,oBAAoB,QAAS;AAChD,WAAO,QAAQ;AACf,SAAK,SAAS,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACpC,WAAO,MAAM;AACX,UAAI,eAAe,oBAAoB,QAAS,QAAO,IAAI;AAC3D,WAAK,SAAS,KAAK;AAAA,IACrB;AAAA,EAGF,GAAG,IAAI;AACP,SAAO;AACT;AAUO,SAAS,wBACd,KACA,OACA,SACM;AACN,QAAM,iBAAa,qBAAO,OAAO;AACjC,aAAW,UAAU;AACrB,8BAAU,MAAM;AACd,QAAI,CAAC,IAAK;AACV,WAAO,IAAI,UAAU,OAAO,aAAW,WAAW,QAAQ,OAAO,CAAC;AAAA,EACpE,GAAG,CAAC,KAAK,KAAK,CAAC;AACjB;AAOO,SAAS,kBACd,KACc;AACd,QAAM,CAAC,QAAQ,SAAS,QAAI,uBAAuB,YAAY;AAC/D,8BAAU,MAAM;AACd,QAAI,CAAC,KAAK;AACR,gBAAU,YAAY;AACtB;AAAA,IACF;AACA,cAAU,IAAI,UAAU,CAAC;AACzB,WAAO,IAAI,SAAS,SAAS;AAAA,EAC/B,GAAG,CAAC,GAAG,CAAC;AACR,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -1093,7 +1093,7 @@ var DataBusTraceReporter = class {
1093
1093
  latencySumMs = 0;
1094
1094
  dedupAccepted = 0;
1095
1095
  dedupSuppressed = 0;
1096
- constructor(options, now = Date.now) {
1096
+ constructor(options, now = options?.now ?? Date.now) {
1097
1097
  this.enabled = options?.enabled ?? false;
1098
1098
  this.mode = options?.mode ?? "all";
1099
1099
  this.metricsIntervalMs = normalizeInterval(options?.metricsIntervalMs);
@@ -1255,6 +1255,12 @@ function roundMs(value) {
1255
1255
  // src/core/data-bus.ts
1256
1256
  var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1257
1257
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1258
+ var PersistenceRetryCancelledError = class extends Error {
1259
+ constructor() {
1260
+ super("Persistence retry cancelled by lifecycle transition.");
1261
+ this.name = "PersistenceRetryCancelledError";
1262
+ }
1263
+ };
1258
1264
  var CrossTabDataBus = class _CrossTabDataBus {
1259
1265
  transport;
1260
1266
  cluster;
@@ -1271,13 +1277,25 @@ var CrossTabDataBus = class _CrossTabDataBus {
1271
1277
  replayMaxPerTopic;
1272
1278
  replayPersistence;
1273
1279
  replayRetentionMs;
1280
+ replayRetentionSweepMs;
1281
+ persistenceRetryMaxAttempts;
1282
+ persistenceRetryBackoffMs;
1283
+ persistenceRetryGeneration = 0;
1274
1284
  replayHydration;
1285
+ // Retention cleanup is coalesced so a burst of publications does not issue
1286
+ // one IndexedDB read/write transaction per message. The newest cutoff wins.
1287
+ replayRetentionCleanup = null;
1288
+ replayRetentionCutoff = null;
1289
+ replayRetentionTimer = null;
1275
1290
  initialConfig;
1276
1291
  hasInitialConfig;
1277
1292
  trace;
1278
1293
  dedupMaxEntries;
1279
1294
  dedupTtlMs;
1295
+ dedupSweepMs;
1296
+ dedupSweepTimer = null;
1280
1297
  dedupEnabled;
1298
+ now;
1281
1299
  seenMessageIds = /* @__PURE__ */ new Map();
1282
1300
  dedupSuppressed = 0;
1283
1301
  dedupAccepted = 0;
@@ -1322,14 +1340,28 @@ var CrossTabDataBus = class _CrossTabDataBus {
1322
1340
  if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1323
1341
  throw new TypeError("replay.retentionMs must be a positive finite number.");
1324
1342
  }
1325
- this.replayHydration = this.hydrateReplay();
1343
+ this.replayRetentionSweepMs = replay?.retentionSweepMs;
1344
+ if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1345
+ throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1346
+ }
1347
+ this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1348
+ this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1349
+ if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1350
+ throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
1351
+ }
1352
+ if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1353
+ throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
1354
+ }
1326
1355
  const { autoStart, initialConfig, trace, transport, dedup, ...clusterOptions } = options;
1356
+ this.now = dedup?.now ?? Date.now;
1357
+ this.replayHydration = this.hydrateReplay();
1327
1358
  this.transport = transport;
1328
1359
  this.initialConfig = initialConfig;
1329
1360
  this.hasInitialConfig = "initialConfig" in options;
1330
1361
  this.trace = new DataBusTraceReporter(trace);
1331
1362
  this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1332
1363
  this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1364
+ this.dedupSweepMs = dedup?.sweepMs;
1333
1365
  this.dedupEnabled = dedup !== void 0;
1334
1366
  if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1335
1367
  throw new TypeError("dedup.maxEntries must be a positive safe integer.");
@@ -1337,6 +1369,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1337
1369
  if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1338
1370
  throw new TypeError("dedup.ttlMs must be a positive finite number.");
1339
1371
  }
1372
+ if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1373
+ throw new TypeError("dedup.sweepMs must be a positive finite number.");
1374
+ }
1340
1375
  this.cluster = new WorkerClusterRuntime({
1341
1376
  ...clusterOptions,
1342
1377
  handlers: {
@@ -1377,11 +1412,16 @@ var CrossTabDataBus = class _CrossTabDataBus {
1377
1412
  onSuspend: () => {
1378
1413
  if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
1379
1414
  this.trace.pause();
1415
+ this.persistenceRetryGeneration += 1;
1416
+ this.stopDedupSweep();
1417
+ this.stopReplayRetentionSweep();
1380
1418
  this.suspendTransport();
1381
1419
  },
1382
1420
  onResume: () => {
1383
1421
  this.trace.event({ type: "lifecycle", action: "resume" });
1384
1422
  this.trace.start();
1423
+ this.startDedupSweep();
1424
+ this.startReplayRetentionSweep();
1385
1425
  this.resumeTransport();
1386
1426
  },
1387
1427
  onDiagnostic: (event) => {
@@ -1409,6 +1449,8 @@ var CrossTabDataBus = class _CrossTabDataBus {
1409
1449
  this.lastError = null;
1410
1450
  this.trace.event({ type: "lifecycle", action: "start" });
1411
1451
  this.trace.start();
1452
+ this.startDedupSweep();
1453
+ this.startReplayRetentionSweep();
1412
1454
  this.updateStatus("connecting");
1413
1455
  this.cluster.start();
1414
1456
  const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
@@ -1533,7 +1575,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1533
1575
  this.topicHandlers.delete(topic);
1534
1576
  this.replayBuffers?.delete(topic);
1535
1577
  if (this.replayPersistence?.clearTopic) {
1536
- void this.replayPersistence.clearTopic(topic).catch((error) => this.reportError(error));
1578
+ void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1537
1579
  }
1538
1580
  this.cluster.unsubscribe(topic);
1539
1581
  }
@@ -1542,9 +1584,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1542
1584
  this.replayBuffers?.clear();
1543
1585
  if (this.replayPersistence?.clear) {
1544
1586
  try {
1545
- await this.replayPersistence.clear();
1587
+ await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1546
1588
  } catch (error) {
1547
- this.reportError(error);
1589
+ this.reportPersistenceError(error);
1548
1590
  throw error;
1549
1591
  }
1550
1592
  }
@@ -1554,9 +1596,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1554
1596
  this.replayBuffers?.delete(topic);
1555
1597
  if (this.replayPersistence?.clearTopic) {
1556
1598
  try {
1557
- await this.replayPersistence.clearTopic(topic);
1599
+ await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1558
1600
  } catch (error) {
1559
- this.reportError(error);
1601
+ this.reportPersistenceError(error);
1560
1602
  throw error;
1561
1603
  }
1562
1604
  }
@@ -1566,12 +1608,19 @@ var CrossTabDataBus = class _CrossTabDataBus {
1566
1608
  if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1567
1609
  if (this.replayBuffers) {
1568
1610
  for (const [topic, messages] of this.replayBuffers) {
1569
- const kept = messages.filter((message) => (message.timestamp ?? 0) >= timestamp);
1611
+ const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1570
1612
  if (kept.length) this.replayBuffers.set(topic, kept);
1571
1613
  else this.replayBuffers.delete(topic);
1572
1614
  }
1573
1615
  }
1574
- if (this.replayPersistence?.clearBefore) await this.replayPersistence.clearBefore(timestamp);
1616
+ if (this.replayPersistence?.clearBefore) {
1617
+ try {
1618
+ await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1619
+ } catch (error) {
1620
+ this.reportPersistenceError(error);
1621
+ throw error;
1622
+ }
1623
+ }
1575
1624
  }
1576
1625
  /** Return bounded deduplication counters for diagnostics and health checks. */
1577
1626
  getDedupStats() {
@@ -1629,8 +1678,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1629
1678
  async stop() {
1630
1679
  if (!this.started) return;
1631
1680
  this.stopping = true;
1681
+ this.persistenceRetryGeneration += 1;
1632
1682
  this.trace.event({ type: "lifecycle", action: "stop" });
1633
1683
  this.trace.stop();
1684
+ this.stopDedupSweep();
1685
+ this.stopReplayRetentionSweep();
1634
1686
  this.topicHandlers.clear();
1635
1687
  this.replayBuffers?.clear();
1636
1688
  this.cluster.stop();
@@ -1641,7 +1693,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1641
1693
  else await this.transport.stop();
1642
1694
  } finally {
1643
1695
  this.transportSubscribedTopics.clear();
1644
- this.seenMessageIds.clear();
1696
+ this.resetDedup();
1645
1697
  this.started = false;
1646
1698
  this.stopping = false;
1647
1699
  this.suspended = false;
@@ -1674,7 +1726,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1674
1726
  }
1675
1727
  isDuplicate(message) {
1676
1728
  if (!this.dedupEnabled || !message.messageId) return false;
1677
- const now = Date.now();
1729
+ const now = this.now();
1678
1730
  for (const [id, timestamp] of this.seenMessageIds) {
1679
1731
  if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
1680
1732
  }
@@ -1694,6 +1746,20 @@ var CrossTabDataBus = class _CrossTabDataBus {
1694
1746
  }
1695
1747
  return false;
1696
1748
  }
1749
+ startDedupSweep() {
1750
+ if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
1751
+ this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
1752
+ }
1753
+ stopDedupSweep() {
1754
+ if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
1755
+ this.dedupSweepTimer = null;
1756
+ }
1757
+ pruneExpiredDedup() {
1758
+ const cutoff = this.now() - this.dedupTtlMs;
1759
+ for (const [id, timestamp] of this.seenMessageIds) {
1760
+ if (timestamp < cutoff) this.seenMessageIds.delete(id);
1761
+ }
1762
+ }
1697
1763
  /** Deliver a message to every local handler registered for its topic,
1698
1764
  * plus every handler registered with a wildcard subscription that matches
1699
1765
  * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
@@ -1720,9 +1786,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1720
1786
  buffer.push(storedMessage);
1721
1787
  if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1722
1788
  if (this.replayPersistence) {
1723
- void this.replayPersistence.append(storedMessage).catch((error) => this.reportError(error));
1789
+ void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
1724
1790
  if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1725
- void this.replayPersistence.clearBefore(Date.now() - this.replayRetentionMs).catch((error) => this.reportError(error));
1791
+ this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
1726
1792
  }
1727
1793
  }
1728
1794
  }
@@ -1732,9 +1798,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1732
1798
  }
1733
1799
  try {
1734
1800
  if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1735
- await this.replayPersistence.clearBefore(Date.now() - this.replayRetentionMs);
1801
+ await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
1736
1802
  }
1737
- for (const message of await this.replayPersistence.load()) {
1803
+ for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
1738
1804
  let buffer = this.replayBuffers.get(message.topic);
1739
1805
  if (!buffer) {
1740
1806
  buffer = [];
@@ -1744,7 +1810,66 @@ var CrossTabDataBus = class _CrossTabDataBus {
1744
1810
  if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1745
1811
  }
1746
1812
  } catch (error) {
1747
- this.reportError(error);
1813
+ this.reportPersistenceError(error);
1814
+ }
1815
+ }
1816
+ scheduleReplayRetentionCleanup(cutoff) {
1817
+ if (!this.replayPersistence?.clearBefore) return;
1818
+ if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
1819
+ this.replayRetentionCutoff = cutoff;
1820
+ }
1821
+ if (this.replayRetentionCleanup) return;
1822
+ this.replayRetentionCleanup = (async () => {
1823
+ while (this.replayRetentionCutoff !== null) {
1824
+ const nextCutoff = this.replayRetentionCutoff;
1825
+ this.replayRetentionCutoff = null;
1826
+ try {
1827
+ await this.replayPersistence.clearBefore(nextCutoff);
1828
+ } catch (error) {
1829
+ this.reportPersistenceError(error);
1830
+ }
1831
+ }
1832
+ })().finally(() => {
1833
+ this.replayRetentionCleanup = null;
1834
+ if (this.replayRetentionCutoff !== null) {
1835
+ this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
1836
+ }
1837
+ });
1838
+ }
1839
+ startReplayRetentionSweep() {
1840
+ if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
1841
+ this.replayRetentionTimer = setInterval(() => {
1842
+ this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
1843
+ }, this.replayRetentionSweepMs);
1844
+ }
1845
+ stopReplayRetentionSweep() {
1846
+ if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
1847
+ this.replayRetentionTimer = null;
1848
+ }
1849
+ async withPersistenceRetry(persistenceOperation, operation) {
1850
+ const generation = this.persistenceRetryGeneration;
1851
+ let attempt = 0;
1852
+ let delay = this.persistenceRetryBackoffMs;
1853
+ while (true) {
1854
+ attempt += 1;
1855
+ try {
1856
+ if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
1857
+ return await operation();
1858
+ } catch (error) {
1859
+ if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
1860
+ throw new PersistenceRetryCancelledError();
1861
+ }
1862
+ if (attempt >= this.persistenceRetryMaxAttempts) throw error;
1863
+ this.trace.event({
1864
+ type: "reliability",
1865
+ operation: "persistence_retry",
1866
+ persistenceOperation,
1867
+ attempt
1868
+ });
1869
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
1870
+ if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
1871
+ delay = Math.min(delay * 2, 1600);
1872
+ }
1748
1873
  }
1749
1874
  }
1750
1875
  /** Deliver buffered history to a newly-registered handler. For an exact
@@ -1782,7 +1907,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1782
1907
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
1783
1908
  }
1784
1909
  if (status === "error" && this.started && !this.stopping) {
1785
- const now = Date.now();
1910
+ const now = this.now();
1786
1911
  if (now - this.lastRecoveryAt >= _CrossTabDataBus.RECOVERY_COOLDOWN_MS) {
1787
1912
  this.lastRecoveryAt = now;
1788
1913
  this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: 1 });
@@ -1799,6 +1924,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1799
1924
  this.trace.event({ type: "error", source: "transport" });
1800
1925
  this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
1801
1926
  }
1927
+ reportPersistenceError(error) {
1928
+ if (error instanceof PersistenceRetryCancelledError) return;
1929
+ this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
1930
+ this.reportError(error);
1931
+ }
1802
1932
  traceSubscription(action, topic) {
1803
1933
  this.trace.event({
1804
1934
  type: "subscription",
@@ -1952,74 +2082,165 @@ function createIndexedDbReplayPersistence(options) {
1952
2082
  throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
1953
2083
  }
1954
2084
  let dbPromise = null;
2085
+ const invalidate = (db) => {
2086
+ if (dbPromise) {
2087
+ void dbPromise.then((current) => {
2088
+ if (current === db) {
2089
+ current.close();
2090
+ dbPromise = null;
2091
+ }
2092
+ }, () => void 0);
2093
+ }
2094
+ };
2095
+ let mutationQueue = Promise.resolve();
2096
+ const serializeMutation = (mutation) => {
2097
+ const next = mutationQueue.then(mutation, mutation);
2098
+ mutationQueue = next.catch(() => void 0);
2099
+ return next;
2100
+ };
1955
2101
  const open = () => {
1956
2102
  if (dbPromise) return dbPromise;
1957
- dbPromise = new Promise((resolve, reject) => {
2103
+ const pending = new Promise((resolve, reject) => {
1958
2104
  const request = indexedDb.open(dbName, 1);
1959
2105
  request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
1960
- request.onsuccess = () => resolve(request.result);
2106
+ request.onsuccess = () => {
2107
+ const db = request.result;
2108
+ db.onversionchange = () => {
2109
+ db.close();
2110
+ if (dbPromise) dbPromise = null;
2111
+ };
2112
+ resolve(db);
2113
+ };
1961
2114
  request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
1962
2115
  });
1963
- return dbPromise;
2116
+ dbPromise = pending;
2117
+ void pending.catch(() => {
2118
+ if (dbPromise === pending) dbPromise = null;
2119
+ });
2120
+ return pending;
1964
2121
  };
1965
2122
  return {
1966
2123
  async load() {
1967
2124
  const db = await open();
1968
2125
  return new Promise((resolve, reject) => {
1969
- const request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
2126
+ let request;
2127
+ try {
2128
+ request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
2129
+ } catch (error) {
2130
+ invalidate(db);
2131
+ reject(error);
2132
+ return;
2133
+ }
1970
2134
  request.onsuccess = () => resolve(request.result.flatMap((record) => record.messages));
1971
- request.onerror = () => reject(request.error ?? new Error("Failed to load replay history."));
2135
+ request.onerror = () => {
2136
+ invalidate(db);
2137
+ reject(request.error ?? new Error("Failed to load replay history."));
2138
+ };
1972
2139
  });
1973
2140
  },
1974
- async append(message) {
1975
- const db = await open();
1976
- await new Promise((resolve, reject) => {
1977
- const transaction = db.transaction(storeName, "readwrite");
1978
- const store = transaction.objectStore(storeName);
1979
- const request = store.get(message.topic);
1980
- request.onsuccess = () => {
1981
- const messages = (request.result?.messages ?? []).concat(message).slice(-maxPerTopic);
1982
- store.put({ topic: message.topic, messages });
1983
- };
1984
- request.onerror = () => reject(request.error ?? new Error("Failed to read replay history."));
1985
- transaction.oncomplete = () => resolve();
1986
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to persist replay history."));
2141
+ append(message) {
2142
+ return serializeMutation(async () => {
2143
+ const db = await open();
2144
+ await new Promise((resolve, reject) => {
2145
+ let transaction;
2146
+ try {
2147
+ transaction = db.transaction(storeName, "readwrite");
2148
+ } catch (error) {
2149
+ invalidate(db);
2150
+ reject(error);
2151
+ return;
2152
+ }
2153
+ const store = transaction.objectStore(storeName);
2154
+ const request = store.get(message.topic);
2155
+ request.onsuccess = () => {
2156
+ const messages = (request.result?.messages ?? []).concat(message).slice(-maxPerTopic);
2157
+ store.put({ topic: message.topic, messages });
2158
+ };
2159
+ request.onerror = () => {
2160
+ invalidate(db);
2161
+ reject(request.error ?? new Error("Failed to read replay history."));
2162
+ };
2163
+ transaction.oncomplete = () => resolve();
2164
+ transaction.onerror = () => {
2165
+ invalidate(db);
2166
+ reject(transaction.error ?? new Error("Failed to persist replay history."));
2167
+ };
2168
+ });
1987
2169
  });
1988
2170
  },
1989
- async clear() {
1990
- const db = await open();
1991
- await new Promise((resolve, reject) => {
1992
- const transaction = db.transaction(storeName, "readwrite");
1993
- transaction.objectStore(storeName).clear();
1994
- transaction.oncomplete = () => resolve();
1995
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to clear replay history."));
2171
+ clear() {
2172
+ return serializeMutation(async () => {
2173
+ const db = await open();
2174
+ await new Promise((resolve, reject) => {
2175
+ let transaction;
2176
+ try {
2177
+ transaction = db.transaction(storeName, "readwrite");
2178
+ } catch (error) {
2179
+ invalidate(db);
2180
+ reject(error);
2181
+ return;
2182
+ }
2183
+ transaction.objectStore(storeName).clear();
2184
+ transaction.oncomplete = () => resolve();
2185
+ transaction.onerror = () => {
2186
+ invalidate(db);
2187
+ reject(transaction.error ?? new Error("Failed to clear replay history."));
2188
+ };
2189
+ });
1996
2190
  });
1997
2191
  },
1998
- async clearTopic(topic) {
1999
- const db = await open();
2000
- await new Promise((resolve, reject) => {
2001
- const transaction = db.transaction(storeName, "readwrite");
2002
- transaction.objectStore(storeName).delete(topic);
2003
- transaction.oncomplete = () => resolve();
2004
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2192
+ clearTopic(topic) {
2193
+ return serializeMutation(async () => {
2194
+ const db = await open();
2195
+ await new Promise((resolve, reject) => {
2196
+ let transaction;
2197
+ try {
2198
+ transaction = db.transaction(storeName, "readwrite");
2199
+ } catch (error) {
2200
+ invalidate(db);
2201
+ reject(error);
2202
+ return;
2203
+ }
2204
+ transaction.objectStore(storeName).delete(topic);
2205
+ transaction.oncomplete = () => resolve();
2206
+ transaction.onerror = () => {
2207
+ invalidate(db);
2208
+ reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2209
+ };
2210
+ });
2005
2211
  });
2006
2212
  },
2007
- async clearBefore(timestamp) {
2008
- const db = await open();
2009
- await new Promise((resolve, reject) => {
2010
- const transaction = db.transaction(storeName, "readwrite");
2011
- const store = transaction.objectStore(storeName);
2012
- const request = store.getAll();
2013
- request.onsuccess = () => {
2014
- for (const record of request.result) {
2015
- const messages = record.messages.filter((message) => (message.timestamp ?? 0) >= timestamp);
2016
- if (messages.length === 0) store.delete(record.topic);
2017
- else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
2213
+ clearBefore(timestamp) {
2214
+ return serializeMutation(async () => {
2215
+ const db = await open();
2216
+ await new Promise((resolve, reject) => {
2217
+ let transaction;
2218
+ try {
2219
+ transaction = db.transaction(storeName, "readwrite");
2220
+ } catch (error) {
2221
+ invalidate(db);
2222
+ reject(error);
2223
+ return;
2018
2224
  }
2019
- };
2020
- request.onerror = () => reject(request.error ?? new Error("Failed to read replay history."));
2021
- transaction.oncomplete = () => resolve();
2022
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to prune replay history."));
2225
+ const store = transaction.objectStore(storeName);
2226
+ const request = store.getAll();
2227
+ request.onsuccess = () => {
2228
+ for (const record of request.result) {
2229
+ const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2230
+ if (messages.length === 0) store.delete(record.topic);
2231
+ else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
2232
+ }
2233
+ };
2234
+ request.onerror = () => {
2235
+ invalidate(db);
2236
+ reject(request.error ?? new Error("Failed to read replay history."));
2237
+ };
2238
+ transaction.oncomplete = () => resolve();
2239
+ transaction.onerror = () => {
2240
+ invalidate(db);
2241
+ reject(transaction.error ?? new Error("Failed to prune replay history."));
2242
+ };
2243
+ });
2023
2244
  });
2024
2245
  }
2025
2246
  };
@@ -2037,11 +2258,13 @@ function parseDataBusPublication(value, fallbackTopic) {
2037
2258
  if (!topic) return null;
2038
2259
  const hasMetadataEnvelope = fallbackTopic !== void 0 && Object.prototype.hasOwnProperty.call(publication, "data") && (typeof publication.messageId === "string" || typeof publication.timestamp === "number");
2039
2260
  const data = nested || fallbackTopic === void 0 || hasMetadataEnvelope ? publication.data : value;
2261
+ const messageId = typeof publication.messageId === "string" && publication.messageId.length > 0 ? publication.messageId : void 0;
2262
+ const timestamp = typeof publication.timestamp === "number" && Number.isFinite(publication.timestamp) ? publication.timestamp : void 0;
2040
2263
  return {
2041
2264
  topic,
2042
2265
  data,
2043
- ...typeof publication.messageId === "string" ? { messageId: publication.messageId } : {},
2044
- ...typeof publication.timestamp === "number" ? { timestamp: publication.timestamp } : {}
2266
+ ...messageId === void 0 ? {} : { messageId },
2267
+ ...timestamp === void 0 ? {} : { timestamp }
2045
2268
  };
2046
2269
  }
2047
2270
 
@@ -2077,7 +2300,9 @@ var WebSocketTransport = class {
2077
2300
  };
2078
2301
  socket.onclose = () => handlers.onStatus("disconnected");
2079
2302
  socket.onerror = () => handlers.onStatus("error");
2080
- socket.onmessage = (event) => this.handleMessage(event.data);
2303
+ socket.onmessage = (event) => {
2304
+ void this.handleMessage(event.data);
2305
+ };
2081
2306
  this.socket = socket;
2082
2307
  }
2083
2308
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
@@ -2153,7 +2378,15 @@ var WebSocketTransport = class {
2153
2378
  /** Parse a server frame. Only objects carrying a string `topic` are
2154
2379
  * publications; malformed JSON and unknown shapes are ignored so a chatty
2155
2380
  * server cannot crash the message path. */
2156
- handleMessage(raw) {
2381
+ async handleMessage(raw) {
2382
+ if (typeof Blob !== "undefined" && raw instanceof Blob) {
2383
+ try {
2384
+ await this.handleMessage(await raw.arrayBuffer());
2385
+ } catch (error) {
2386
+ this.handlers?.onError(error);
2387
+ }
2388
+ return;
2389
+ }
2157
2390
  let parsed;
2158
2391
  if (raw instanceof ArrayBuffer) {
2159
2392
  const bytes = new Uint8Array(raw);