web/server/collab-socket.tsblame
View source
0b4b5821import http from "http";
0b4b5822import { Server } from "socket.io";
0b4b5823import * as Y from "yjs";
0b4b5824import { socketAuth } from "./collab-auth";
0b4b5825import {
0b4b5826 getRoom,
0b4b5827 roomKey,
0b4b5828 loadRoom,
0b4b5829 persistRoom,
0b4b58210 getYDoc,
0b4b58211 persistYDoc,
0b4b58212 canAccessRepo,
0b4b58213 pickColor,
0b4b58214} from "./collab-rooms";
0b4b58215
0b4b58216const PORT = parseInt(process.env.COLLAB_SOCKET_PORT || "3334", 10);
0b4b58217const CORS_ORIGIN = process.env.CORS_ORIGIN || true; // true = reflect request origin
0b4b58218
0b4b58219const server = http.createServer((_req, res) => {
0b4b58220 res.writeHead(200);
0b4b58221 res.end("ok");
0b4b58222});
0b4b58223
0b4b58224const io = new Server(server, {
0b4b58225 cors: { origin: CORS_ORIGIN, credentials: true },
0b4b58226});
0b4b58227
0b4b58228const collabNs = io.of("/collab");
0b4b58229collabNs.use(socketAuth as any);
0b4b58230
0b4b58231collabNs.on("connection", (socket: any) => {
0b4b58232 let currentOwner: string | null = null;
0b4b58233 let currentRepo: string | null = null;
0b4b58234 let userName: string | null = null;
0b4b58235
0b4b58236 socket.on(
0b4b58237 "join-room",
0b4b58238 async ({ owner, repo }: { owner: string; repo: string }) => {
0b4b58239 const allowed = await canAccessRepo(owner, repo, socket.token);
0b4b58240 if (!allowed) {
0b4b58241 socket.emit("error", { message: "Access denied" });
0b4b58242 return;
0b4b58243 }
0b4b58244
0b4b58245 currentOwner = owner;
0b4b58246 currentRepo = repo;
0b4b58247 userName =
0b4b58248 socket.user.display_name ||
0b4b58249 socket.user.username ||
0b4b58250 `User-${socket.id.slice(0, 4)}`;
0b4b58251
0b4b58252 const key = roomKey(owner, repo);
0b4b58253 socket.join(key);
0b4b58254
0b4b58255 const room = getRoom(key);
0b4b58256 // Load persisted notes if room is fresh
0b4b58257 if (Object.keys(room.notes).length === 0) {
0b4b58258 room.notes = loadRoom(owner, repo);
0b4b58259 }
0b4b58260
0b4b58261 room.users[socket.id] = {
0b4b58262 id: socket.id,
0b4b58263 name: userName!,
0b4b58264 color: pickColor(room),
0b4b58265 cursor: null,
0b4b58266 activeTab: null,
0b4b58267 };
0b4b58268
0b4b58269 // Send current state to the joining user
0b4b58270 socket.emit("room-state", {
0b4b58271 notes: room.notes,
0b4b58272 users: room.users,
0b4b58273 });
0b4b58274
0b4b58275 // Notify others
0b4b58276 collabNs.to(key).emit("users-updated", room.users);
0b4b58277 }
0b4b58278 );
0b4b58279
0b4b58280 // ── Yjs sync protocol over socket.io ──
0b4b58281 socket.on("yjs-sync", ({ diagramId }: { diagramId: string }) => {
0b4b58282 if (!currentOwner || !currentRepo) return;
0b4b58283 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
0b4b58284 const state = Y.encodeStateAsUpdate(ydoc);
0b4b58285 socket.emit("yjs-sync", {
0b4b58286 diagramId,
0b4b58287 update: Buffer.from(state).toString("base64"),
0b4b58288 });
0b4b58289 });
0b4b58290
0b4b58291 socket.on(
0b4b58292 "yjs-update",
0b4b58293 ({ diagramId, update }: { diagramId: string; update: string }) => {
0b4b58294 if (!currentOwner || !currentRepo) return;
0b4b58295 const key = roomKey(currentOwner, currentRepo);
0b4b58296 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
0b4b58297 const buf = Buffer.from(update, "base64");
0b4b58298 Y.applyUpdate(ydoc, new Uint8Array(buf));
0b4b58299 socket.to(key).emit("yjs-update", { diagramId, update });
0b4b582100 // Persist (debounced per diagram)
0b4b582101 const owner = currentOwner;
0b4b582102 const repo = currentRepo;
0b4b582103 clearTimeout((ydoc as any)._persistTimer);
0b4b582104 (ydoc as any)._persistTimer = setTimeout(
0b4b582105 () => persistYDoc(owner, repo, diagramId),
0b4b582106 2000
0b4b582107 );
0b4b582108 }
0b4b582109 );
0b4b582110
0b4b582111 socket.on(
0b4b582112 "cursor-move",
0b4b582113 ({
0b4b582114 x,
0b4b582115 y,
0b4b582116 activeTab,
0b4b582117 }: {
0b4b582118 x: number;
0b4b582119 y: number;
0b4b582120 activeTab: string;
0b4b582121 }) => {
0b4b582122 if (!currentOwner || !currentRepo) return;
0b4b582123 const key = roomKey(currentOwner, currentRepo);
0b4b582124 const room = getRoom(key);
0b4b582125 if (room.users[socket.id]) {
0b4b582126 room.users[socket.id].cursor = { x, y };
0b4b582127 room.users[socket.id].activeTab = activeTab;
0b4b582128 }
0b4b582129 socket.to(key).emit("cursor-updated", {
0b4b582130 userId: socket.id,
0b4b582131 x,
0b4b582132 y,
0b4b582133 activeTab,
0b4b582134 name: userName,
0b4b582135 color: room.users[socket.id]?.color,
0b4b582136 });
0b4b582137 }
0b4b582138 );
0b4b582139
0b4b582140 socket.on("add-note", (note: any) => {
0b4b582141 if (!currentOwner || !currentRepo) return;
0b4b582142 const key = roomKey(currentOwner, currentRepo);
0b4b582143 const room = getRoom(key);
0b4b582144 const diagramId = note.diagramId;
0b4b582145 if (!room.notes[diagramId]) room.notes[diagramId] = [];
0b4b582146
0b4b582147 const fullNote = {
0b4b582148 id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
0b4b582149 author: userName!,
0b4b582150 text: note.text,
0b4b582151 x: note.x,
0b4b582152 y: note.y,
0b4b582153 diagramId,
0b4b582154 diagramTitle: note.diagramTitle || diagramId,
0b4b582155 targetNode: note.targetNode || null,
0b4b582156 timestamp: new Date().toISOString(),
0b4b582157 };
0b4b582158
0b4b582159 room.notes[diagramId].push(fullNote);
0b4b582160 persistRoom(currentOwner, currentRepo);
0b4b582161 collabNs.to(key).emit("note-added", fullNote);
0b4b582162 });
0b4b582163
0b4b582164 socket.on(
0b4b582165 "edit-note",
0b4b582166 ({
0b4b582167 noteId,
0b4b582168 diagramId,
0b4b582169 text,
0b4b582170 }: {
0b4b582171 noteId: string;
0b4b582172 diagramId: string;
0b4b582173 text: string;
0b4b582174 }) => {
0b4b582175 if (!currentOwner || !currentRepo) return;
0b4b582176 const key = roomKey(currentOwner, currentRepo);
0b4b582177 const room = getRoom(key);
0b4b582178 const notes = room.notes[diagramId];
0b4b582179 if (!notes) return;
0b4b582180 const note = notes.find((n) => n.id === noteId);
0b4b582181 if (!note) return;
0b4b582182 note.text = text;
0b4b582183 note.editedAt = new Date().toISOString();
0b4b582184 persistRoom(currentOwner, currentRepo);
0b4b582185 collabNs.to(key).emit("note-edited", {
0b4b582186 noteId,
0b4b582187 diagramId,
0b4b582188 text,
0b4b582189 editedAt: note.editedAt,
0b4b582190 });
0b4b582191 }
0b4b582192 );
0b4b582193
0b4b582194 socket.on(
0b4b582195 "diagram-code",
0b4b582196 ({ diagramId, code }: { diagramId: string; code: string }) => {
0b4b582197 if (!currentOwner || !currentRepo) return;
0b4b582198 const key = roomKey(currentOwner, currentRepo);
0b4b582199 socket
0b4b582200 .to(key)
0b4b582201 .emit("diagram-code", { diagramId, code, userId: socket.id });
0b4b582202 }
0b4b582203 );
0b4b582204
0b4b582205 socket.on(
0b4b582206 "delete-note",
0b4b582207 ({ noteId, diagramId }: { noteId: string; diagramId: string }) => {
0b4b582208 if (!currentOwner || !currentRepo) return;
0b4b582209 const key = roomKey(currentOwner, currentRepo);
0b4b582210 const room = getRoom(key);
0b4b582211 if (!room.notes[diagramId]) return;
0b4b582212 room.notes[diagramId] = room.notes[diagramId].filter(
0b4b582213 (n) => n.id !== noteId
0b4b582214 );
0b4b582215 persistRoom(currentOwner, currentRepo);
0b4b582216 collabNs.to(key).emit("note-deleted", { noteId, diagramId });
0b4b582217 }
0b4b582218 );
0b4b582219
0b4b582220 socket.on("disconnect", () => {
0b4b582221 if (!currentOwner || !currentRepo) return;
0b4b582222 const key = roomKey(currentOwner, currentRepo);
0b4b582223 const room = getRoom(key);
0b4b582224 delete room.users[socket.id];
0b4b582225 collabNs.to(key).emit("users-updated", room.users);
0b4b582226 collabNs.to(key).emit("cursor-removed", { userId: socket.id });
0b4b582227 });
0b4b582228});
0b4b582229
0b4b582230server.listen(PORT, () => {
0b4b582231 console.log(`Grove Collab socket server running on port ${PORT}`);
0b4b582232});