cross-tab-worker-databus 0.8.0 → 0.11.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -0
  3. package/README.zh.md +2 -0
  4. package/dist/centrifuge-protocol.d.ts +4 -0
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts.map +1 -1
  7. package/dist/centrifuge.js +29 -11
  8. package/dist/centrifuge.js.map +2 -2
  9. package/dist/centrifuge.shared.worker.js +37 -6
  10. package/dist/centrifuge.shared.worker.js.map +3 -3
  11. package/dist/centrifuge.worker.js +37 -6
  12. package/dist/centrifuge.worker.js.map +3 -3
  13. package/dist/{chunk-WWUY2FV2.js → chunk-NX76TAV3.js} +137 -26
  14. package/dist/chunk-NX76TAV3.js.map +7 -0
  15. package/dist/cjs/centrifuge.cjs +164 -35
  16. package/dist/cjs/centrifuge.cjs.map +4 -4
  17. package/dist/cjs/index.cjs +172 -38
  18. package/dist/cjs/index.cjs.map +3 -3
  19. package/dist/core/cluster.d.ts +3 -2
  20. package/dist/core/cluster.d.ts.map +1 -1
  21. package/dist/core/data-bus.d.ts +19 -0
  22. package/dist/core/data-bus.d.ts.map +1 -1
  23. package/dist/core/publication.d.ts +10 -0
  24. package/dist/core/publication.d.ts.map +1 -0
  25. package/dist/core/replay-persistence.d.ts +2 -0
  26. package/dist/core/replay-persistence.d.ts.map +1 -1
  27. package/dist/core/trace.d.ts +7 -0
  28. package/dist/core/trace.d.ts.map +1 -1
  29. package/dist/core/types.d.ts +19 -7
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +39 -14
  34. package/dist/index.js.map +2 -2
  35. package/dist/websocket.d.ts.map +1 -1
  36. package/docs/api.md +27 -6
  37. package/docs/architecture.md +7 -1
  38. package/docs/capabilities.md +2 -2
  39. package/docs/configuration.md +1 -1
  40. package/docs/roadmap.md +15 -1
  41. package/docs/transports.md +9 -5
  42. package/docs/zh/api.md +27 -6
  43. package/docs/zh/architecture.md +7 -1
  44. package/docs/zh/capabilities.md +2 -2
  45. package/docs/zh/configuration.md +1 -1
  46. package/docs/zh/transports.md +8 -4
  47. package/package.json +1 -1
  48. package/dist/chunk-WWUY2FV2.js.map +0 -7
@@ -298,6 +298,13 @@ function writeJson(storage, key, value) {
298
298
  } catch {
299
299
  }
300
300
  }
301
+ function publicationMetadata(messageId, timestamp) {
302
+ if (messageId === void 0 && timestamp === void 0) return void 0;
303
+ return {
304
+ ...messageId === void 0 ? {} : { messageId },
305
+ ...timestamp === void 0 ? {} : { timestamp }
306
+ };
307
+ }
301
308
  function listKeys(storage, prefix) {
302
309
  try {
303
310
  return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
@@ -527,26 +534,21 @@ var WorkerClusterRuntime = class {
527
534
  this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
528
535
  }
529
536
  }
530
- /**
531
- * Publish a message to `topic`, routing through the owning Worker (or self if
532
- * no owner is found). Returns false when the control message could not be
533
- * posted to a remote owner, so the caller can surface the failure instead of
534
- * silently dropping the publication.
535
- */
536
- publish(topic, data, messageId) {
537
+ publish(topic, data, metadataOrMessageId) {
538
+ const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
537
539
  const topicKey = this.rememberTopic(topic);
538
540
  if (this.assignedTopics.has(topicKey)) {
539
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, messageId);
541
+ return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
540
542
  }
541
543
  for (const pattern of this.assignedTopics.values()) {
542
544
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
543
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, messageId);
545
+ return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
544
546
  }
545
547
  }
546
548
  const workers = this.readWorkers();
547
549
  const route = this.readRoute(topicKey);
548
550
  const target = this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
549
- return this.sendControl(target, "PUBLISH", topic, topicKey, data, messageId);
551
+ return this.sendControl(target, "PUBLISH", topic, topicKey, data, metadata);
550
552
  }
