@liveblocks/yjs
Version:
Integrate your existing or new Yjs documents with Liveblocks.
550 lines (538 loc) • 15.8 kB
JavaScript
// src/index.ts
import { detectDupes } from "@liveblocks/core";
// src/version.ts
var PKG_NAME = "@liveblocks/yjs";
var PKG_VERSION = "2.22.3";
var PKG_FORMAT = "esm";
// src/provider.ts
import { ClientMsgCode, kInternal } 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 { parseUpdateMeta, 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 { Base64 } from "js-base64";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
var yDocHandler = class extends Observable {
unsubscribers = [];
_synced = false;
doc;
updateRoomDoc;
fetchRoomDoc;
useV2Encoding;
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();
}
handleServerUpdate = ({
update,
stateVector,
readOnly,
v2
}) => {
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;
}
};
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]);
}
}
updateHandler = (update, origin) => {
const isFromLocal = origin instanceof IndexeddbPersistence;
if (origin !== "backend" && !isFromLocal) {
this.updateRoomDoc(update);
}
};
destroy() {
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 = /* @__PURE__ */ new Map();
permanentUserData;
pending = [];
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.emit("status", [this.getStatus()]);
})
);
this.unsubscribers.push(
this.room.events.ydoc.subscribe((message) => {
const { type } = message;
if (type === ClientMsgCode.UPDATE_YDOC) {
return;
}
const { stateVector, update: updateStr, guid, v2 } = message;
const canWrite = this.room.getSelf()?.canWrite ?? true;
const update = Base642.toUint8Array(updateStr);
let foundPendingUpdate = false;
const updateId = this.getUniqueUpdateId(update);
this.pending = this.pending.filter((pendingUpdate) => {
if (pendingUpdate === updateId) {
foundPendingUpdate = true;
return false;
}
return true;
});
if (!foundPendingUpdate) {
if (guid !== void 0) {
this.subdocHandlers.get(guid)?.handleServerUpdate({
update,
stateVector,
readOnly: !canWrite,
v2
});
} else {
this.rootDocHandler.handleServerUpdate({
update,
stateVector,
readOnly: !canWrite,
v2
});
}
}
this.emit("status", [this.getStatus()]);
})
);
if (options.offlineSupport_experimental) {
this.setupOfflineSupport();
}
this.rootDocHandler.on("synced", () => {
const state = this.rootDocHandler.synced;
for (const [_, handler] of this.subdocHandlers) {
handler.syncDoc();
}
this.emit("synced", [state]);
this.emit("sync", [state]);
this.emit("status", [this.getStatus()]);
});
this.rootDoc.on("subdocs", this.handleSubdocs);
this.syncDoc();
}
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);
if (this.options.autoloadSubdocs) {
for (const subdoc of added) {
if (!this.subdocHandlers.has(subdoc.guid)) {
subdoc.load();
}
}
}
for (const subdoc of removed) {
if (this.subdocHandlers.has(subdoc.guid)) {
this.subdocHandlers.get(subdoc.guid)?.destroy();
this.subdocHandlers.delete(subdoc.guid);
}
}
};
getUniqueUpdateId = (update) => {
const clock = parseUpdateMeta(update).to.get(this.rootDoc.clientID) ?? "-1";
return this.rootDoc.clientID + ":" + clock;
};
updateDoc = (update, guid) => {
const canWrite = this.room.getSelf()?.canWrite ?? true;
if (canWrite && !this.isPaused) {
const updateId = this.getUniqueUpdateId(update);
this.pending.push(updateId);
this.room.updateYDoc(
Base642.fromUint8Array(update),
guid,
this.useV2Encoding
);
this.emit("status", [this.getStatus()]);
}
};
fetchDoc = (vector, guid) => {
this.room.fetchYDoc(vector, guid, this.useV2Encoding);
};
createSubdocHandler = (subdoc) => {
if (this.subdocHandlers.has(subdoc.guid)) {
this.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
});
this.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) {
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() {
if (!this.synced) {
return "loading";
}
return this.pending.length === 0 ? "synchronized" : "synchronizing";
}
destroy() {
this.unsubscribers.forEach((unsub) => unsub());
this.awareness.destroy();
this.rootDocHandler.destroy();
this._observers = /* @__PURE__ */ new Map();
for (const [_, handler] of this.subdocHandlers) {
handler.destroy();
}
this.subdocHandlers.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() {
}
};
// src/providerContext.ts
import { Doc } from "yjs";
var providersMap = /* @__PURE__ */ new WeakMap();
var getYjsProviderForRoom = (room, options = {}) => {
const provider = providersMap.get(room);
if (provider !== void 0) {
return provider;
}
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