UNPKG

@collabs/tab-sync

Version:

Collabs cross-tab synchronization using BroadcastChannel

186 lines 7.19 kB
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