6.3 KB233 lines
Blame
1import http from "http";
2import { Server } from "socket.io";
3import * as Y from "yjs";
4import { socketAuth } from "./collab-auth";
5import {
6 getRoom,
7 roomKey,
8 loadRoom,
9 persistRoom,
10 getYDoc,
11 persistYDoc,
12 canAccessRepo,
13 pickColor,
14} from "./collab-rooms";
15
16const PORT = parseInt(process.env.COLLAB_SOCKET_PORT || "3334", 10);
17const CORS_ORIGIN = process.env.CORS_ORIGIN || true; // true = reflect request origin
18
19const server = http.createServer((_req, res) => {
20 res.writeHead(200);
21 res.end("ok");
22});
23
24const io = new Server(server, {
25 cors: { origin: CORS_ORIGIN, credentials: true },
26});
27
28const collabNs = io.of("/collab");
29collabNs.use(socketAuth as any);
30
31collabNs.on("connection", (socket: any) => {
32 let currentOwner: string | null = null;
33 let currentRepo: string | null = null;
34 let userName: string | null = null;
35
36 socket.on(
37 "join-room",
38 async ({ owner, repo }: { owner: string; repo: string }) => {
39 const allowed = await canAccessRepo(owner, repo, socket.token);
40 if (!allowed) {
41 socket.emit("error", { message: "Access denied" });
42 return;
43 }
44
45 currentOwner = owner;
46 currentRepo = repo;
47 userName =
48 socket.user.display_name ||
49 socket.user.username ||
50 `User-${socket.id.slice(0, 4)}`;
51
52 const key = roomKey(owner, repo);
53 socket.join(key);
54
55 const room = getRoom(key);
56 // Load persisted notes if room is fresh
57 if (Object.keys(room.notes).length === 0) {
58 room.notes = loadRoom(owner, repo);
59 }
60
61 room.users[socket.id] = {
62 id: socket.id,
63 name: userName!,
64 color: pickColor(room),
65 cursor: null,
66 activeTab: null,
67 };
68
69 // Send current state to the joining user
70 socket.emit("room-state", {
71 notes: room.notes,
72 users: room.users,
73 });
74
75 // Notify others
76 collabNs.to(key).emit("users-updated", room.users);
77 }
78 );
79
80 // ── Yjs sync protocol over socket.io ──
81 socket.on("yjs-sync", ({ diagramId }: { diagramId: string }) => {
82 if (!currentOwner || !currentRepo) return;
83 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
84 const state = Y.encodeStateAsUpdate(ydoc);
85 socket.emit("yjs-sync", {
86 diagramId,
87 update: Buffer.from(state).toString("base64"),
88 });
89 });
90
91 socket.on(
92 "yjs-update",
93 ({ diagramId, update }: { diagramId: string; update: string }) => {
94 if (!currentOwner || !currentRepo) return;
95 const key = roomKey(currentOwner, currentRepo);
96 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
97 const buf = Buffer.from(update, "base64");
98 Y.applyUpdate(ydoc, new Uint8Array(buf));
99 socket.to(key).emit("yjs-update", { diagramId, update });
100 // Persist (debounced per diagram)
101 const owner = currentOwner;
102 const repo = currentRepo;
103 clearTimeout((ydoc as any)._persistTimer);
104 (ydoc as any)._persistTimer = setTimeout(
105 () => persistYDoc(owner, repo, diagramId),
106 2000
107 );
108 }
109 );
110
111 socket.on(
112 "cursor-move",
113 ({
114 x,
115 y,
116 activeTab,
117 }: {
118 x: number;
119 y: number;
120 activeTab: string;
121 }) => {
122 if (!currentOwner || !currentRepo) return;
123 const key = roomKey(currentOwner, currentRepo);
124 const room = getRoom(key);
125 if (room.users[socket.id]) {
126 room.users[socket.id].cursor = { x, y };
127 room.users[socket.id].activeTab = activeTab;
128 }
129 socket.to(key).emit("cursor-updated", {
130 userId: socket.id,
131 x,
132 y,
133 activeTab,
134 name: userName,
135 color: room.users[socket.id]?.color,
136 });
137 }
138 );
139
140 socket.on("add-note", (note: any) => {
141 if (!currentOwner || !currentRepo) return;
142 const key = roomKey(currentOwner, currentRepo);
143 const room = getRoom(key);
144 const diagramId = note.diagramId;
145 if (!room.notes[diagramId]) room.notes[diagramId] = [];
146
147 const fullNote = {
148 id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
149 author: userName!,
150 text: note.text,
151 x: note.x,
152 y: note.y,
153 diagramId,
154 diagramTitle: note.diagramTitle || diagramId,
155 targetNode: note.targetNode || null,
156 timestamp: new Date().toISOString(),
157 };
158
159 room.notes[diagramId].push(fullNote);
160 persistRoom(currentOwner, currentRepo);
161 collabNs.to(key).emit("note-added", fullNote);
162 });
163
164 socket.on(
165 "edit-note",
166 ({
167 noteId,
168 diagramId,
169 text,
170 }: {
171 noteId: string;
172 diagramId: string;
173 text: string;
174 }) => {
175 if (!currentOwner || !currentRepo) return;
176 const key = roomKey(currentOwner, currentRepo);
177 const room = getRoom(key);
178 const notes = room.notes[diagramId];
179 if (!notes) return;
180 const note = notes.find((n) => n.id === noteId);
181 if (!note) return;
182 note.text = text;
183 note.editedAt = new Date().toISOString();
184 persistRoom(currentOwner, currentRepo);
185 collabNs.to(key).emit("note-edited", {
186 noteId,
187 diagramId,
188 text,
189 editedAt: note.editedAt,
190 });
191 }
192 );
193
194 socket.on(
195 "diagram-code",
196 ({ diagramId, code }: { diagramId: string; code: string }) => {
197 if (!currentOwner || !currentRepo) return;
198 const key = roomKey(currentOwner, currentRepo);
199 socket
200 .to(key)
201 .emit("diagram-code", { diagramId, code, userId: socket.id });
202 }
203 );
204
205 socket.on(
206 "delete-note",
207 ({ noteId, diagramId }: { noteId: string; diagramId: string }) => {
208 if (!currentOwner || !currentRepo) return;
209 const key = roomKey(currentOwner, currentRepo);
210 const room = getRoom(key);
211 if (!room.notes[diagramId]) return;
212 room.notes[diagramId] = room.notes[diagramId].filter(
213 (n) => n.id !== noteId
214 );
215 persistRoom(currentOwner, currentRepo);
216 collabNs.to(key).emit("note-deleted", { noteId, diagramId });
217 }
218 );
219
220 socket.on("disconnect", () => {
221 if (!currentOwner || !currentRepo) return;
222 const key = roomKey(currentOwner, currentRepo);
223 const room = getRoom(key);
224 delete room.users[socket.id];
225 collabNs.to(key).emit("users-updated", room.users);
226 collabNs.to(key).emit("cursor-removed", { userId: socket.id });
227 });
228});
229
230server.listen(PORT, () => {
231 console.log(`Grove Collab socket server running on port ${PORT}`);
232});
233