@collabs/tab-sync
Version:
Collabs cross-tab synchronization using BroadcastChannel
248 lines (215 loc) • 7.35 kB
text/typescript
import { AbstractDoc, CRuntime, ReplicaIDs } from "@collabs/collabs";
import { EventEmitter, nonNull } from "@collabs/core";
type Doc = AbstractDoc | CRuntime;
interface DocInfo {
readonly docID: string;
off?: () => void;
unsubscribed?: true;
}
type Message =
| {
type: "update";
docID: string;
updateType: "message" | "savedState";
update: Uint8Array;
}
| { type: "join"; senderID: string; docID: string; savedState: Uint8Array }
| {
type: "joinReply";
targetID: string;
docID: string;
savedState: Uint8Array;
};
/** Events record for [[TabSyncNetwork]]. */
export interface TabSyncNetworkEventsRecord {
/**
* Emitted when there is an error, e.g., we fail to parse a message.
*/
Error: { err: unknown };
}
/**
* 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<TabSyncNetworkEventsRecord> {
/**
* The name of this class's BroadcastChannel,
* set in the constructor.
*
* Default: "@collabs/tab-sync".
*/
readonly bcName: string;
private readonly allUpdates: boolean;
private readonly bc: BroadcastChannel;
/** A random senderID for this object. */
private readonly objID: string;
private readonly subs = new Map<Doc, DocInfo>();
/** Inverse map docID -> Doc. */
private readonly docsByID = new Map<string, Doc>();
private closed = false;
readonly isTabSyncNetwork = true;
/**
* 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: { bcName?: string; allUpdates?: boolean } = {}) {
super();
this.bcName = options.bcName ?? "@collabs/tab-sync";
this.allUpdates = options.allUpdates ?? false;
this.objID = ReplicaIDs.random();
this.bc = new BroadcastChannel(this.bcName);
this.bc.addEventListener("message", (e) =>
this.bcReceive(e.data as Message)
);
this.bc.addEventListener("messageerror", (e) =>
this.emit("Error", { err: e })
);
}
private sendInternal(message: 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: AbstractDoc | CRuntime, docID: string) {
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: DocInfo = { 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: AbstractDoc | CRuntime) {
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();
}
private bcReceive(message: 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 ${(<any>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();
}
}