Yjs integration with Milkdown Collab plugin #1993
-
I'm working on a collaborative editor with Milkdown but I am encountering some issues with the editors not syncing properly at times. It looks like at some point the server and client have a different version of the ydoc and therefore cannot integrate new updates coming in. Client
Client setup// Setup collaboration
const collabServiceRef = useRef<CollabService>();
const editor = loading ? null : get() || null;
useEffect(() => {
if (!editor || !wsUrl) return undefined;
const doc = new Y.Doc();
yDocRef.current = doc;
let wsProvider: WebsocketProvider;
let retryDelay = 1000;
const maxDelay = 30000;
const connect = () => {
wsProvider = new WebsocketProvider(wsUrl, props.docId, doc, { connect: true });
editor.action((ctx: Ctx) => {
collabServiceRef.current = ctx.get(collabServiceCtx);
collabServiceRef.current?.bindDoc(doc).setAwareness(wsProvider!.awareness);
if (props.base64yDoc) {
console.log('Applying base64yDoc to editor');
Y.applyUpdate(doc, base64docToUint8Array(props.base64yDoc));
}
});
wsProvider.on('status', (payload: { status: string }) => {
console.log(`Editor Status: ${payload.status}`);
props.setColabStatus(payload.status);
});
wsProvider.awareness.setLocalStateField('user', {
name: displayName ?? userEntityRef,
color: `#${randomColor()}`,
});
wsProvider.once('sync', isSynced => {
if (isSynced) {
console.log('Synced with Yjs server');
if (props.template) {
console.log('Applying template to editor');
collabServiceRef.current?.applyTemplate(props.template);
}
collabServiceRef.current?.connect();
}
});
wsProvider.on('connection-error', (ev) => {
console.warn(`WS connection failed: ${ev}, retrying...`);
wsProvider?.disconnect();
retryDelay = Math.min(retryDelay * 2, maxDelay);
setTimeout(connect, retryDelay);
});
};
connect();
return () => {
collabServiceRef.current?.disconnect();
wsProvider?.disconnect();
};
}, [editor, wsUrl, props.docId]); Server
Yjs server setupsetupWsConnection = async (
conn: WebSocket,
req: http.IncomingMessage,
): Promise<void> => {
conn.binaryType = 'arraybuffer';
const docId = req.url?.slice(1).split('?')[0] as string
// Get doc, initialize if it does not exist yet
const [doc, isNewDoc] = this.getYDoc(docId);
doc.conns.set(conn, new Set());
if (isNewDoc) {
this.logger.info(`Document doesn't exist in memory yet, fetching latest updates from Redis ${docId}`);
const redisUpdates = await this.redisClient.getDocUpdatesFromQueue(doc);
const redisYDoc = new Y.Doc();
redisYDoc.transact(() => {
for (const u of redisUpdates) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.readSyncMessage(decoding.createDecoder(u), encoder, redisYDoc, this.redisClient.getRedis());
}
});
this.logger.info(`Applying ${redisUpdates.length} updates from Redis to document ${docId}`);
this.logger.debug(`redisYDoc ${redisYDoc.getXmlElement('prosemirror')} and doc ${doc.getXmlElement('prosemirror')}`);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(redisYDoc));
}
// Listen and reply to events
conn.on('message', (message: WSData) => {
this.messageListener(conn, doc, new Uint8Array(message as ArrayBuffer));
});
// Check if connection is still alive
let pongReceived = true;
const pingInterval = setInterval(() => {
if (!pongReceived) {
if (doc.conns.has(conn)) {
closeConn(doc, conn);
}
clearInterval(pingInterval);
} else if (doc.conns.has(conn)) {
pongReceived = false;
try {
conn.ping();
} catch (e) {
closeConn(doc, conn);
clearInterval(pingInterval);
}
}
}, pingTimeout);
// send sync step 1
this.logger.debug(`Sending sync steps for document ${docId}`);
send(doc, conn, encodeSyncStep1(Y.encodeStateVector(doc)))
send(doc, conn, encodeSyncStep2(Y.encodeStateAsUpdate(doc)))
if (doc.awareness.states.size > 0) {
send(doc, conn, encodeAwarenessUpdate(doc.awareness, array.from(doc.awareness.states.keys())))
}
conn.on('close', () => {
closeConn(doc, conn);
clearInterval(pingInterval);
});
conn.on('pong', () => {
pongReceived = true;
});
} ThoughtsIn most examples I have seen the document is stored in a persistend database (e.g. postgres) from the server. However, I would like to have the editor as a plugin. With the plugin-user having control and ability on where to store and potential expose different parts of the content. By this point I am not sure if this is even possible and just an anti-pattern that should be avoided. |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 4 replies
-
IMO it's not possible. If your client can decide whether to sync it's content to server. Then maybe all clients are just cover contents from another and that's not controlled by CRDT. |
Beta Was this translation helpful? Give feedback.
-
@lexhuismans-wk You should use But , milkdown does not fully support nodejs @Saul-Mirone |
Beta Was this translation helpful? Give feedback.
@lexhuismans-wk You should use
@hocuspocus/server
as milkdown collab-server to save doc to database, see https://github.com/orgs/Milkdown/discussions/1756But , milkdown does not fully support nodejs @Saul-Mirone