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
@@ -339,6 +339,13 @@ function writeJson(storage, key, value) {
339
339
  } catch {
340
340
  }
341
341
  }
342
+ function publicationMetadata(messageId, timestamp) {
343
+ if (messageId === void 0 && timestamp === void 0) return void 0;
344
+ return {
345
+ ...messageId === void 0 ? {} : { messageId },
346
+ ...timestamp === void 0 ? {} : { timestamp }
347
+ };
348
+ }
342
349
  function listKeys(storage, prefix) {
343
350
  try {
344
351
  return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
@@ -568,26 +575,21 @@ var WorkerClusterRuntime = class {
568
575
  this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
569
576
  }
570
577
  }
571
- /**
572
- * Publish a message to `topic`, routing through the owning Worker (or self if
573
- * no owner is found). Returns false when the control message could not be
574
- * posted to a remote owner, so the caller can surface the failure instead of
575
- * silently dropping the publication.
576
- */
577
- publish(topic, data, messageId) {
578
+ publish(topic, data, metadataOrMessageId) {
579
+ const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
578
580
  const topicKey = this.rememberTopic(topic);
579
581
  if (this.assignedTopics.has(topicKey)) {
580
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, messageId);
582
+ return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
581
583
  }
582
584
  for (const pattern of this.assignedTopics.values()) {
583
585
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
584
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, messageId);
586
+ return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
585
587
  }
586
588
  }
587
589
  const workers = this.readWorkers();
588
590
  const route = this.readRoute(topicKey);
589
591
  const target = this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
590
- return this.sendControl(target, "PUBLISH", topic, topicKey, data, messageId);
592
+ return this.sendControl(target, "PUBLISH", topic, topicKey, data, metadata);
591
593
  }
592
594
  /** True when `route` exists and its owner worker is among `workers`.
593
595
  * Shared by subscribe (skip re-assignment) and publish (route to owner).
@@ -709,11 +711,15 @@ var WorkerClusterRuntime = class {
709
711
  default:
710
712
  break;
711
713
  }
712
- if (message.messageId === void 0) {
713
- this.handlers.onControl(message.action, message.topic, message.data);
714
- } else {
715
- this.handlers.onControl(message.action, message.topic, message.data, message.messageId);
716
- }
714
+ const metadata = publicationMetadata(message.messageId, message.timestamp);
715
+ if (metadata) this.handlers.onControl(
716
+ message.action,
717
+ message.topic,
718
+ message.data,
719
+ metadata.messageId,
720
+ metadata.timestamp
721
+ );
722
+ else this.handlers.onControl(message.action, message.topic, message.data);
717
723
  if (message.action !== "PUBLISH") this.updateLoad();
718
724
  }
719
725
  /**
@@ -830,7 +836,7 @@ var WorkerClusterRuntime = class {
830
836
  * Local execution updates the assignment map and route synchronously, bypassing
831
837
  * the BroadcastChannel latency.
832
838
  */
833
- sendControl(targetWorkerId, action, topic, topicKey, data, messageId) {
839
+ sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
834
840
  if (targetWorkerId === this.workerId) {
835
841
  switch (action) {
836
842
  case "SUBSCRIBE":
@@ -844,8 +850,14 @@ var WorkerClusterRuntime = class {
844
850
  default:
845
851
  break;
846
852
  }
847
- if (messageId === void 0) this.handlers.onControl(action, topic, data);
848
- else this.handlers.onControl(action, topic, data, messageId);
853
+ if (metadata) this.handlers.onControl(
854
+ action,
855
+ topic,
856
+ data,
857
+ metadata.messageId,
858
+ metadata.timestamp
859
+ );
860
+ else this.handlers.onControl(action, topic, data);
849
861
  if (action !== "PUBLISH") this.updateLoad();
850
862
  return true;
851
863
  }
@@ -857,7 +869,8 @@ var WorkerClusterRuntime = class {
857
869
  topic,
858
870
  topicKey,
859
871
  ...data === void 0 ? {} : { data },
860
- ...messageId === void 0 ? {} : { messageId }
872
+ ...metadata?.messageId === void 0 ? {} : { messageId: metadata.messageId },
873
+ ...metadata?.timestamp === void 0 ? {} : { timestamp: metadata.timestamp }
861
874
  });
862
875
  }
863
876
  /** Post a message on the BroadcastChannel. Returns false on postMessage failure. */