551
553
  /** True when `route` exists and its owner worker is among `workers`.
552
554
  * Shared by subscribe (skip re-assignment) and publish (route to owner).
@@ -668,11 +670,15 @@ var WorkerClusterRuntime = class {
668
670
  default:
669
671
  break;
670
672
  }
671
- if (message.messageId === void 0) {
672
- this.handlers.onControl(message.action, message.topic, message.data);
673
- } else {
674
- this.handlers.onControl(message.action, message.topic, message.data, message.messageId);
675
- }
673
+ const metadata = publicationMetadata(message.messageId, message.timestamp);
674
+ if (metadata) this.handlers.onControl(
675
+ message.action,
676
+ message.topic,
677
+ message.data,
678
+ metadata.messageId,
679
+ metadata.timestamp
680
+ );
681
+ else this.handlers.onControl(message.action, message.topic, message.data);
676
682
  if (message.action !== "PUBLISH") this.updateLoad();
677
683
  }
678
684
  /**
@@ -789,7 +795,7 @@ var WorkerClusterRuntime = class {
789
795
  * Local execution updates the assignment map and route synchronously, bypassing
790
796
  * the BroadcastChannel latency.
791
797
  */
792
- sendControl(targetWorkerId, action, topic, topicKey, data, messageId) {
798
+ sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
793
799
  if (targetWorkerId === this.workerId) {
794
800
  switch (action) {
795
801
  case "SUBSCRIBE":
@@ -803,8 +809,14 @@ var WorkerClusterRuntime = class {
803
809
  default:
804
810
  break;
805
811
  }
806
- if (messageId === void 0) this.handlers.onControl(action, topic, data);
807
- else this.handlers.onControl(action, topic, data, messageId);
812
+ if (metadata) this.handlers.onControl(
813
+ action,
814
+ topic,
815
+ data,
816
+ metadata.messageId,
817
+ metadata.timestamp
818
+ );
819
+ else this.handlers.onControl(action, topic, data);
808
820
  if (action !== "PUBLISH") this.updateLoad();
809
821
  return true;
810
822
  }
@@ -816,7 +828,8 @@ var WorkerClusterRuntime = class {
816
828
  topic,
817
829
  topicKey,
818
830
  ...data === void 0 ? {} : { data },
819
- ...messageId === void 0 ? {} : { messageId }
831
+ ...metadata?.messageId === void 0 ? {} : { messageId: metadata.messageId },
832
+ ...metadata?.timestamp === void 0 ? {} : { timestamp: metadata.timestamp }
820
833
  });
821
834
  }
822
835
  /** Post a message on the BroadcastChannel. Returns false on postMessage failure. */
