cross-tab-worker-databus 0.3.0 → 0.5.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.
@@ -25,6 +25,7 @@ __export(src_exports, {
25
25
  WebSocketTransport: () => WebSocketTransport,
26
26
  WorkerClusterRuntime: () => WorkerClusterRuntime,
27
27
  createBrowserEnvironment: () => createBrowserEnvironment,
28
+ createIndexedDbReplayPersistence: () => createIndexedDbReplayPersistence,
28
29
  createOpaqueKey: () => createOpaqueKey,
29
30
  createWebSocketDataBus: () => createWebSocketDataBus,
30
31
  getOrCreateTabId: () => getOrCreateTabId,
@@ -1210,6 +1211,7 @@ function roundMs(value) {
1210
1211
 
1211
1212
  // src/core/data-bus.ts
1212
1213
  var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1214
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1213
1215
  var CrossTabDataBus = class _CrossTabDataBus {
1214
1216
  transport;
1215
1217
  cluster;
@@ -1220,6 +1222,12 @@ var CrossTabDataBus = class _CrossTabDataBus {
1220
1222
  transportSubscribedTopics = /* @__PURE__ */ new Set();
1221
1223
  statusHandlers = /* @__PURE__ */ new Set();
1222
1224
  errorHandlers = /* @__PURE__ */ new Set();
1225
+ // Bounded per-topic ring of recent dispatched publications. Null unless
1226
+ // replay is enabled — buffering is opt-in and must cost nothing otherwise.
1227
+ replayBuffers;
1228
+ replayMaxPerTopic;
1229
+ replayPersistence;
1230
+ replayHydration;
1223
1231
  initialConfig;
1224
1232
  hasInitialConfig;
1225
1233
  trace;
@@ -1248,6 +1256,19 @@ var CrossTabDataBus = class _CrossTabDataBus {
1248
1256
  // Minimum interval in ms between automatic recovery attempts.
1249
1257
  static RECOVERY_COOLDOWN_MS = 1e3;
1250
1258
  constructor(options) {
1259
+ const replay = options.replay;
1260
+ if (replay) {
1261
+ const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1262
+ if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1263
+ throw new TypeError(
1264
+ `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1265
+ );
1266
+ }
1267
+ }
1268
+ this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1269
+ this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1270
+ this.replayPersistence = replay?.persistence ?? null;
1271
+ this.replayHydration = this.hydrateReplay();
1251
1272
  const { autoStart, initialConfig, trace, transport, ...clusterOptions } = options;
1252
1273
  this.transport = transport;
1253
1274
  this.initialConfig = initialConfig;
@@ -1404,13 +1425,26 @@ var CrossTabDataBus = class _CrossTabDataBus {
1404
1425
  * delivered to this tab, regardless of which tab published it. Returns an
1405
1426
  * unsubscribe function for convenience.
1406
1427
  */
1407
- subscribe(topic, handler) {
1428
+ subscribe(topic, handler, options) {
1408
1429
  this.ensureStarted();
1409
1430
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
1410
1431
  const wasUnused = handlers.size === 0;
1411
1432
  handlers.add(handler);
1412
1433
  this.topicHandlers.set(topic, handlers);
1413
1434
  if (wasUnused) this.cluster.subscribe(topic);
1435
+ if (options?.replay) {
1436
+ const limit = Math.min(
1437
+ typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1438
+ this.replayMaxPerTopic
1439
+ );
1440
+ if (this.replayPersistence) {
1441
+ void this.replayHydration.then(() => {
1442
+ if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1443
+ });
1444
+ } else {
1445
+ this.deliverReplay(topic, limit, handler);
1446
+ }
1447
+ }
1414
1448
  return () => this.unsubscribe(topic, handler);
1415
1449
  }
1416
1450
  /** Remove a specific handler, or all handlers for `topic`.
@@ -1424,6 +1458,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1424
1458
  else handlers.clear();
1425
1459
  if (handlers.size > 0) return;
1426
1460
  this.topicHandlers.delete(topic);
1461
+ this.replayBuffers?.delete(topic);
1427
1462
  this.cluster.unsubscribe(topic);
1428
1463
  }
1429
1464
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
@@ -1470,6 +1505,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1470
1505
  this.trace.event({ type: "lifecycle", action: "stop" });
1471
1506
  this.trace.stop();
1472
1507
  this.topicHandlers.clear();
1508
+ this.replayBuffers?.clear();
1473
1509
  this.cluster.stop();
1474
1510
  try {
1475
1511
  await this.startPromise?.catch(() => void 0);
@@ -1518,6 +1554,61 @@ var CrossTabDataBus = class _CrossTabDataBus {
1518
1554
  this.invokeHandlers(handlers, (handler) => handler(message));
1519
1555
  }
1520
1556
  }
1557
+ this.recordReplay(message);
1558
+ }
1559
+ /** Append a dispatched publication to the topic's replay ring buffer.
1560
+ * No-op when replay is disabled. */
1561
+ recordReplay(message) {
1562
+ if (!this.replayBuffers) return;
1563
+ let buffer = this.replayBuffers.get(message.topic);
1564
+ if (!buffer) {
1565
+ buffer = [];
1566
+ this.replayBuffers.set(message.topic, buffer);
1567
+ }
1568
+ buffer.push(message);
1569
+ if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1570
+ if (this.replayPersistence) {
1571
+ void this.replayPersistence.append(message).catch((error) => this.reportError(error));
1572
+ }
1573
+ }
1574
+ async hydrateReplay() {
1575
+ if (!this.replayBuffers || !this.replayPersistence) {
1576
+ return;
1577
+ }
1578
+ try {
1579
+ for (const message of await this.replayPersistence.load()) {
1580
+ let buffer = this.replayBuffers.get(message.topic);
1581
+ if (!buffer) {
1582
+ buffer = [];
1583
+ this.replayBuffers.set(message.topic, buffer);
1584
+ }
1585
+ buffer.push(message);
1586
+ if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1587
+ }
1588
+ } catch (error) {
1589
+ this.reportError(error);
1590
+ }
1591
+ }
1592
+ /** Deliver buffered history to a newly-registered handler. For an exact
1593
+ * topic this is that topic's ring; for a wildcard subscription every
1594
+ * buffered topic matching the pattern contributes (in buffer insertion
1595
+ * order). Replay deliveries are marked `replayed: true` and are not
1596
+ * counted into trace metrics. */
1597
+ deliverReplay(topic, limit, handler) {
1598
+ if (!this.replayBuffers || limit <= 0) return;
1599
+ const deliver = (buffer2) => {
1600
+ for (const message of buffer2.slice(-limit)) {
1601
+ this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
1602
+ }
1603
+ };
1604
+ if (isWildcardTopic(topic)) {
1605
+ for (const [bufferedTopic, buffer2] of this.replayBuffers) {
1606
+ if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
1607
+ }
1608
+ return;
1609
+ }
1610
+ const buffer = this.replayBuffers.get(topic);
1611
+ if (buffer) deliver(buffer);
1521
1612
  }
1522
1613
  /**
1523
1614
  * Propagate a status change to the cluster, trace, and all registered
@@ -1691,6 +1782,54 @@ function formatRouteTrace(route) {
1691
1782
  return `${route.topicKey}@${route.workerId}|confirmed=${route.confirmedAt !== void 0}`;
1692
1783
  }
1693
1784
 
1785
+ // src/core/replay-persistence.ts
1786
+ function createIndexedDbReplayPersistence(options) {
1787
+ const indexedDb = globalThis.indexedDB;
1788
+ if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
1789
+ const dbName = options.dbName ?? "cross-tab-worker-databus";
1790
+ const storeName = "replay";
1791
+ const maxPerTopic = options.maxPerTopic;
1792
+ if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1793
+ throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
1794
+ }
1795
+ let dbPromise = null;
1796
+ const open = () => {
1797
+ if (dbPromise) return dbPromise;
1798
+ dbPromise = new Promise((resolve, reject) => {
1799
+ const request = indexedDb.open(dbName, 1);
1800
+ request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
1801
+ request.onsuccess = () => resolve(request.result);
1802
+ request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
1803
+ });
1804
+ return dbPromise;
1805
+ };
1806
+ return {
1807
+ async load() {
1808
+ const db = await open();
1809
+ return new Promise((resolve, reject) => {
1810
+ const request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
1811
+ request.onsuccess = () => resolve(request.result.flatMap((record) => record.messages));
1812
+ request.onerror = () => reject(request.error ?? new Error("Failed to load replay history."));
1813
+ });
1814
+ },
1815
+ async append(message) {
1816
+ const db = await open();
1817
+ await new Promise((resolve, reject) => {
1818
+ const transaction = db.transaction(storeName, "readwrite");
1819
+ const store = transaction.objectStore(storeName);
1820
+ const request = store.get(message.topic);
1821
+ request.onsuccess = () => {
1822
+ const messages = (request.result?.messages ?? []).concat(message).slice(-maxPerTopic);
1823
+ store.put({ topic: message.topic, messages });
1824
+ };
1825
+ request.onerror = () => reject(request.error ?? new Error("Failed to read replay history."));
1826
+ transaction.oncomplete = () => resolve();
1827
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to persist replay history."));
1828
+ });
1829
+ }
1830
+ };
1831
+ }
1832
+
1694
1833
  // src/websocket.ts
1695
1834
  var WS_OPEN = 1;
1696
1835
  var WebSocketTransport = class {