@@ -1078,6 +1091,8 @@ var DataBusTraceReporter = class {
1078
1091
  // Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
1079
1092
  latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
1080
1093
  latencySumMs = 0;
1094
+ dedupAccepted = 0;
1095
+ dedupSuppressed = 0;
1081
1096
  constructor(options, now = Date.now) {
1082
1097
  this.enabled = options?.enabled ?? false;
1083
1098
  this.mode = options?.mode ?? "all";
@@ -1153,6 +1168,13 @@ var DataBusTraceReporter = class {
1153
1168
  this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
1154
1169
  this.latencySumMs += delayMs;
1155
1170
  }
1171
+ /** Record deduplication outcomes for the next metrics window. */
1172
+ recordDedupAccepted() {
1173
+ if (this.metricsActive) this.dedupAccepted += 1;
1174
+ }
1175
+ recordDedupSuppressed() {
1176
+ if (this.metricsActive) this.dedupSuppressed += 1;
1177
+ }
1156
1178
  /** True when metrics recording is active: enabled and mode includes metrics.
1157
1179
  * Extracted so the four record / flush methods share one guard expression
1158
1180
  * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
@@ -1166,7 +1188,7 @@ var DataBusTraceReporter = class {
1166
1188
  }
1167
1189
  flushNow() {
1168
1190
  const timestamp = this.now();
1169
- if (this.received > 0 || this.dispatched > 0) {
1191
+ if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
1170
1192
  const samples = this.latencySamples;
1171
1193
  this.emit({
1172
1194
  type: "message_metrics",
@@ -1180,6 +1202,8 @@ var DataBusTraceReporter = class {
1180
1202
  dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1181
1203
  dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1182
1204
  dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1205
+ dedupAccepted: this.dedupAccepted,
1206
+ dedupSuppressed: this.dedupSuppressed,
1183
1207
  timestamp
1184
1208
  });
1185
1209
  this.resetMetrics();
@@ -1194,6 +1218,8 @@ var DataBusTraceReporter = class {
1194
1218
  this.receivedAt.clear();
1195
1219
  this.latencyBuckets.fill(0);
1196
1220
  this.latencySumMs = 0;
1221
+ this.dedupAccepted = 0;
1222
+ this.dedupSuppressed = 0;
1197
1223
  }
1198
1224
  emit(event) {
1199
1225
  try {
@@ -1244,6 +1270,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1244
1270
  replayBuffers;
1245
1271
  replayMaxPerTopic;
1246
1272
  replayPersistence;
1273
+ replayRetentionMs;
1247
1274
  replayHydration;
1248
1275
  initialConfig;
1249
1276
  hasInitialConfig;
@@ -1252,6 +1279,8 @@ var CrossTabDataBus = class _CrossTabDataBus {
1252
1279
  dedupTtlMs;
1253
1280
  dedupEnabled;
1254
1281
  seenMessageIds = /* @__PURE__ */ new Map();
1282
+ dedupSuppressed = 0;
1283
+ dedupAccepted = 0;
1255
1284
  activeConfig;
1256
1285
  status = "disconnected";
1257
1286
  started = false;
@@ -1289,6 +1318,10 @@ var CrossTabDataBus = class _CrossTabDataBus {
1289
1318
  this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1290
1319
  this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1291
1320
  this.replayPersistence = replay?.persistence ?? null;
1321
+ this.replayRetentionMs = replay?.retentionMs;
1322
+ if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1323
+ throw new TypeError("replay.retentionMs must be a positive finite number.");
1324
+ }
1292
1325
  this.replayHydration = this.hydrateReplay();
1293
1326
  const { autoStart, initialConfig, trace, transport, dedup, ...clusterOptions } = options;
1294
1327
  this.transport = transport;
@@ -1309,7 +1342,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1309
1342
  handlers: {
1310
1343
  // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1311
1344
  // control message — meaning the owning Worker has delegated the action to us.
1312
- onControl: (action, topic, data, messageId) => {
1345
+ onControl: (action, topic, data, messageId, timestamp) => {
1313
1346
  switch (action) {
1314
1347
  case "SUBSCRIBE":
1315
1348
  if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
@@ -1318,7 +1351,14 @@ var CrossTabDataBus = class _CrossTabDataBus {
1318
1351
  if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1319
1352
  break;
1320
1353
  case "PUBLISH":
1321
- this.runTransport(() => this.transport.publish(topic, data, messageId ? { messageId } : void 0));
1354
+ this.runTransport(() => this.transport.publish(
1355
+ topic,
1356
+ data,
1357
+ messageId === void 0 && timestamp === void 0 ? void 0 : {
1358
+ ...messageId === void 0 ? {} : { messageId },
1359
+ ...timestamp === void 0 ? {} : { timestamp }
1360
+ }
1361
+ ));
1322
1362
  break;
1323
1363
  default:
1324
1364
  break;
@@ -1509,10 +1549,49 @@ var CrossTabDataBus = class _CrossTabDataBus {
1509
1549
  }
1510
1550
  }
1511
1551
  }
1552
+ /** Clear replay history for one exact topic, including durable storage. */
1553
+ async clearReplayTopic(topic) {
1554
+ this.replayBuffers?.delete(topic);
1555
+ if (this.replayPersistence?.clearTopic) {
1556
+ try {
1557
+ await this.replayPersistence.clearTopic(topic);
1558
+ } catch (error) {
1559
+ this.reportError(error);
1560
+ throw error;
1561
+ }
1562
+ }
1563
+ }
1564
+ /** Remove replay entries older than an epoch-millisecond cutoff. */
1565
+ async clearReplayBefore(timestamp) {
1566
+ if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1567
+ if (this.replayBuffers) {
1568
+ for (const [topic, messages] of this.replayBuffers) {
1569
+ const kept = messages.filter((message) => (message.timestamp ?? 0) >= timestamp);
1570
+ if (kept.length) this.replayBuffers.set(topic, kept);
1571
+ else this.replayBuffers.delete(topic);
1572
+ }
1573
+ }
1574
+ if (this.replayPersistence?.clearBefore) await this.replayPersistence.clearBefore(timestamp);
1575
+ }
1576
+ /** Return bounded deduplication counters for diagnostics and health checks. */
1577
+ getDedupStats() {
1578
+ return {
1579
+ enabled: this.dedupEnabled,
1580
+ tracked: this.seenMessageIds.size,
1581
+ suppressed: this.dedupSuppressed,
1582
+ accepted: this.dedupAccepted
1583
+ };
1584
+ }
1585
+ /** Drop all remembered IDs and reset dedup counters. */
1586
+ resetDedup() {
1587
+ this.seenMessageIds.clear();
1588
+ this.dedupSuppressed = 0;
1589
+ this.dedupAccepted = 0;
1590
+ }
1512
1591
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1513
1592
  publish(topic, data, options) {
1514
1593
  this.ensureStarted();
1515
- if (!this.cluster.publish(topic, data, options?.messageId)) {
1594
+ if (!this.cluster.publish(topic, data, options)) {
1516
1595
  this.reportError(
1517
1596
  new Error("Failed to send the publish control message to the owning worker.")
1518
1597
  );
@@ -1601,9 +1680,13 @@ var CrossTabDataBus = class _CrossTabDataBus {
1601
1680
  }
1602
1681
  if (this.seenMessageIds.has(message.messageId)) {
1603
1682
  this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
1683
+ this.dedupSuppressed += 1;
1684
+ this.trace.recordDedupSuppressed();
1604
1685
  return true;
1605
1686
  }
1606
1687
  this.seenMessageIds.set(message.messageId, now);
1688
+ this.dedupAccepted += 1;
1689
+ this.trace.recordDedupAccepted();
1607
1690
  while (this.seenMessageIds.size > this.dedupMaxEntries) {
1608
1691
  const oldest = this.seenMessageIds.keys().next().value;
1609
1692
  if (oldest === void 0) break;
@@ -1633,10 +1716,14 @@ var CrossTabDataBus = class _CrossTabDataBus {
1633
1716
  buffer = [];
1634
1717
  this.replayBuffers.set(message.topic, buffer);
1635
1718
  }
1636
- buffer.push(message);
1719
+ const storedMessage = message;
1720
+ buffer.push(storedMessage);
1637
1721
  if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1638
1722
  if (this.replayPersistence) {
1639
- void this.replayPersistence.append(message).catch((error) => this.reportError(error));
1723
+ void this.replayPersistence.append(storedMessage).catch((error) => this.reportError(error));
1724
+ if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1725
+ void this.replayPersistence.clearBefore(Date.now() - this.replayRetentionMs).catch((error) => this.reportError(error));
1726
+ }
1640
1727
  }
1641
1728
  }
1642
1729
  async hydrateReplay() {
@@ -1644,6 +1731,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1644
1731
  return;
1645
1732
  }
1646
1733
  try {
1734
+ if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
1735
+ await this.replayPersistence.clearBefore(Date.now() - this.replayRetentionMs);
1736
+ }
1647
1737
  for (const message of await this.replayPersistence.load()) {
1648
1738
  let buffer = this.replayBuffers.get(message.topic);
1649
1739
  if (!buffer) {
@@ -1913,10 +2003,48 @@ function createIndexedDbReplayPersistence(options) {
1913
2003
  transaction.oncomplete = () => resolve();
1914
2004
  transaction.onerror = () => reject(transaction.error ?? new Error("Failed to clear topic replay history."));
1915
2005
  });
2006
+ },
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 });
2018
+ }
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."));
2023
+ });
1916
2024
  }
1917
2025
  };