@@ -1037,6 +1050,8 @@ var DataBusTraceReporter = class {
1037
1050
  // Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
1038
1051
  latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
1039
1052
  latencySumMs = 0;
1053
+ dedupAccepted = 0;
1054
+ dedupSuppressed = 0;
1040
1055
  constructor(options, now = Date.now) {
1041
1056
  this.enabled = options?.enabled ?? false;
1042
1057
  this.mode = options?.mode ?? "all";
@@ -1112,6 +1127,13 @@ var DataBusTraceReporter = class {
1112
1127
  this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
1113
1128
  this.latencySumMs += delayMs;
1114
1129
  }
1130
+ /** Record deduplication outcomes for the next metrics window. */
1131
+ recordDedupAccepted() {
1132
+ if (this.metricsActive) this.dedupAccepted += 1;
1133
+ }
1134
+ recordDedupSuppressed() {
1135
+ if (this.metricsActive) this.dedupSuppressed += 1;
1136
+ }
1115
1137
  /** True when metrics recording is active: enabled and mode includes metrics.
1116
1138
  * Extracted so the four record / flush methods share one guard expression
1117
1139
  * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
@@ -1125,7 +1147,7 @@ var DataBusTraceReporter = class {
1125
1147
  }
1126
1148
  flushNow() {
1127
1149
  const timestamp = this.now();
1128
- if (this.received > 0 || this.dispatched > 0) {
1150
+ if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
1129
1151
  const samples = this.latencySamples;
1130
1152
  this.emit({
1131
1153
  type: "message_metrics",
@@ -1139,6 +1161,8 @@ var DataBusTraceReporter = class {
1139
1161
  dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1140
1162
  dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1141
1163
  dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1164
+ dedupAccepted: this.dedupAccepted,
1165
+ dedupSuppressed: this.dedupSuppressed,
1142
1166
  timestamp
1143
1167
  });
1144
1168
  this.resetMetrics();
@@ -1153,6 +1177,8 @@ var DataBusTraceReporter = class {
1153
1177
  this.receivedAt.clear();
1154
1178
  this.latencyBuckets.fill(0);
1155
1179
  this.latencySumMs = 0;
1180
+ this.dedupAccepted = 0;
1181
+ this.dedupSuppressed = 0;
1156
1182
  }
1157
1183
  emit(event) {
1158
1184
  try {
@@ -1203,6 +1229,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1203
1229
  replayBuffers;
1204
1230
  replayMaxPerTopic;
1205
1231
  replayPersistence;
1232
+ replayRetentionMs;
1206
1233
  replayHydration;
1207
1234
  initialConfig;
1208
1235
  hasInitialConfig;
@@ -1211,6 +1238,8 @@ var CrossTabDataBus = class _CrossTabDataBus {
1211
1238
  dedupTtlMs;
1212
1239
  dedupEnabled;
1213
1240
  seenMessageIds = /* @__PURE__ */ new Map();
1241
+ dedupSuppressed = 0;
1242
+ dedupAccepted = 0;
1214
1243
  activeConfig;
1215
1244
  status = "disconnected";
1216
1245
  started = false;
@@ -1248,6 +1277,10 @@ var CrossTabDataBus = class _CrossTabDataBus {
1248
1277
  this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1249
1278
  this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1250
1279
  this.replayPersistence = replay?.persistence ?? null;
1280
+ this.replayRetentionMs = replay?.retentionMs;
1281
+ if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1282
+ throw new TypeError("replay.retentionMs must be a positive finite number.");
1283
+ }
1251
1284
  this.replayHydration = this.hydrateReplay();
1252
1285
  const { autoStart, initialConfig, trace, transport, dedup, ...clusterOptions } = options;
1253
1286
  this.transport = transport;
@@ -1268,7 +1301,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1268
1301
  handlers: {
1269
1302
  // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1270
1303
  // control message — meaning the owning Worker has delegated the action to us.
1271
- onControl: (action, topic, data, messageId) => {
1304
+ onControl: (action, topic, data, messageId, timestamp) => {
1272
1305
  switch (action) {
1273
1306
  case "SUBSCRIBE":
1274
1307
  if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
@@ -1277,7 +1310,14 @@ var CrossTabDataBus = class _CrossTabDataBus {
1277
1310
  if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1278
1311
  break;
1279
1312
  case "PUBLISH":
1280
- this.runTransport(() => this.transport.publish(topic, data, messageId ? { messageId } : void 0));
1313
+ this.runTransport(() => this.transport.publish(
1314
+ topic,
1315
+ data,
1316
+ messageId === void 0 && timestamp === void 0 ? void 0 : {
1317
+ ...messageId === void 0 ? {} : { messageId },
1318
+ ...timestamp === void 0 ? {} : { timestamp }
1319
+ }
1320
+ ));
1281
1321
  break;
1282
1322
  default:
1283
1323
  break;
@@ -1468,10 +1508,49 @@ var CrossTabDataBus = class _CrossTabDataBus {
1468
1508
  }
1469
1509
  }
1470
1510
  }
1511
+ /** Clear replay history for one exact topic, including durable storage. */
1512
+ async clearReplayTopic(topic) {
1513
+ this.replayBuffers?.delete(topic);
1514
+ if (this.replayPersistence?.clearTopic) {
1515
+ try {
1516
+ await this.replayPersistence.clearTopic(topic);
1517
+ } catch (error) {
1518
+ this.reportError(error);
1519
+ throw error;
1520
+ }
1521
+ }
1522
+ }
1523
+ /** Remove replay entries older than an epoch-millisecond cutoff. */
1524
+ async clearReplayBefore(timestamp) {
1525
+ if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1526
+ if (this.replayBuffers) {
1527
+ for (const [topic, messages] of this.replayBuffers) {
1528
+ const kept = messages.filter((message) => (message.timestamp ?? 0) >= timestamp);
1529
+ if (kept.length) this.replayBuffers.set(topic, kept);
1530
+ else this.replayBuffers.delete(topic);
1531
+ }
1532
+ }
1533
+ if (this.replayPersistence?.clearBefore) await this.replayPersistence.clearBefore(timestamp);
1534
+ }
1535
+ /** Return bounded deduplication counters for diagnostics and health checks. */
1536
+ getDedupStats() {
1537
+ return {
1538
+ enabled: this.dedupEnabled,
1539
+ tracked: this.seenMessageIds.size,
1540
+ suppressed: this.dedupSuppressed,
1541
+ accepted: this.dedupAccepted
1542
+ };
1543
+ }
1544
+ /** Drop all remembered IDs and reset dedup counters. */
1545
+ resetDedup() {
1546
+ this.seenMessageIds.clear();
1547
+ this.dedupSuppressed = 0;
1548
+ this.dedupAccepted = 0;
1549
+ }
1471
1550
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1472
1551
  publish(topic, data, options) {
1473
1552
  this.ensureStarted();
1474
- if (!this.cluster.publish(topic, data, options?.messageId)) {
1553
+ if (!this.cluster.publish(topic, data, options)) {
1475
1554
  this.reportError(
1476
1555
  new Error("Failed to send the publish control message to the owning worker.")
1477
1556
  );
@@ -1560,9 +1639,13 @@ var CrossTabDataBus = class _CrossTabDataBus {
1560
1639
  }
1561
1640
  if (this.seenMessageIds.has(message.messageId)) {
1562
1641
  this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
1642
+ this.dedupSuppressed += 1;
1643
+ this.trace.recordDedupSuppressed();
1563
1644
  return true;
1564
1645
  }
1565
1646
  this.seenMessageIds.set(message.messageId, now);
1647
+ this.dedupAccepted += 1;
1648
+ this.trace.recordDedupAccepted();
1566
1649
  while (this.seenMessageIds.size > this.dedupMaxEntries) {
1567
1650
  const oldest = this.seenMessageIds.keys().next().value;
1568
1651
  if (oldest === void 0) break;
@@ -1592,10 +1675,14 @@ var CrossTabDataBus = class _CrossTabDataBus {
1592
1675
  buffer = [];
1593
1676
  this.replayBuffers.set(message.topic, buffer);
1594
1677
  }
1595
- buffer.push(message);
1678
+ const storedMessage = message;
1679
+ buffer.push(storedMessage);
1596
1680
  if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1597
1681
  if (this.replayPersistence) {
1598
- void this.replayPersistence.append(message).catch((error) => this.reportError(error));
1682
+ void this.replayPersistence.append(storedMessage).catch((error) => this.reportError(error));
1683
+ if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1684
+ void this.replayPersistence.clearBefore(Date.now() - this.replayRetentionMs).catch((error) => this.reportError(error));
1685
+ }
1599
1686
  }
1600
1687
  }
1601
1688
  async hydrateReplay() {
@@ -1603,6 +1690,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1603
1690
  return;
1604
1691
  }
1605
1692
  try {
1693
+ if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1694
+ await this.replayPersistence.clearBefore(Date.now() - this.replayRetentionMs);
1695
+ }
1606
1696
  for (const message of await this.replayPersistence.load()) {
1607
1697
  let buffer = this.replayBuffers.get(message.topic);
1608
1698
  if (!buffer) {
@@ -1820,6 +1910,26 @@ function selectWorkerBackend(mode, availability = {}) {
1820
1910
  return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
1821
1911
  }
1822
1912
 
1913
+ // src/core/publication.ts
1914
+ function parseDataBusPublication(value, fallbackTopic) {
1915
+ if (!value || typeof value !== "object") {
1916
+ return fallbackTopic ? { topic: fallbackTopic, data: value } : null;
1917
+ }
1918
+ const frame = value;
1919
+ const nested = frame.publication && typeof frame.publication === "object" ? frame.publication : null;
1920
+ const publication = nested ?? frame;
1921
+ const topic = typeof publication.topic === "string" ? publication.topic : fallbackTopic;
1922
+ if (!topic) return null;
1923
+ const hasMetadataEnvelope = fallbackTopic !== void 0 && Object.prototype.hasOwnProperty.call(publication, "data") && (typeof publication.messageId === "string" || typeof publication.timestamp === "number");
1924
+ const data = nested || fallbackTopic === void 0 || hasMetadataEnvelope ? publication.data : value;
1925
+ return {
1926
+ topic,
1927
+ data,
1928
+ ...typeof publication.messageId === "string" ? { messageId: publication.messageId } : {},
1929
+ ...typeof publication.timestamp === "number" ? { timestamp: publication.timestamp } : {}
1930
+ };
1931
+ }
1932
+
1823
1933
  export {
1824
1934
  createBrowserEnvironment,
1825
1935
  getOrCreateTabId,
@@ -1833,6 +1943,7 @@ export {
1833
1943
  topicMatchesPattern,
1834
1944
  WorkerClusterRuntime,
1835
1945
  CrossTabDataBus,
1946
+ parseDataBusPublication,
1836
1947
  selectWorkerBackend
1837
1948
  };
1838
- //# sourceMappingURL=chunk-WWUY2FV2.js.map
1949
+ //# sourceMappingURL=chunk-NX76TAV3.js.map