From 230094b95641f942b1f5d7d319d74b25f0218964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 01:12:13 +0100 Subject: [PATCH] Fix Yjs WebSocket in dev mode - Add Vite plugin implementing y-protocols sync server for dev mode - Fix useYjs hook: move provider creation into useEffect to survive React StrictMode remounts - Fix WebSocket URL: use /sync as base (y-websocket appends room name) - Verified in browser: Connected status, Host badge, awareness working Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/use-yjs.ts | 42 +++++---- apps/planner/app/lib/vite-yjs-plugin.ts | 119 ++++++++++++++++++++++++ apps/planner/package.json | 2 + apps/planner/vite.config.ts | 3 +- pnpm-lock.yaml | 6 ++ 5 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 apps/planner/app/lib/vite-yjs-plugin.ts diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index b1efa7b..7170b87 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -27,47 +27,49 @@ export interface YjsState { export function useYjs(sessionId: string): YjsState | null { const [state, setState] = useState(null); - const [connected, setConnected] = useState(false); - const initialized = useRef(false); + const providerRef = useRef(null); + const docRef = useRef(null); useEffect(() => { - if (initialized.current) return; - initialized.current = true; - const doc = new Y.Doc(); - const wsUrl = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/sync/${sessionId}`; + docRef.current = doc; + + const wsUrl = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/sync`; const provider = new WebsocketProvider(wsUrl, sessionId, doc); + providerRef.current = provider; const waypoints = doc.getArray>("waypoints"); const routeData = doc.getMap("routeData"); - // Set local awareness state const color = randomItem(COLORS); const name = randomItem(NAMES); provider.awareness.setLocalStateField("user", { color, name }); + const updateState = (connected: boolean) => { + setState({ + doc, + provider, + waypoints, + routeData, + awareness: provider.awareness, + connected, + }); + }; + provider.on("status", ({ status }: { status: string }) => { - setConnected(status === "connected"); + updateState(status === "connected"); }); - setState({ - doc, - provider, - waypoints, - routeData, - awareness: provider.awareness, - connected: false, - }); + // Set initial state (even before connection) + updateState(false); return () => { provider.destroy(); doc.destroy(); + providerRef.current = null; + docRef.current = null; }; }, [sessionId]); - if (state) { - state.connected = connected; - } - return state; } diff --git a/apps/planner/app/lib/vite-yjs-plugin.ts b/apps/planner/app/lib/vite-yjs-plugin.ts new file mode 100644 index 0000000..219a6ef --- /dev/null +++ b/apps/planner/app/lib/vite-yjs-plugin.ts @@ -0,0 +1,119 @@ +import type { Plugin } from "vite"; +import { WebSocketServer, type WebSocket } from "ws"; +import * as Y from "yjs"; +import * as syncProtocol from "y-protocols/sync"; +import * as awarenessProtocol from "y-protocols/awareness"; +import * as encoding from "lib0/encoding"; +import * as decoding from "lib0/decoding"; + +const messageSync = 0; +const messageAwareness = 1; + +/** + * Vite plugin that runs a Yjs sync server in dev mode. + * Implements the y-websocket protocol for full compatibility. + */ +export function yjsDevPlugin(): Plugin { + const docs = new Map(); + const awarenessMap = new Map(); + const conns = new Map }>(); + + function getDoc(docName: string): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { + let doc = docs.get(docName); + let awareness = awarenessMap.get(docName); + if (!doc) { + doc = new Y.Doc(); + docs.set(docName, doc); + awareness = new awarenessProtocol.Awareness(doc); + awarenessMap.set(docName, awareness); + + awareness.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => { + const changedClients = added.concat(updated, removed); + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageAwareness); + encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness!, changedClients)); + const message = encoding.toUint8Array(encoder); + + for (const [ws, meta] of conns) { + if (meta.docName === docName) { + ws.send(message); + } + } + }); + } + return { doc, awareness: awareness! }; + } + + function handleMessage(ws: WebSocket, message: Uint8Array, docName: string) { + const { doc, awareness } = getDoc(docName); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + + switch (messageType) { + case messageSync: { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.readSyncMessage(decoder, encoder, doc, ws); + const resp = encoding.toUint8Array(encoder); + if (encoding.length(encoder) > 1) { + ws.send(resp); + } + break; + } + case messageAwareness: { + const update = decoding.readVarUint8Array(decoder); + awarenessProtocol.applyAwarenessUpdate(awareness, update, ws); + break; + } + } + } + + return { + name: "yjs-dev-websocket", + configureServer(server) { + const wss = new WebSocketServer({ noServer: true }); + + server.httpServer?.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "", `http://${request.headers.host}`); + if (!url.pathname.startsWith("/sync/")) return; + + const docName = url.pathname.slice("/sync/".length); + + wss.handleUpgrade(request, socket, head, (ws) => { + const { doc, awareness } = getDoc(docName); + conns.set(ws, { docName, awarenessIds: new Set() }); + + // Send sync step 1 + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.writeSyncStep1(encoder, doc); + ws.send(encoding.toUint8Array(encoder)); + + // Send awareness states + const awarenessStates = awareness.getStates(); + if (awarenessStates.size > 0) { + const awarenessEncoder = encoding.createEncoder(); + encoding.writeVarUint(awarenessEncoder, messageAwareness); + encoding.writeVarUint8Array( + awarenessEncoder, + awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(awarenessStates.keys())), + ); + ws.send(encoding.toUint8Array(awarenessEncoder)); + } + + ws.on("message", (data: ArrayBuffer) => { + handleMessage(ws, new Uint8Array(data), docName); + }); + + ws.on("close", () => { + const meta = conns.get(ws); + if (meta) { + awarenessProtocol.removeAwarenessStates(awareness, Array.from(meta.awarenessIds), null); + } + conns.delete(ws); + }); + }); + }); + }, + }; +} diff --git a/apps/planner/package.json b/apps/planner/package.json index fb2a791..83258b0 100644 --- a/apps/planner/package.json +++ b/apps/planner/package.json @@ -21,10 +21,12 @@ "@trails-cool/ui": "workspace:*", "drizzle-orm": "catalog:", "isbot": "^5.1.0", + "lib0": "^0.2.117", "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", "ws": "^8.20.0", + "y-protocols": "^1.0.7", "y-websocket": "^3.0.0", "yjs": "^13.6.30" }, diff --git a/apps/planner/vite.config.ts b/apps/planner/vite.config.ts index 3493018..311be5a 100644 --- a/apps/planner/vite.config.ts +++ b/apps/planner/vite.config.ts @@ -2,9 +2,10 @@ import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import path from "node:path"; +import { yjsDevPlugin } from "./app/lib/vite-yjs-plugin"; export default defineConfig({ - plugins: [tailwindcss(), reactRouter()], + plugins: [tailwindcss(), reactRouter(), yjsDevPlugin()], resolve: { alias: { "~": path.resolve(__dirname, "./app"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a8f60a..b3a678b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,6 +255,9 @@ importers: isbot: specifier: ^5.1.0 version: 5.1.36 + lib0: + specifier: ^0.2.117 + version: 0.2.117 react: specifier: 'catalog:' version: 19.2.4 @@ -267,6 +270,9 @@ importers: ws: specifier: ^8.20.0 version: 8.20.0 + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.30) y-websocket: specifier: ^3.0.0 version: 3.0.0(yjs@13.6.30)