1918
2026
  }
1919
2027
 
2028
+ // src/core/publication.ts
2029
+ function parseDataBusPublication(value, fallbackTopic) {
2030
+ if (!value || typeof value !== "object") {
2031
+ return fallbackTopic ? { topic: fallbackTopic, data: value } : null;
2032
+ }
2033
+ const frame = value;
2034
+ const nested = frame.publication && typeof frame.publication === "object" ? frame.publication : null;
2035
+ const publication = nested ?? frame;
2036
+ const topic = typeof publication.topic === "string" ? publication.topic : fallbackTopic;
2037
+ if (!topic) return null;
2038
+ const hasMetadataEnvelope = fallbackTopic !== void 0 && Object.prototype.hasOwnProperty.call(publication, "data") && (typeof publication.messageId === "string" || typeof publication.timestamp === "number");
2039
+ const data = nested || fallbackTopic === void 0 || hasMetadataEnvelope ? publication.data : value;
2040
+ return {
2041
+ topic,
2042
+ data,
2043
+ ...typeof publication.messageId === "string" ? { messageId: publication.messageId } : {},
2044
+ ...typeof publication.timestamp === "number" ? { timestamp: publication.timestamp } : {}
2045
+ };
2046
+ }
2047
+
1920
2048
  // src/websocket.ts
