@liveblocks/yjs
Version:
Integrate your existing or new Yjs documents with Liveblocks.
624 lines (612 loc) • 18.4 kB
JavaScript
// src/index.ts
import { detectDupes } from "@liveblocks/core";
// src/version.ts
var PKG_NAME = "@liveblocks/yjs";
var PKG_VERSION = "3.15.5";
var PKG_FORMAT = "esm";
// src/provider.ts
import {
DerivedSignal as DerivedSignal2
} from "@liveblocks/core";
import { ClientMsgCode, kInternal, MutableSignal } from "@liveblocks/core";
import { Base64 as Base642 } from "js-base64";
// ../../node_modules/lib0/map.js
var create = () => /* @__PURE__ */ new Map();
var setIfUndefined = (map, key, createT) => {
let set = map.get(key);
if (set === void 0) {
map.set(key, set = createT());
}
return set;
};
// ../../node_modules/lib0/set.js
var create2 = () => /* @__PURE__ */ new Set();
// ../../node_modules/lib0/array.js
var from = Array.from;
var isArray = Array.isArray;
// ../../node_modules/lib0/observable.js
var Observable = class {
constructor() {
this._observers = create();
}
/**
* @param {N} name
* @param {function} f
*/
on(name, f) {
setIfUndefined(this._observers, name, create2).add(f);
}
/**
* @param {N} name
* @param {function} f
*/
once(name, f) {
const _f = (...args) => {
this.off(name, _f);
f(...args);
};
this.on(name, _f);
}
/**
* @param {N} name
* @param {function} f
*/
off(name, f) {
const observers = this._observers.get(name);
if (observers !== void 0) {
observers.delete(f);
if (observers.size === 0) {
this._observers.delete(name);
}
}
}
/**
* Emit a named event. All registered event listeners that listen to the
* specified name will receive the event.
*
* @todo This should catch exceptions
*
* @param {N} name The event name.
* @param {Array<any>} args The arguments that are applied to the event listener.
*/
emit(name, args) {
return from((this._observers.get(name) || create()).values()).forEach((f) => f(...args));
}
destroy() {
this._observers = create();
}
};
// src/provider.ts
import { IndexeddbPersistence as IndexeddbPersistence2 } from "y-indexeddb";
import { PermanentUserData } from "yjs";
// src/awareness.ts
var Y_PRESENCE_KEY = "__yjs";
var Y_PRESENCE_ID_KEY = "__yjs_clientid";
var Awareness = class extends Observable {
room;
doc;
states = /* @__PURE__ */ new Map();
// used to map liveblock's ActorId to Yjs ClientID, both unique numbers representing a client
actorToClientMap = /* @__PURE__ */ new Map();
// Meta is used to keep track and timeout users who disconnect. Liveblocks provides this for us, so we don't need to
// manage it here. Unfortunately, it's expected to exist by various integrations, so it's an empty map.
meta = /* @__PURE__ */ new Map();
// _checkInterval this would hold a timer to remove users, but Liveblock's presence already handles this
// unfortunately it's typed by various integrations
_checkInterval = 0;
othersUnsub;
constructor(doc, room) {
super();
this.doc = doc;
this.room = room;
this.room.updatePresence({
[Y_PRESENCE_ID_KEY]: this.doc.clientID
});
this.othersUnsub = this.room.events.others.subscribe((event) => {
let updates;
if (event.type === "leave") {
const targetClientId = this.actorToClientMap.get(
event.user.connectionId
);
if (targetClientId !== void 0) {
updates = { added: [], updated: [], removed: [targetClientId] };
}
this.rebuildActorToClientMap(event.others);
}
if (event.type === "enter" || event.type === "update") {
this.rebuildActorToClientMap(event.others);
const targetClientId = this.actorToClientMap.get(
event.user.connectionId
);
if (targetClientId !== void 0) {
updates = {
added: event.type === "enter" ? [targetClientId] : [],
updated: event.type === "update" ? [targetClientId] : [],
removed: []
};
}
}
if (event.type === "reset") {
this.rebuildActorToClientMap(event.others);
}
if (updates !== void 0) {
this.emit("change", [updates, "presence"]);
this.emit("update", [updates, "presence"]);
}
});
}
rebuildActorToClientMap(others) {
this.actorToClientMap.clear();
others.forEach((user) => {
if (user.presence[Y_PRESENCE_ID_KEY] !== void 0) {
this.actorToClientMap.set(
user.connectionId,
user.presence[Y_PRESENCE_ID_KEY]
);
}
});
}
destroy() {
this.emit("destroy", [this]);
this.othersUnsub();
this.setLocalState(null);
super.destroy();
}
getLocalState() {
const presence = this.room.getPresence();
if (Object.keys(presence).length === 0 || typeof presence[Y_PRESENCE_KEY] === "undefined") {
return null;
}
return presence[Y_PRESENCE_KEY];
}
setLocalState(state) {
const presence = this.room.getSelf()?.presence;
if (state === null) {
if (presence === void 0) {
return;
}
this.room.updatePresence({ ...presence, [Y_PRESENCE_KEY]: null });
this.emit("update", [
{ added: [], updated: [], removed: [this.doc.clientID] },
"local"
]);
return;
}
const yPresence = presence?.[Y_PRESENCE_KEY];
const added = yPresence === void 0 ? [this.doc.clientID] : [];
const updated = yPresence === void 0 ? [] : [this.doc.clientID];
this.room.updatePresence({
[Y_PRESENCE_KEY]: {
...yPresence || {},
...state || {}
}
});
this.emit("update", [{ added, updated, removed: [] }, "local"]);
}
setLocalStateField(field, value) {
const presence = this.room.getSelf()?.presence[Y_PRESENCE_KEY];
const update = { [field]: value };
this.room.updatePresence({
[Y_PRESENCE_KEY]: { ...presence || {}, ...update }
});
}
// Translate liveblocks presence to yjs awareness
getStates() {
const others = this.room.getOthers();
const states = others.reduce((acc, otherUser) => {
const otherPresence = otherUser.presence[Y_PRESENCE_KEY];
const otherClientId = otherUser.presence[Y_PRESENCE_ID_KEY];
if (otherPresence !== void 0 && otherClientId !== void 0) {
acc.set(otherClientId, otherPresence || {});
}
return acc;
}, /* @__PURE__ */ new Map());
const localPresence = this.room.getSelf()?.presence[Y_PRESENCE_KEY];
if (localPresence !== void 0) {
states.set(this.doc.clientID, localPresence);
}
return states;
}
};
// src/doc.ts
import {
DerivedSignal,
Signal
} from "@liveblocks/core";
import { sha256 } from "@noble/hashes/sha2";
import { Base64 } from "js-base64";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
var yDocHandler = class _yDocHandler extends Observable {
unsubscribers = [];
_synced = false;
doc;
updateRoomDoc;
fetchRoomDoc;
useV2Encoding;
localSnapshotHash\u03A3;
remoteSnapshotHash\u03A3;
hasEverSynced\u03A3;
debounceTimer = null;
static DEBOUNCE_INTERVAL_MS = 200;
isLocalAndRemoteSnapshotEqual\u03A3;
constructor({
doc,
isRoot,
updateDoc,
fetchDoc,
useV2Encoding
}) {
super();
this.doc = doc;
this.useV2Encoding = useV2Encoding;
this.doc.on(useV2Encoding ? "updateV2" : "update", this.updateHandler);
this.updateRoomDoc = (update) => {
updateDoc(update, isRoot ? void 0 : this.doc.guid);
};
this.fetchRoomDoc = (vector) => {
fetchDoc(vector, isRoot ? void 0 : this.doc.guid);
};
this.syncDoc();
const encodedSnapshot = this.useV2Encoding ? Y.encodeSnapshotV2(Y.snapshot(this.doc)) : Y.encodeSnapshot(Y.snapshot(this.doc));
this.localSnapshotHash\u03A3 = new Signal(
Base64.fromUint8Array(sha256(encodedSnapshot))
);
this.remoteSnapshotHash\u03A3 = new Signal(null);
this.hasEverSynced\u03A3 = new Signal(false);
this.isLocalAndRemoteSnapshotEqual\u03A3 = DerivedSignal.from(() => {
const remoteSnapshotHash = this.remoteSnapshotHash\u03A3.get();
if (remoteSnapshotHash === null) return false;
const localSnapshotHash = this.localSnapshotHash\u03A3.get();
if (localSnapshotHash !== remoteSnapshotHash) {
return false;
}
return true;
});
}
handleServerUpdate = ({
update,
stateVector,
readOnly,
v2,
remoteSnapshotHash
}) => {
const applyUpdate2 = v2 ? Y.applyUpdateV2 : Y.applyUpdate;
applyUpdate2(this.doc, update, "backend");
if (stateVector) {
if (!readOnly) {
try {
const encodeUpdate = this.useV2Encoding ? Y.encodeStateAsUpdateV2 : Y.encodeStateAsUpdate;
const localUpdate = encodeUpdate(
this.doc,
Base64.toUint8Array(stateVector)
);
this.updateRoomDoc(localUpdate);
} catch (e) {
console.warn(e);
}
}
this.synced = true;
this.hasEverSynced\u03A3.set(true);
}
this.remoteSnapshotHash\u03A3.set(remoteSnapshotHash);
};
syncDoc = () => {
this.synced = false;
const encodedVector = Base64.fromUint8Array(Y.encodeStateVector(this.doc));
this.fetchRoomDoc(encodedVector);
};
// The sync'd property is required by some provider implementations
get synced() {
return this._synced;
}
set synced(state) {
if (this._synced !== state) {
this._synced = state;
this.emit("synced", [state]);
this.emit("sync", [state]);
}
}
debounced_updateLocalSnapshot() {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
const encodedSnapshot = this.useV2Encoding ? Y.encodeSnapshotV2(Y.snapshot(this.doc)) : Y.encodeSnapshot(Y.snapshot(this.doc));
this.localSnapshotHash\u03A3.set(
Base64.fromUint8Array(sha256(encodedSnapshot))
);
this.debounceTimer = null;
}, _yDocHandler.DEBOUNCE_INTERVAL_MS);
}
updateHandler = (update, origin) => {
this.debounced_updateLocalSnapshot();
const isFromLocal = origin instanceof IndexeddbPersistence;
if (origin !== "backend" && !isFromLocal) {
this.updateRoomDoc(update);
}
};
experimental_getSyncStatus() {
const hasEverSynced = this.hasEverSynced\u03A3.get();
if (!hasEverSynced) {
return "loading";
}
if (!this.isLocalAndRemoteSnapshotEqual\u03A3.get()) {
return "synchronizing";
}
return "synchronized";
}
destroy() {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.doc.off("update", this.updateHandler);
this.unsubscribers.forEach((unsub) => unsub());
this._observers = /* @__PURE__ */ new Map();
this.doc.destroy();
}
};
// src/provider.ts
var LiveblocksYjsProvider = class extends Observable {
room;
rootDoc;
options;
indexeddbProvider = null;
isPaused = false;
unsubscribers = [];
awareness;
rootDocHandler;
subdocHandlers\u03A3 = new MutableSignal(/* @__PURE__ */ new Map());
syncStatus\u03A3;
permanentUserData;
constructor(room, doc, options = {}) {
super();
this.rootDoc = doc;
this.room = room;
this.options = options;
this.rootDocHandler = new yDocHandler({
doc,
isRoot: true,
updateDoc: this.updateDoc,
fetchDoc: this.fetchDoc,
useV2Encoding: this.options.useV2Encoding_experimental ?? false
});
if (this.options.enablePermanentUserData) {
this.permanentUserData = new PermanentUserData(doc);
}
room[kInternal].setYjsProvider(this);
this.awareness = new Awareness(this.rootDoc, this.room);
this.unsubscribers.push(
this.room.events.status.subscribe((status) => {
if (status === "connected") {
this.rootDocHandler.syncDoc();
} else {
this.rootDocHandler.synced = false;
}
})
);
this.unsubscribers.push(
this.room.events.ydoc.subscribe((message) => {
const { type } = message;
if (type === ClientMsgCode.UPDATE_YDOC) {
return;
}
const {
stateVector,
update: updateStr,
guid,
v2,
remoteSnapshotHash
} = message;
const canWrite = this.room.getSelf()?.canWrite ?? true;
const update = Base642.toUint8Array(updateStr);
if (guid !== void 0) {
this.subdocHandlers\u03A3.get().get(guid)?.handleServerUpdate({
update,
stateVector,
readOnly: !canWrite,
v2,
remoteSnapshotHash
});
} else {
this.rootDocHandler.handleServerUpdate({
update,
stateVector,
readOnly: !canWrite,
v2,
remoteSnapshotHash
});
}
})
);
if (options.offlineSupport_experimental) {
this.setupOfflineSupport();
}
this.rootDocHandler.on("synced", () => {
const state = this.rootDocHandler.synced;
for (const [_, handler] of this.subdocHandlers\u03A3.get()) {
handler.syncDoc();
}
this.emit("synced", [state]);
this.emit("sync", [state]);
});
this.rootDoc.on("subdocs", this.handleSubdocs);
this.syncDoc();
this.syncStatus\u03A3 = DerivedSignal2.from(() => {
const rootDocumentStatus = this.rootDocHandler.experimental_getSyncStatus();
if (rootDocumentStatus === "loading" || rootDocumentStatus === "synchronizing") {
return rootDocumentStatus;
}
const subdocumentStatuses = Array.from(
this.subdocHandlers\u03A3.get().values()
).map((handler) => handler.experimental_getSyncStatus());
if (subdocumentStatuses.some((state) => state !== "synchronized")) {
return "synchronizing";
}
return "synchronized";
});
this.emit("status", [this.getStatus()]);
this.unsubscribers.push(
this.syncStatus\u03A3.subscribe(() => {
this.emit("status", [this.getStatus()]);
})
);
}
setupOfflineSupport = () => {
this.indexeddbProvider = new IndexeddbPersistence2(
this.room.id,
this.rootDoc
);
const onIndexedDbSync = () => {
this.rootDocHandler.synced = true;
};
this.indexeddbProvider.on("synced", onIndexedDbSync);
this.unsubscribers.push(() => {
this.indexeddbProvider?.off("synced", onIndexedDbSync);
});
};
handleSubdocs = ({
loaded,
removed,
added
}) => {
loaded.forEach(this.createSubdocHandler);
const subdocHandlers = this.subdocHandlers\u03A3.get();
if (this.options.autoloadSubdocs) {
for (const subdoc of added) {
if (!subdocHandlers.has(subdoc.guid)) {
subdoc.load();
}
}
}
for (const subdoc of removed) {
if (subdocHandlers.has(subdoc.guid)) {
subdocHandlers.get(subdoc.guid)?.destroy();
subdocHandlers.delete(subdoc.guid);
}
}
};
updateDoc = (update, guid) => {
const canWrite = this.room.getSelf()?.canWrite ?? true;
if (canWrite && !this.isPaused) {
this.room.updateYDoc(
Base642.fromUint8Array(update),
guid,
this.useV2Encoding
);
}
};
fetchDoc = (vector, guid) => {
this.room.fetchYDoc(vector, guid, this.useV2Encoding);
};
createSubdocHandler = (subdoc) => {
const subdocHandlers = this.subdocHandlers\u03A3.get();
if (subdocHandlers.has(subdoc.guid)) {
subdocHandlers.get(subdoc.guid)?.syncDoc();
return;
}
const handler = new yDocHandler({
doc: subdoc,
isRoot: false,
updateDoc: this.updateDoc,
fetchDoc: this.fetchDoc,
useV2Encoding: this.options.useV2Encoding_experimental ?? false
});
subdocHandlers.set(subdoc.guid, handler);
};
// attempt to load a subdoc of a given guid
loadSubdoc = (guid) => {
for (const subdoc of this.rootDoc.subdocs) {
if (subdoc.guid === guid) {
subdoc.load();
return true;
}
}
return false;
};
syncDoc = () => {
this.rootDocHandler.syncDoc();
for (const [_, handler] of this.subdocHandlers\u03A3.get()) {
handler.syncDoc();
}
};
get useV2Encoding() {
return this.options.useV2Encoding_experimental ?? false;
}
// The sync'd property is required by some provider implementations
get synced() {
return this.rootDocHandler.synced;
}
async pause() {
await this.indexeddbProvider?.destroy();
this.indexeddbProvider = null;
this.isPaused = true;
}
unpause() {
this.isPaused = false;
if (this.options.offlineSupport_experimental) {
this.setupOfflineSupport();
}
this.rootDocHandler.syncDoc();
}
getStatus() {
return this.syncStatus\u03A3.get();
}
destroy() {
this.unsubscribers.forEach((unsub) => unsub());
this.awareness.destroy();
this.rootDocHandler.destroy();
this._observers = /* @__PURE__ */ new Map();
for (const [_, handler] of this.subdocHandlers\u03A3.get()) {
handler.destroy();
}
this.subdocHandlers\u03A3.get().clear();
super.destroy();
}
async clearOfflineData() {
if (!this.indexeddbProvider) return;
return this.indexeddbProvider.clearData();
}
getYDoc() {
return this.rootDoc;
}
// Some provider implementations expect to be able to call connect/disconnect, implement as noop
disconnect() {
}
connect() {
}
get subdocHandlers() {
return this.subdocHandlers\u03A3.get();
}
set subdocHandlers(value) {
this.subdocHandlers\u03A3.mutate((map) => {
map.clear();
for (const [key, handler] of value) {
map.set(key, handler);
}
});
}
};
// src/providerContext.ts
import { Doc } from "yjs";
var providersMap = /* @__PURE__ */ new WeakMap();
var getYjsProviderForRoom = (room, options = {}, forceNewProvider = false) => {
const provider = providersMap.get(room);
if (provider !== void 0) {
if (!forceNewProvider) {
return provider;
}
provider.destroy();
providersMap.delete(room);
}
const doc = new Doc();
const newProvider = new LiveblocksYjsProvider(room, doc, options);
room.events.roomWillDestroy.subscribeOnce(() => {
newProvider.destroy();
});
providersMap.set(room, newProvider);
return newProvider;
};
// src/index.ts
detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT);
export {
LiveblocksYjsProvider,
getYjsProviderForRoom
};
//# sourceMappingURL=index.js.map