fix: HIGH issues #13,#14,#18,#19,#20 — frontend state/sync: copy-paste selection, dynamic activeLayer, group creation, Yjs awareness protocol, connect sync
This commit is contained in:
Generated
+20
@@ -15,6 +15,7 @@
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^4.28.0",
|
||||
"y-leveldb": "^0.2.0",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3868,6 +3869,25 @@
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y-protocols": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.85"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.31",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^4.28.0",
|
||||
"y-leveldb": "^0.2.0",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
/**
|
||||
* Yjs WebSocket Server – Real-time collaboration with LevelDB persistence
|
||||
* Yjs WebSocket Server – Real-time collaboration with LevelDB persistence.
|
||||
*
|
||||
* Message protocol (byte-prefixed):
|
||||
* 0 = Y.Doc update (Y.encodeStateAsUpdate or Y.encodeStateAsUpdateYUpdate)
|
||||
* 1 = Awareness update (encodeAwarenessUpdate from y-protocols/awareness)
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import { LeveldbPersistence } from 'y-leveldb';
|
||||
import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
|
||||
|
||||
// Message type prefixes (must match frontend WebSocketProvider.ts)
|
||||
const MSG_DOC_UPDATE = 0;
|
||||
const MSG_AWARENESS = 1;
|
||||
|
||||
// Document store: docName → Y.Doc
|
||||
const docs = new Map<string, Y.Doc>();
|
||||
// Awareness store: docName → Awareness
|
||||
const awarenessMap = new Map<string, Awareness>();
|
||||
// Connection tracking: docName → Set<WebSocket>
|
||||
const connections = new Map<string, Set<WebSocket>>();
|
||||
let persistence: LeveldbPersistence | null = null;
|
||||
@@ -46,6 +57,10 @@ async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
|
||||
const doc = new Y.Doc();
|
||||
docs.set(docName, doc);
|
||||
|
||||
// Create awareness instance for this document
|
||||
const awareness = new Awareness(doc);
|
||||
awarenessMap.set(docName, awareness);
|
||||
|
||||
// Load persisted state if available
|
||||
if (persistence && persistenceReady) {
|
||||
try {
|
||||
@@ -72,14 +87,23 @@ async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
|
||||
return doc;
|
||||
}
|
||||
|
||||
function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocket): void {
|
||||
function getOrCreateAwareness(docName: string): Awareness | null {
|
||||
return awarenessMap.get(docName) ?? null;
|
||||
}
|
||||
|
||||
function broadcastUpdate(docName: string, update: Uint8Array, msgType: number, exclude?: WebSocket): void {
|
||||
const conns = connections.get(docName);
|
||||
if (!conns) return;
|
||||
|
||||
// Prepend message type byte
|
||||
const msg = new Uint8Array(update.length + 1);
|
||||
msg[0] = msgType;
|
||||
msg.set(update, 1);
|
||||
|
||||
for (const ws of conns) {
|
||||
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
|
||||
try {
|
||||
ws.send(update);
|
||||
ws.send(msg);
|
||||
} catch {
|
||||
// Connection error — will be cleaned up on close
|
||||
}
|
||||
@@ -108,6 +132,7 @@ export function registerYjsWebSocket(fastify: FastifyInstance, authService: Auth
|
||||
}
|
||||
|
||||
const doc = await getOrCreateDoc(docName);
|
||||
const awareness = getOrCreateAwareness(docName);
|
||||
|
||||
// Track connection
|
||||
if (!connections.has(docName)) {
|
||||
@@ -115,18 +140,50 @@ export function registerYjsWebSocket(fastify: FastifyInstance, authService: Auth
|
||||
}
|
||||
connections.get(docName)!.add(socket);
|
||||
|
||||
// Send current document state to new client
|
||||
// Send current document state to new client (with MSG_DOC_UPDATE prefix)
|
||||
const stateUpdate = Y.encodeStateAsUpdate(doc);
|
||||
if (stateUpdate.length > 0 && socket.readyState === 1) {
|
||||
socket.send(stateUpdate);
|
||||
const msg = new Uint8Array(stateUpdate.length + 1);
|
||||
msg[0] = MSG_DOC_UPDATE;
|
||||
msg.set(stateUpdate, 1);
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
// Listen for updates from this client
|
||||
// Send current awareness states to new client
|
||||
if (awareness && awareness.getStates().size > 0 && socket.readyState === 1) {
|
||||
const awarenessUpdate = encodeAwarenessUpdate(awareness, Array.from(awareness.getStates().keys()));
|
||||
if (awarenessUpdate.length > 0) {
|
||||
const msg = new Uint8Array(awarenessUpdate.length + 1);
|
||||
msg[0] = MSG_AWARENESS;
|
||||
msg.set(awarenessUpdate, 1);
|
||||
socket.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for messages from this client
|
||||
socket.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const update = new Uint8Array(data);
|
||||
Y.applyUpdate(doc, update);
|
||||
broadcastUpdate(docName, update, socket);
|
||||
const raw = new Uint8Array(data);
|
||||
if (raw.length === 0) return;
|
||||
|
||||
const msgType = raw[0];
|
||||
const payload = raw.slice(1);
|
||||
|
||||
if (msgType === MSG_AWARENESS) {
|
||||
// Awareness update — apply to server awareness and broadcast
|
||||
if (awareness) {
|
||||
applyAwarenessUpdate(awareness, payload, socket);
|
||||
broadcastUpdate(docName, payload, MSG_AWARENESS, socket);
|
||||
}
|
||||
} else if (msgType === MSG_DOC_UPDATE) {
|
||||
// Y.Doc update — apply to server doc and broadcast
|
||||
Y.applyUpdate(doc, payload);
|
||||
broadcastUpdate(docName, payload, MSG_DOC_UPDATE, socket);
|
||||
} else {
|
||||
// Legacy: treat entire message as a Y.Doc update (no prefix)
|
||||
Y.applyUpdate(doc, raw);
|
||||
broadcastUpdate(docName, raw, MSG_DOC_UPDATE, socket);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Invalid update from client for ${docName}:`, err);
|
||||
}
|
||||
@@ -137,6 +194,26 @@ export function registerYjsWebSocket(fastify: FastifyInstance, authService: Auth
|
||||
const conns = connections.get(docName);
|
||||
if (conns) {
|
||||
conns.delete(socket);
|
||||
|
||||
// Remove this client's awareness state
|
||||
if (awareness) {
|
||||
// Find and remove the client's awareness state based on socket origin
|
||||
const statesToRemove: number[] = [];
|
||||
for (const [clientID, meta] of awareness.meta) {
|
||||
if (meta === socket) {
|
||||
statesToRemove.push(clientID);
|
||||
}
|
||||
}
|
||||
if (statesToRemove.length > 0) {
|
||||
removeAwarenessStates(awareness, statesToRemove, socket);
|
||||
// Broadcast removal to remaining clients
|
||||
const removalUpdate = encodeAwarenessUpdate(awareness, statesToRemove);
|
||||
if (removalUpdate.length > 0) {
|
||||
broadcastUpdate(docName, removalUpdate, MSG_AWARENESS, socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conns.size === 0) {
|
||||
connections.delete(docName);
|
||||
// Keep doc in memory for reconnection (persistence handles durability)
|
||||
|
||||
Reference in New Issue
Block a user