1921
2049
  var WS_OPEN = 1;
1922
2050
  var WebSocketTransport = class {
@@ -1966,10 +2094,16 @@ var WebSocketTransport = class {
1966
2094
  /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
1967
2095
  publish(topic, data, options) {
1968
2096
  if (data instanceof ArrayBuffer) {
1969
- this.sendBinaryFrame(topic, data, options?.messageId);
2097
+ this.sendBinaryFrame(topic, data, options?.messageId, options?.timestamp);
1970
2098
  return;
1971
2099
  }
1972
- this.sendFrame({ op: "publish", topic, data, ...options?.messageId ? { messageId: options.messageId } : {} });
2100
+ this.sendFrame({
2101
+ op: "publish",
2102
+ topic,
2103
+ data,
2104
+ ...options?.messageId === void 0 ? {} : { messageId: options.messageId },
2105
+ ...options?.timestamp === void 0 ? {} : { timestamp: options.timestamp }
2106
+ });
1973
2107
  }
1974
2108
  /** Close the socket and drop all state. Safe to call multiple times. */
1975
2109
  stop() {
@@ -1989,9 +2123,15 @@ var WebSocketTransport = class {
1989
2123
  }
1990
2124
  this.socket.send(JSON.stringify(payload));
1991
2125
  }
1992
- sendBinaryFrame(topic, data, messageId) {
1993
- if (messageId) {
1994
- this.sendFrame({ op: "publish", topic, data: Array.from(new Uint8Array(data)), messageId });
2126
+ sendBinaryFrame(topic, data, messageId, timestamp) {
2127
+ if (messageId !== void 0 || timestamp !== void 0) {
2128
+ this.sendFrame({
2129
+ op: "publish",
2130
+ topic,
2131
+ data: Array.from(new Uint8Array(data)),
2132
+ ...messageId === void 0 ? {} : { messageId },
2133
+ ...timestamp === void 0 ? {} : { timestamp }
2134
+ });
1995
2135
  return;
1996
2136
  }
1997
2137
  if (this.socket?.readyState !== WS_OPEN) {
@@ -2033,14 +2173,8 @@ var WebSocketTransport = class {
2033
2173
  return;
2034
2174
  }
2035
2175
  if (!parsed || typeof parsed !== "object") return;
2036
- const frame = parsed;
2037
- if (typeof frame.topic !== "string") return;
2038
- const message = {
2039
- topic: frame.topic,
2040
- data: frame.data,
2041
- ...typeof frame.messageId === "string" ? { messageId: frame.messageId } : {}
2042
- };
2043
- this.handlers?.onMessage(message);
2176
+ const publication = parseDataBusPublication(parsed);
2177
+ if (publication) this.handlers?.onMessage(publication);
2044
2178
  }
2045
2179
  };
2046
2180
  function defaultWebSocketFactory(url, protocols) {