-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #94 from ruiqi7/feature/coding-sandbox
Implement collaboration coding sandbox
- Loading branch information
Showing
17 changed files
with
1,865 additions
and
160 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
162 changes: 130 additions & 32 deletions
162
backend/collab-service/src/handlers/websocketHandler.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,72 +1,170 @@ | ||
import { Socket } from "socket.io"; | ||
import { io } from "../server"; | ||
import redisClient from "../config/redis"; | ||
import { Doc, applyUpdateV2, encodeStateAsUpdateV2 } from "yjs"; | ||
|
||
enum CollabEvents { | ||
// Receive | ||
JOIN = "join", | ||
CHANGE = "change", | ||
LEAVE = "leave", | ||
DISCONNECT = "disconnect", | ||
INIT_DOCUMENT = "init_document", | ||
UPDATE_REQUEST = "update_request", | ||
UPDATE_CURSOR_REQUEST = "update_cursor_request", | ||
RECONNECT_REQUEST = "reconnect_request", | ||
|
||
// Send | ||
ROOM_FULL = "room_full", | ||
CONNECTED = "connected", | ||
NEW_USER_CONNECTED = "new_user_connected", | ||
CODE_CHANGE = "code_change", | ||
PARTNER_LEFT = "partner_left", | ||
PARTNER_DISCONNECTED = "partner_disconnected", | ||
ROOM_READY = "room_ready", | ||
DOCUMENT_READY = "document_ready", | ||
UPDATE = "updateV2", | ||
UPDATE_CURSOR = "update_cursor", | ||
// PARTNER_LEFT = "partner_left", | ||
// PARTNER_DISCONNECTED = "partner_disconnected", | ||
} | ||
|
||
const EXPIRY_TIME = 3600; | ||
const CONNECTION_DELAY = 3000; // time window to allow for page re-renders / refresh | ||
|
||
const userConnections = new Map<string, NodeJS.Timeout | null>(); | ||
const collabSessions = new Map<string, Doc>(); | ||
const partnerReadiness = new Map<string, boolean>(); | ||
|
||
export const handleWebsocketCollabEvents = (socket: Socket) => { | ||
socket.on(CollabEvents.JOIN, async ({ roomId }) => { | ||
if (!roomId) { | ||
socket.on(CollabEvents.JOIN, async (uid: string, roomId: string) => { | ||
const connectionKey = `${uid}:${roomId}`; | ||
if (userConnections.has(connectionKey)) { | ||
clearTimeout(userConnections.get(connectionKey)!); | ||
return; | ||
} | ||
userConnections.set(connectionKey, null); | ||
|
||
const room = io.sockets.adapter.rooms.get(roomId); | ||
if (room && room.size >= 2) { | ||
socket.emit(CollabEvents.ROOM_FULL); | ||
if (room && room?.size >= 2) { | ||
socket.emit(CollabEvents.ROOM_READY, false); | ||
return; | ||
} | ||
|
||
socket.join(roomId); | ||
socket.data.roomId = roomId; | ||
|
||
// in case of disconnect, send the code to the user when he rejoins | ||
const code = await redisClient.get(`collaboration:${roomId}`); | ||
socket.emit(CollabEvents.CONNECTED, { code: code ? code : "" }); | ||
if ( | ||
io.sockets.adapter.rooms.get(roomId)?.size === 2 && | ||
!collabSessions.has(roomId) | ||
) { | ||
createCollabSession(roomId); | ||
io.to(roomId).emit(CollabEvents.ROOM_READY, true); | ||
} | ||
}); | ||
|
||
socket.on(CollabEvents.INIT_DOCUMENT, (roomId: string, template: string) => { | ||
const doc = getDocument(roomId); | ||
const isPartnerReady = partnerReadiness.get(roomId); | ||
|
||
// inform the other user that a new user has joined | ||
socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); | ||
if (isPartnerReady && doc.getText().length === 0) { | ||
doc.transact(() => { | ||
doc.getText().insert(0, template); | ||
}); | ||
io.to(roomId).emit(CollabEvents.DOCUMENT_READY); | ||
} else { | ||
partnerReadiness.set(roomId, true); | ||
} | ||
}); | ||
|
||
socket.on(CollabEvents.CHANGE, async ({ roomId, code }) => { | ||
if (!roomId || !code) { | ||
return; | ||
socket.on( | ||
CollabEvents.UPDATE_REQUEST, | ||
(roomId: string, update: Uint8Array) => { | ||
const doc = collabSessions.get(roomId); | ||
if (doc) { | ||
applyUpdateV2(doc, new Uint8Array(update)); | ||
} else { | ||
// TODO: error handling | ||
} | ||
} | ||
); | ||
|
||
await redisClient.set(`collaboration:${roomId}`, code, { | ||
EX: EXPIRY_TIME, | ||
}); | ||
socket.to(roomId).emit(CollabEvents.CODE_CHANGE, { code }); | ||
}); | ||
socket.on( | ||
CollabEvents.UPDATE_CURSOR_REQUEST, | ||
( | ||
roomId: string, | ||
cursor: { uid: string; username: string; from: number; to: number } | ||
) => { | ||
socket.to(roomId).emit(CollabEvents.UPDATE_CURSOR, cursor); | ||
} | ||
); | ||
|
||
socket.on(CollabEvents.LEAVE, ({ roomId }) => { | ||
if (!roomId) { | ||
socket.on(CollabEvents.LEAVE, (uid: string, roomId: string) => { | ||
const connectionKey = `${uid}:${roomId}`; | ||
if (!userConnections.has(connectionKey)) { | ||
return; | ||
} | ||
|
||
socket.leave(roomId); | ||
socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); | ||
clearTimeout(userConnections.get(connectionKey)!); | ||
|
||
const connectionTimeout = setTimeout(() => { | ||
userConnections.delete(connectionKey); | ||
socket.leave(roomId); | ||
socket.disconnect(); | ||
|
||
const room = io.sockets.adapter.rooms.get(roomId); | ||
if (!room || room.size === 0) { | ||
removeCollabSession(roomId); | ||
} | ||
}, CONNECTION_DELAY); | ||
|
||
userConnections.set(connectionKey, connectionTimeout); | ||
}); | ||
|
||
socket.on(CollabEvents.DISCONNECT, () => { | ||
const { roomId } = socket.data; | ||
if (roomId) { | ||
socket.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); | ||
socket.on(CollabEvents.RECONNECT_REQUEST, async (roomId: string) => { | ||
// TODO: Handle recconnection | ||
socket.join(roomId); | ||
|
||
const doc = getDocument(roomId); | ||
const storeData = await redisClient.get(`collaboration:${roomId}`); | ||
|
||
if (storeData) { | ||
const tempDoc = new Doc(); | ||
const update = Buffer.from(storeData, "base64"); | ||
applyUpdateV2(tempDoc, new Uint8Array(update)); | ||
const tempText = tempDoc.getText().toString(); | ||
|
||
const text = doc.getText(); | ||
doc.transact(() => { | ||
text.delete(0, text.length); | ||
text.insert(0, tempText); | ||
}); | ||
} | ||
}); | ||
}; | ||
|
||
const createCollabSession = (roomId: string) => { | ||
getDocument(roomId); | ||
partnerReadiness.set(roomId, false); | ||
}; | ||
|
||
const removeCollabSession = (roomId: string) => { | ||
collabSessions.get(roomId)?.destroy(); | ||
collabSessions.delete(roomId); | ||
partnerReadiness.delete(roomId); | ||
}; | ||
|
||
const getDocument = (roomId: string) => { | ||
let doc = collabSessions.get(roomId); | ||
if (!doc) { | ||
doc = new Doc(); | ||
doc.on(CollabEvents.UPDATE, (_update) => { | ||
saveDocument(roomId, doc!); | ||
io.to(roomId).emit(CollabEvents.UPDATE, encodeStateAsUpdateV2(doc!)); | ||
}); | ||
collabSessions.set(roomId, doc); | ||
} | ||
|
||
return doc; | ||
}; | ||
|
||
const saveDocument = async (roomId: string, doc: Doc) => { | ||
const docState = encodeStateAsUpdateV2(doc); | ||
const docAsString = Buffer.from(docState).toString("base64"); | ||
await redisClient.set(`collaboration:${roomId}`, docAsString, { | ||
EX: EXPIRY_TIME, | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.