@collabs/tab-sync
Version:
Collabs cross-tab synchronization using BroadcastChannel
186 lines • 7.19 kB
JavaScript
import { ReplicaIDs } from "@collabs/collabs";
import { EventEmitter, nonNull } from "@collabs/core";
/**
* Syncs updates to Collabs documents across different tabs for the same origin,
* using BroadcastChannel.
*
* By default, this only forwards *local* operations to other tabs. Updates from other sources (e.g., a remote server via
* [@collabs/ws-client](https://www.npmjs.com/package/@collabs/ws-client))
* are not sent over the BroadcastChannel, since we expect that other tabs will
* get a copy from their own sources. You can override this with the `allUpdates`
* constructor option.
*
* Likewise, our other providers do not forward or store
* operations from TabSyncNetwork. Instead, it is expected that
* each tab sets up its own providers to forward/store updates.
*/
export class TabSyncNetwork extends EventEmitter {
/**
* Constructs a TabSyncNetwork.
*
* You typically only need one TabSyncNetwork per app, since it
* can [[subscribe]] multiple documents.
*
* @param options.bcName The name of the BroadcastChannel to use.
* Default: "@collabs/tab-sync".
* @param options.allUpdates Set to true to forward all doc updates over
* the BroadcastChannel, not just local operations.
*/
constructor(options = {}) {
var _a, _b;
super();
this.subs = new Map();
/** Inverse map docID -> Doc. */
this.docsByID = new Map();
this.closed = false;
this.isTabSyncNetwork = true;
this.bcName = (_a = options.bcName) !== null && _a !== void 0 ? _a : "@collabs/tab-sync";
this.allUpdates = (_b = options.allUpdates) !== null && _b !== void 0 ? _b : false;
this.objID = ReplicaIDs.random();
this.bc = new BroadcastChannel(this.bcName);
this.bc.addEventListener("message", (e) => this.bcReceive(e.data));
this.bc.addEventListener("messageerror", (e) => this.emit("Error", { err: e }));
}
sendInternal(message) {
this.bc.postMessage(message);
}
/**
* Subscribes `doc` to updates for `docID`.
*
* `doc` will send and receive updates with other tabs
* that are subscribed to `docID`. It will also sync initial states with
* other tabs, to ensure that they start up-to-date.
*
* @param doc The document to subscribe.
* @param docID An arbitrary string that identifies which updates to use.
* @throws If `doc` is already subscribed to a docID.
* @throws If another doc is subscribed to `docID`.
*/
subscribe(doc, docID) {
if (this.closed)
throw new Error("Already closed");
if (this.subs.has(doc)) {
throw new Error("doc is already subscribed to a docID");
}
if (this.docsByID.has(docID)) {
throw new Error("Unsupported: multiple docs with same docID");
}
const info = { docID };
this.subs.set(doc, info);
this.docsByID.set(docID, doc);
// Call save() in a separate task, to match other networks
// (in particular, they don't block for long during subscribe()).
setTimeout(() => {
if (info.unsubscribed)
return;
// Broadcast our current state.
// We will get a reply from each other peer with their current
// state, bringing us up-to-date.
this.sendInternal({
type: "join",
senderID: this.objID,
docID,
savedState: doc.save(),
});
// Subscribe to future updates.
info.off = doc.on("Update", (e) => {
// Skip updates that we delivered.
if (e.caller === this)
return;
// Skip non-local updates unless allUpdates is true.
if (!(this.allUpdates || e.isLocalOp))
return;
this.sendInternal({
type: "update",
docID,
updateType: e.updateType,
update: e.update,
});
});
// Note: the above pattern (initial state-based sync followed by
// update forwarding) is a good strategy for peer-to-peer
// networks in general.
}, 0);
}
/**
* Unsubscribes `doc` from its subscribed `docID` (if any).
*
* `doc` will no longer send or receive updates with other tabs.
*/
unsubscribe(doc) {
const info = this.subs.get(doc);
if (info === undefined)
return;
info.unsubscribed = true;
this.subs.delete(doc);
this.docsByID.delete(info.docID);
if (info.off !== undefined)
info.off();
}
bcReceive(message) {
const doc = this.docsByID.get(message.docID);
if (doc === undefined)
return;
const info = nonNull(this.subs.get(doc));
switch (message.type) {
case "join":
// Reply with our state.
// OPT: use a delta on top of the peer's state instead.
this.sendInternal({
type: "joinReply",
targetID: message.senderID,
docID: message.docID,
savedState: doc.save(),
});
// Merge the new peer's state into ours.
// Do it in a separate task to avoid blocking for too long.
setTimeout(() => {
if (info.unsubscribed)
return;
doc.load(message.savedState, this);
});
break;
case "joinReply":
if (message.targetID !== this.objID)
return;
// Merge the peer's state into ours.
doc.load(message.savedState, this);
break;
case "update":
// Apply the update.
switch (message.updateType) {
case "message":
doc.receive(message.update, this);
break;
case "savedState":
doc.load(message.update, this);
break;
default:
this.emit("Error", {
err: `Unrecognized message.updateType ${message.updateType} on ${message}`,
});
}
break;
default:
this.emit("Error", {
err: `Unrecognized message.type ${message.type} on ${message}`,
});
}
}
/**
* Closes our BroadcastChannel and unsubscribes all documents.
*
* Future [[subscribe]] calls will throw an error.
*/
close() {
if (this.closed)
return;
this.closed = true;
// Unsubscribe all docs.
for (const doc of this.subs.keys())
this.unsubscribe(doc);
// Close our BroadcastChannel.
this.bc.close();
}
}
//# sourceMappingURL=tab_sync_network.js.map