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) <noreply@anthropic.com>
This commit is contained in:
parent
49e9d589b1
commit
230094b956
5 changed files with 151 additions and 21 deletions
|
|
@ -27,47 +27,49 @@ export interface YjsState {
|
|||
|
||||
export function useYjs(sessionId: string): YjsState | null {
|
||||
const [state, setState] = useState<YjsState | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
const providerRef = useRef<WebsocketProvider | null>(null);
|
||||
const docRef = useRef<Y.Doc | null>(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<Y.Map<unknown>>("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;
|
||||
}
|
||||
|
|
|
|||
119
apps/planner/app/lib/vite-yjs-plugin.ts
Normal file
119
apps/planner/app/lib/vite-yjs-plugin.ts
Normal file
|
|
@ -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<string, Y.Doc>();
|
||||
const awarenessMap = new Map<string, awarenessProtocol.Awareness>();
|
||||
const conns = new Map<WebSocket, { docName: string; awarenessIds: Set<number> }>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue