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.
@@ -160,6 +160,9 @@ function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORK
160
160
  (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
161
161
  ).slice(0, maxActiveWorkers);
162
162
  }
163
+ function isWildcardTopic(pattern) {
164
+ return pattern === "*" || pattern.endsWith(".*");
165
+ }
163
166
  function topicMatchesPattern(pattern, topic) {
164
167
  if (!pattern || !topic) return false;
165
168
  if (pattern === topic) return true;
@@ -1183,6 +1186,7 @@ function roundMs(value) {
1183
1186
 
1184
1187
  // src/core/data-bus.ts
1185
1188
  var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1189
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1186
1190
  var CrossTabDataBus = class _CrossTabDataBus {
1187
1191
  transport;
1188
1192
  cluster;
@@ -1193,6 +1197,12 @@ var CrossTabDataBus = class _CrossTabDataBus {
1193
1197
  transportSubscribedTopics = /* @__PURE__ */ new Set();
1194
1198
  statusHandlers = /* @__PURE__ */ new Set();
1195
1199
  errorHandlers = /* @__PURE__ */ new Set();
1200
+ // Bounded per-topic ring of recent dispatched publications. Null unless
1201
+ // replay is enabled — buffering is opt-in and must cost nothing otherwise.
1202
+ replayBuffers;
1203
+ replayMaxPerTopic;
1204
+ replayPersistence;
1205
+ replayHydration;
1196
1206
  initialConfig;
1197
1207
  hasInitialConfig;
1198
1208
  trace;
@@ -1221,6 +1231,19 @@ var CrossTabDataBus = class _CrossTabDataBus {
1221
1231
  // Minimum interval in ms between automatic recovery attempts.
1222
1232
  static RECOVERY_COOLDOWN_MS = 1e3;
1223
1233
  constructor(options) {
1234
+ const replay = options.replay;
1235
+ if (replay) {
1236
+ const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1237
+ if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1238
+ throw new TypeError(
1239
+ `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1240
+ );
1241
+ }
1242
+ }
1243
+ this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1244
+ this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1245
+ this.replayPersistence = replay?.persistence ?? null;
1246
+ this.replayHydration = this.hydrateReplay();
1224
1247
  const { autoStart, initialConfig, trace, transport, ...clusterOptions } = options;
1225
1248
  this.transport = transport;
1226
1249
  this.initialConfig = initialConfig;
@@ -1377,13 +1400,26 @@ var CrossTabDataBus = class _CrossTabDataBus {
1377
1400
  * delivered to this tab, regardless of which tab published it. Returns an
1378
1401
  * unsubscribe function for convenience.
1379
1402
  */
1380
- subscribe(topic, handler) {
1403
+ subscribe(topic, handler, options) {
1381
1404
  this.ensureStarted();
1382
1405
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
1383
1406
  const wasUnused = handlers.size === 0;
1384
1407
  handlers.add(handler);
1385
1408
  this.topicHandlers.set(topic, handlers);
1386
1409
  if (wasUnused) this.cluster.subscribe(topic);
1410
+ if (options?.replay) {
1411
+ const limit = Math.min(
1412
+ typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1413
+ this.replayMaxPerTopic
1414
+ );
1415
+ if (this.replayPersistence) {
1416
+ void this.replayHydration.then(() => {
1417
+ if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1418
+ });
1419
+ } else {
1420
+ this.deliverReplay(topic, limit, handler);
1421
+ }
1422
+ }
1387
1423
  return () => this.unsubscribe(topic, handler);
1388
1424
  }
1389
1425
  /** Remove a specific handler, or all handlers for `topic`.
@@ -1397,6 +1433,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1397
1433
  else handlers.clear();
1398
1434
  if (handlers.size > 0) return;
1399
1435
  this.topicHandlers.delete(topic);
1436
+ this.replayBuffers?.delete(topic);
1400
1437
  this.cluster.unsubscribe(topic);
1401
1438
  }
1402
1439
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
@@ -1443,6 +1480,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1443
1480
  this.trace.event({ type: "lifecycle", action: "stop" });
1444
1481
  this.trace.stop();
1445
1482
  this.topicHandlers.clear();
1483
+ this.replayBuffers?.clear();
1446
1484
  this.cluster.stop();
1447
1485
  try {
1448
1486
  await this.startPromise?.catch(() => void 0);
@@ -1491,6 +1529,61 @@ var CrossTabDataBus = class _CrossTabDataBus {
1491
1529
  this.invokeHandlers(handlers, (handler) => handler(message));
1492
1530
  }
1493
1531
  }
1532
+ this.recordReplay(message);
1533
+ }
1534
+ /** Append a dispatched publication to the topic's replay ring buffer.
1535
+ * No-op when replay is disabled. */
1536
+ recordReplay(message) {
1537
+ if (!this.replayBuffers) return;
1538
+ let buffer = this.replayBuffers.get(message.topic);
1539
+ if (!buffer) {
1540
+ buffer = [];
1541
+ this.replayBuffers.set(message.topic, buffer);
1542
+ }
1543
+ buffer.push(message);
1544
+ if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1545
+ if (this.replayPersistence) {
1546
+ void this.replayPersistence.append(message).catch((error) => this.reportError(error));
1547
+ }
1548
+ }
1549
+ async hydrateReplay() {
1550
+ if (!this.replayBuffers || !this.replayPersistence) {
1551
+ return;
1552
+ }
1553
+ try {
1554
+ for (const message of await this.replayPersistence.load()) {
1555
+ let buffer = this.replayBuffers.get(message.topic);
1556
+ if (!buffer) {
1557
+ buffer = [];
1558
+ this.replayBuffers.set(message.topic, buffer);
1559
+ }
1560
+ buffer.push(message);
1561
+ if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1562
+ }
1563
+ } catch (error) {
1564
+ this.reportError(error);
1565
+ }
1566
+ }
1567
+ /** Deliver buffered history to a newly-registered handler. For an exact
1568
+ * topic this is that topic's ring; for a wildcard subscription every
1569
+ * buffered topic matching the pattern contributes (in buffer insertion
1570
+ * order). Replay deliveries are marked `replayed: true` and are not
1571
+ * counted into trace metrics. */
1572
+ deliverReplay(topic, limit, handler) {
1573
+ if (!this.replayBuffers || limit <= 0) return;
1574
+ const deliver = (buffer2) => {
1575
+ for (const message of buffer2.slice(-limit)) {
1576
+ this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
1577
+ }
1578
+ };
1579
+ if (isWildcardTopic(topic)) {
1580
+ for (const [bufferedTopic, buffer2] of this.replayBuffers) {
1581
+ if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
1582
+ }
1583
+ return;
1584
+ }
1585
+ const buffer = this.replayBuffers.get(topic);
1586
+ if (buffer) deliver(buffer);
1494
1587
  }
1495
1588
  /**
1496
1589
  * Propagate a status change to the cluster, trace, and all registered
@@ -2081,7 +2174,15 @@ function createDefaultWorker() {
2081
2174
  if (typeof Worker === "undefined") {
2082
2175
  throw new Error("CentrifugeWorkerTransport requires a browser Worker implementation.");
2083
2176
  }
2084
- return new Worker(new URL("./centrifuge.worker.js", import_meta.url), {
2177
+ let workerUrl;
2178
+ try {
2179
+ workerUrl = new URL("./centrifuge.worker.js", import_meta.url);
2180
+ } catch {
2181
+ throw new Error(
2182
+ "The default Centrifuge Worker URL is unavailable in this module format; provide workerFactory explicitly."
2183
+ );
2184
+ }
2185
+ return new Worker(workerUrl, {
2085
2186
  name: "cross-tab-worker-databus",
2086
2187
  type: "module"
2087
2188
  });
@@ -2090,7 +2191,15 @@ function createDefaultSharedWorker() {
2090
2191
  if (typeof SharedWorker === "undefined") {
2091
2192
  throw new Error("CentrifugeWorkerTransport requires a browser SharedWorker implementation.");
2092
2193
  }
2093
- return new SharedWorker(new URL("./centrifuge.shared.worker.js", import_meta.url), {
2194
+ let workerUrl;
2195
+ try {
2196
+ workerUrl = new URL("./centrifuge.shared.worker.js", import_meta.url);
2197
+ } catch {
2198
+ throw new Error(
2199
+ "The default Centrifuge SharedWorker URL is unavailable in this module format; provide sharedWorkerFactory explicitly."
2200
+ );
2201
+ }
2202
+ return new SharedWorker(workerUrl, {
2094
2203
  name: "cross-tab-worker-databus-shared",
2095
2204
  type: "module"
2096
2205
  });