Skip to content

Commit 8898a48

Browse files
authored
Merge pull request #94 from ruiqi7/feature/coding-sandbox
Implement collaboration coding sandbox
2 parents 009712f + 14b0299 commit 8898a48

File tree

17 files changed

+1865
-160
lines changed

17 files changed

+1865
-160
lines changed

backend/README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
> Before proceeding to each microservice for more instructions:
44
5-
1. Set up cloud MongoDB if not using docker. We recommend this if you are just testing out each microservice separately to avoid needing to manually set up multiple instances of local MongoDB. Else, if you are using docker-compose.yml to run PeerPrep, check out the READMEs in the different backend microservices to set up the env for the local MongoDB instances.
5+
1. Set up cloud MongoDB if you are not using Docker. We recommend this if you are just testing out each microservice separately to avoid needing to manually set up multiple instances of local MongoDB. Otherwise, if you are using `docker-compose.yml` to run PeerPrep, check out the READMEs in the different backend microservices to set up the `.env` files for the local MongoDB instances.
66

77
2. Set up Firebase.
88

9-
3. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20.
9+
3. For the microservices that use Redis, to view the contents stored:
10+
11+
1. Go to [http://localhost:5540](http://localhost:5540).
12+
13+
2. Click on "Add Redis Database".
14+
15+
3. Enter `host.docker.internal` as the Host.
16+
17+
4. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20.
1018

1119
## Setting-up cloud MongoDB (in production)
1220

backend/collab-service/package-lock.json

Lines changed: 82 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/collab-service/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"license": "ISC",
1515
"description": "",
1616
"dependencies": {
17+
"@codemirror/collab": "^6.1.1",
18+
"@codemirror/state": "^6.4.1",
1719
"axios": "^1.7.7",
1820
"body-parser": "^1.20.3",
1921
"cors": "^2.8.5",
@@ -22,7 +24,9 @@
2224
"redis": "^4.7.0",
2325
"socket.io": "^4.8.1",
2426
"swagger-ui-express": "^5.0.1",
25-
"yaml": "^2.6.0"
27+
"y-protocols": "^1.0.6",
28+
"yaml": "^2.6.0",
29+
"yjs": "^13.6.20"
2630
},
2731
"devDependencies": {
2832
"@eslint/js": "^9.13.0",
Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,170 @@
11
import { Socket } from "socket.io";
22
import { io } from "../server";
33
import redisClient from "../config/redis";
4+
import { Doc, applyUpdateV2, encodeStateAsUpdateV2 } from "yjs";
45

56
enum CollabEvents {
67
// Receive
78
JOIN = "join",
8-
CHANGE = "change",
99
LEAVE = "leave",
1010
DISCONNECT = "disconnect",
11+
INIT_DOCUMENT = "init_document",
12+
UPDATE_REQUEST = "update_request",
13+
UPDATE_CURSOR_REQUEST = "update_cursor_request",
14+
RECONNECT_REQUEST = "reconnect_request",
1115

1216
// Send
13-
ROOM_FULL = "room_full",
14-
CONNECTED = "connected",
15-
NEW_USER_CONNECTED = "new_user_connected",
16-
CODE_CHANGE = "code_change",
17-
PARTNER_LEFT = "partner_left",
18-
PARTNER_DISCONNECTED = "partner_disconnected",
17+
ROOM_READY = "room_ready",
18+
DOCUMENT_READY = "document_ready",
19+
UPDATE = "updateV2",
20+
UPDATE_CURSOR = "update_cursor",
21+
// PARTNER_LEFT = "partner_left",
22+
// PARTNER_DISCONNECTED = "partner_disconnected",
1923
}
2024

2125
const EXPIRY_TIME = 3600;
26+
const CONNECTION_DELAY = 3000; // time window to allow for page re-renders / refresh
27+
28+
const userConnections = new Map<string, NodeJS.Timeout | null>();
29+
const collabSessions = new Map<string, Doc>();
30+
const partnerReadiness = new Map<string, boolean>();
2231

2332
export const handleWebsocketCollabEvents = (socket: Socket) => {
24-
socket.on(CollabEvents.JOIN, async ({ roomId }) => {
25-
if (!roomId) {
33+
socket.on(CollabEvents.JOIN, async (uid: string, roomId: string) => {
34+
const connectionKey = `${uid}:${roomId}`;
35+
if (userConnections.has(connectionKey)) {
36+
clearTimeout(userConnections.get(connectionKey)!);
2637
return;
2738
}
39+
userConnections.set(connectionKey, null);
2840

2941
const room = io.sockets.adapter.rooms.get(roomId);
30-
if (room && room.size >= 2) {
31-
socket.emit(CollabEvents.ROOM_FULL);
42+
if (room && room?.size >= 2) {
43+
socket.emit(CollabEvents.ROOM_READY, false);
3244
return;
3345
}
3446

3547
socket.join(roomId);
3648
socket.data.roomId = roomId;
3749

38-
// in case of disconnect, send the code to the user when he rejoins
39-
const code = await redisClient.get(`collaboration:${roomId}`);
40-
socket.emit(CollabEvents.CONNECTED, { code: code ? code : "" });
50+
if (
51+
io.sockets.adapter.rooms.get(roomId)?.size === 2 &&
52+
!collabSessions.has(roomId)
53+
) {
54+
createCollabSession(roomId);
55+
io.to(roomId).emit(CollabEvents.ROOM_READY, true);
56+
}
57+
});
58+
59+
socket.on(CollabEvents.INIT_DOCUMENT, (roomId: string, template: string) => {
60+
const doc = getDocument(roomId);
61+
const isPartnerReady = partnerReadiness.get(roomId);
4162

42-
// inform the other user that a new user has joined
43-
socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED);
63+
if (isPartnerReady && doc.getText().length === 0) {
64+
doc.transact(() => {
65+
doc.getText().insert(0, template);
66+
});
67+
io.to(roomId).emit(CollabEvents.DOCUMENT_READY);
68+
} else {
69+
partnerReadiness.set(roomId, true);
70+
}
4471
});
4572

46-
socket.on(CollabEvents.CHANGE, async ({ roomId, code }) => {
47-
if (!roomId || !code) {
48-
return;
73+
socket.on(
74+
CollabEvents.UPDATE_REQUEST,
75+
(roomId: string, update: Uint8Array) => {
76+
const doc = collabSessions.get(roomId);
77+
if (doc) {
78+
applyUpdateV2(doc, new Uint8Array(update));
79+
} else {
80+
// TODO: error handling
81+
}
4982
}
83+
);
5084

51-
await redisClient.set(`collaboration:${roomId}`, code, {
52-
EX: EXPIRY_TIME,
53-
});
54-
socket.to(roomId).emit(CollabEvents.CODE_CHANGE, { code });
55-
});
85+
socket.on(
86+
CollabEvents.UPDATE_CURSOR_REQUEST,
87+
(
88+
roomId: string,
89+
cursor: { uid: string; username: string; from: number; to: number }
90+
) => {
91+
socket.to(roomId).emit(CollabEvents.UPDATE_CURSOR, cursor);
92+
}
93+
);
5694

57-
socket.on(CollabEvents.LEAVE, ({ roomId }) => {
58-
if (!roomId) {
95+
socket.on(CollabEvents.LEAVE, (uid: string, roomId: string) => {
96+
const connectionKey = `${uid}:${roomId}`;
97+
if (!userConnections.has(connectionKey)) {
5998
return;
6099
}
61100

62-
socket.leave(roomId);
63-
socket.to(roomId).emit(CollabEvents.PARTNER_LEFT);
101+
clearTimeout(userConnections.get(connectionKey)!);
102+
103+
const connectionTimeout = setTimeout(() => {
104+
userConnections.delete(connectionKey);
105+
socket.leave(roomId);
106+
socket.disconnect();
107+
108+
const room = io.sockets.adapter.rooms.get(roomId);
109+
if (!room || room.size === 0) {
110+
removeCollabSession(roomId);
111+
}
112+
}, CONNECTION_DELAY);
113+
114+
userConnections.set(connectionKey, connectionTimeout);
64115
});
65116

66-
socket.on(CollabEvents.DISCONNECT, () => {
67-
const { roomId } = socket.data;
68-
if (roomId) {
69-
socket.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED);
117+
socket.on(CollabEvents.RECONNECT_REQUEST, async (roomId: string) => {
118+
// TODO: Handle recconnection
119+
socket.join(roomId);
120+
121+
const doc = getDocument(roomId);
122+
const storeData = await redisClient.get(`collaboration:${roomId}`);
123+
124+
if (storeData) {
125+
const tempDoc = new Doc();
126+
const update = Buffer.from(storeData, "base64");
127+
applyUpdateV2(tempDoc, new Uint8Array(update));
128+
const tempText = tempDoc.getText().toString();
129+
130+
const text = doc.getText();
131+
doc.transact(() => {
132+
text.delete(0, text.length);
133+
text.insert(0, tempText);
134+
});
70135
}
71136
});
72137
};
138+
139+
const createCollabSession = (roomId: string) => {
140+
getDocument(roomId);
141+
partnerReadiness.set(roomId, false);
142+
};
143+
144+
const removeCollabSession = (roomId: string) => {
145+
collabSessions.get(roomId)?.destroy();
146+
collabSessions.delete(roomId);
147+
partnerReadiness.delete(roomId);
148+
};
149+
150+
const getDocument = (roomId: string) => {
151+
let doc = collabSessions.get(roomId);
152+
if (!doc) {
153+
doc = new Doc();
154+
doc.on(CollabEvents.UPDATE, (_update) => {
155+
saveDocument(roomId, doc!);
156+
io.to(roomId).emit(CollabEvents.UPDATE, encodeStateAsUpdateV2(doc!));
157+
});
158+
collabSessions.set(roomId, doc);
159+
}
160+
161+
return doc;
162+
};
163+
164+
const saveDocument = async (roomId: string, doc: Doc) => {
165+
const docState = encodeStateAsUpdateV2(doc);
166+
const docAsString = Buffer.from(docState).toString("base64");
167+
await redisClient.set(`collaboration:${roomId}`, docAsString, {
168+
EX: EXPIRY_TIME,
169+
});
170+
};

backend/collab-service/src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import http from "http";
22
import app, { allowedOrigins } from "./app.ts";
33
import { handleWebsocketCollabEvents } from "./handlers/websocketHandler.ts";
4-
import { Server } from "socket.io";
4+
import { Server, Socket } from "socket.io";
55
import { connectRedis } from "./config/redis.ts";
66

77
const server = http.createServer(app);
@@ -13,7 +13,7 @@ export const io = new Server(server, {
1313
connectionStateRecovery: {},
1414
});
1515

16-
io.on("connection", (socket) => {
16+
io.on("connection", (socket: Socket) => {
1717
handleWebsocketCollabEvents(socket);
1818
});
1919

0 commit comments

Comments
 (0)