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.
- package/CHANGELOG.md +30 -0
- package/dist/centrifuge.js +19 -3
- package/dist/centrifuge.js.map +2 -2
- package/dist/{chunk-PNYWDVTH.js → chunk-W7DAXK4D.js} +92 -2
- package/dist/{chunk-PNYWDVTH.js.map → chunk-W7DAXK4D.js.map} +3 -3
- package/dist/cjs/centrifuge.cjs +112 -3
- package/dist/cjs/centrifuge.cjs.map +3 -3
- package/dist/cjs/index.cjs +140 -1
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/core/data-bus.d.ts +32 -1
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/replay-persistence.d.ts +13 -0
- package/dist/core/replay-persistence.d.ts.map +1 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +50 -1
- package/dist/index.js.map +3 -3
- package/docs/README.md +2 -1
- package/docs/api.md +1 -0
- package/docs/getting-started.md +2 -0
- package/docs/roadmap.md +18 -0
- package/docs/zh/README.md +2 -1
- package/docs/zh/api.md +1 -0
- package/docs/zh/getting-started.md +2 -0
- package/docs/zh/roadmap.md +18 -0
- package/package.json +4 -2
package/dist/cjs/index.cjs
CHANGED
|
@@ -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 {
|