diff --git a/frontend/next.config.js b/frontend/next.config.js index a843cbee..5dd865f2 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,6 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, -} + experimental: { + externalDir: true, + }, +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/frontend/package.json b/frontend/package.json index ba176022..3dade824 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", + "diff-match-patch": "^1.0.5", "eslint": "8.49.0", "eslint-config-next": "13.4.19", "firebase": "^10.4.0", @@ -36,6 +37,7 @@ "lucide-react": "^0.279.0", "monaco-editor": "^0.43.0", "next": "13.4.19", + "ot-text-unicode": "^4.0.0", "postcss": "8.4.29", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/frontend/src/components/room/code-editor.tsx b/frontend/src/components/room/code-editor.tsx index 2998fa1a..c0889119 100644 --- a/frontend/src/components/room/code-editor.tsx +++ b/frontend/src/components/room/code-editor.tsx @@ -7,7 +7,7 @@ import { Settings, Play, } from "lucide-react"; -import Editor from "@monaco-editor/react"; +import Editor, { OnMount } from "@monaco-editor/react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -25,6 +25,7 @@ import { } from "@/components/ui/popover"; import { Card } from "../ui/card"; import { TypographyBody, TypographyBodyHeavy } from "../ui/typography"; +import { editor } from "monaco-editor"; type CodeEditorProps = { theme?: string; @@ -33,7 +34,9 @@ type CodeEditorProps = { defaultValue?: string; className?: string; text: string; + cursor: number; onChange: React.Dispatch>; + onCursorChange: React.Dispatch>; }; const frameworks = [ @@ -66,17 +69,50 @@ export default function CodeEditor({ defaultValue = "#Write your solution here", className, text, + cursor, onChange, + onCursorChange, }: CodeEditorProps) { const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(""); + const [monacoInstance, setMonacoInstance] = + React.useState(null); + + const editorMount: OnMount = (editorL: editor.IStandaloneCodeEditor) => { + setMonacoInstance(editorL); + }; + + const setCursorPosition = React.useCallback( + (cursor: number) => { + if (!monacoInstance) return; + + const position = monacoInstance.getModel()!.getPositionAt(cursor); + monacoInstance.setPosition(position); + }, + [monacoInstance] + ); + + React.useEffect(() => { + if (cursor !== undefined) { + setCursorPosition(cursor); + } + }, [cursor, setCursorPosition]); + const editorOnChange = React.useCallback( (value: string | undefined) => { + if (!monacoInstance) return; if (value === undefined) return; + + if (monacoInstance.getPosition()) { + const cursor = monacoInstance + .getModel()! + .getOffsetAt(monacoInstance.getPosition()!); + onCursorChange(cursor); + } onChange(value); }, - [onChange] + [onChange, onCursorChange, monacoInstance] ); return ( @@ -142,6 +178,7 @@ export default function CodeEditor({ value={text} theme={theme} onChange={(e) => editorOnChange(e)} + onMount={editorMount} />
diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index 7cebbdb2..f0d4da39 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -1,30 +1,82 @@ import { useEffect, useState, useRef } from "react"; -import io from "socket.io-client"; +import { io, Socket } from "socket.io-client"; import { debounce } from "lodash"; +import { + TextOperationSetWithCursor, + createTextOpFromTexts, +} from "../../../utils/shared-ot"; +import { TextOp } from "ot-text-unicode"; type UseCollaborationProps = { roomId: string; userId: string; }; +enum SocketEvents { + ROOM_JOIN = "api/collaboration-service/room/join", + ROOM_UPDATE = "api/collaboration-service/room/update", + ROOM_SAVE = "api/collaboration-service/room/save", + ROOM_LOAD = "api/collaboration-service/room/load", +} + +var vers = 0; + const useCollaboration = ({ roomId, userId }: UseCollaborationProps) => { - const [socket, setSocket] = useState(null); - const [text, setText] = useState(""); + const [socket, setSocket] = useState(null); + const [text, setText] = useState("#Write your solution here"); + const [cursor, setCursor] = useState( + "#Write your solution here".length + ); const textRef = useRef(text); + const cursorRef = useRef(cursor); + const prevCursorRef = useRef(cursor); + const prevTextRef = useRef(text); + const awaitingAck = useRef(false); // ack from sending update + const awaitingSync = useRef(false); // synced with server useEffect(() => { const socketConnection = io("http://localhost:5003/"); setSocket(socketConnection); - socketConnection.emit("/room/join", roomId, userId); + socketConnection.emit(SocketEvents.ROOM_JOIN, roomId, userId); + + socketConnection.on( + SocketEvents.ROOM_UPDATE, + ({ + version, + text, + cursor, + }: { + version: number; + text: string; + cursor: number | undefined | null; + }) => { + prevCursorRef.current = cursorRef.current; + console.log("prevCursor: " + prevCursorRef.current); + + console.log("cursor: " + cursor); - // if is my own socket connection, don't update text - if (socket && socket.id === socketConnection.id) { - console.log("update"); - socketConnection.on("/room/update", ({ text }: { text: string }) => { + console.log("Update vers to " + version); + vers = version; + + if (awaitingAck.current) return; + + textRef.current = text; + prevTextRef.current = text; setText(text); - }); - } + if (cursor && cursor > -1) { + console.log("Update cursor to " + cursor); + cursorRef.current = cursor; + setCursor(cursor); + } else { + cursorRef.current = prevCursorRef.current; + cursor = prevCursorRef.current; + console.log("Update cursor to " + prevCursorRef.current); + setCursor(prevCursorRef.current); + } + awaitingSync.current = false; + } + ); return () => { socketConnection.disconnect(); @@ -35,19 +87,43 @@ const useCollaboration = ({ roomId, userId }: UseCollaborationProps) => { textRef.current = text; }, [text]); + useEffect(() => { + cursorRef.current = cursor; + }, [cursor]); + useEffect(() => { if (!socket) return; - const handleTextChange = debounce(() => { - socket.emit("/room/update", textRef.current); - }, 10); + if (prevTextRef.current === textRef.current) return; + + if (awaitingAck.current || awaitingSync.current) return; + + awaitingAck.current = true; + + console.log("prevtext: " + prevTextRef.current); + console.log("currenttext: " + textRef.current); + console.log("version: " + vers); + const textOp: TextOp = createTextOpFromTexts( + prevTextRef.current, + textRef.current + ); - handleTextChange(); + prevTextRef.current = textRef.current; + + console.log(textOp); + + const textOperationSet: TextOperationSetWithCursor = { + version: vers, + operations: textOp, + cursor: cursorRef.current, + }; - return () => handleTextChange.cancel(); + socket.emit(SocketEvents.ROOM_UPDATE, textOperationSet, () => { + awaitingAck.current = false; + }); }, [text, socket]); - return { text, setText }; + return { text, setText, cursor, setCursor }; }; export default useCollaboration; diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index 2a48455e..e85bb4c1 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -12,7 +12,7 @@ export default function Room() { const roomId = router.query.id as string; const userId = "user1"; - const { text, setText } = useCollaboration({ + const { text, setText, cursor, setCursor } = useCollaboration({ roomId: roomId as string, userId, }); @@ -52,7 +52,12 @@ export default function Room() { {question.solution}
- +
diff --git a/prisma/migrations/20231012153618_add_room/migration.sql b/prisma/migrations/20231012153618_add_room/migration.sql new file mode 100644 index 00000000..6c4a36bf --- /dev/null +++ b/prisma/migrations/20231012153618_add_room/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "EnumRoomStatus" AS ENUM ('active', 'inactive'); + +-- CreateTable +CREATE TABLE "Room" ( + "room_id" TEXT NOT NULL, + "users" TEXT[], + "status" "EnumRoomStatus" NOT NULL, + "text" TEXT NOT NULL, + "saved_text" TEXT, + + CONSTRAINT "Room_pkey" PRIMARY KEY ("room_id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1dda83d..ac6d951b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,25 +12,38 @@ datasource db { // todo rename for colalboration service model User { - id String @id @default(uuid()) + id String @id @default(uuid()) isLookingForMatch Boolean - matchedUserId String? @unique + matchedUserId String? @unique lastConnected DateTime? } model Match { - roomId String @id @default(uuid()) - userId1 String - userId2 String - chosenDifficulty String + roomId String @id @default(uuid()) + userId1 String + userId2 String + chosenDifficulty String chosenProgrammingLanguage String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) } model AppUser { - uid String @id - displayName String? - photoUrl String? - matchDifficulty Int? + uid String @id + displayName String? + photoUrl String? + matchDifficulty Int? matchProgrammingLanguage String? } + +model Room { + room_id String @id + users String[] // Array of user_id strings + status EnumRoomStatus + text String + saved_text String? +} + +enum EnumRoomStatus { + active + inactive +} diff --git a/services/collaboration-service/package.json b/services/collaboration-service/package.json index ff01525c..a80827a7 100644 --- a/services/collaboration-service/package.json +++ b/services/collaboration-service/package.json @@ -14,10 +14,14 @@ "body-parser": "^1.20.2", "cookie-parser": "~1.4.4", "debug": "~2.6.9", + "diff-match-patch": "^1.0.5", "express": "~4.16.1", "express-openapi": "^12.1.3", + "json0-ot-diff": "^1.1.2", "morgan": "~1.9.1", "openapi": "^1.0.1", + "ot-json1": "^1.0.2", + "ot-text-unicode": "^4.0.0", "socket.io": "^4.7.2", "swagger-autogen": "^2.23.5", "swagger-express-ts": "^1.1.0", @@ -28,9 +32,10 @@ "devDependencies": { "@types/cookie-parser": "^1.4.4", "@types/cors": "^2.8.14", + "@types/diff-match-patch": "^1.0.34", "@types/express": "^4.17.17", "@types/morgan": "^1.9.5", - "@types/node": "^20.6.2", + "@types/node": "^20.8.4", "@types/socket.io": "^3.0.2", "@types/swagger-ui-express": "^4.1.3", "@types/uuid": "^9.0.4", diff --git a/services/collaboration-service/src/db/prisma-db.ts b/services/collaboration-service/src/db/prisma-db.ts new file mode 100644 index 00000000..a4c84013 --- /dev/null +++ b/services/collaboration-service/src/db/prisma-db.ts @@ -0,0 +1,146 @@ +import { PrismaClient, Room } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export async function isRoomExists(room_id: string) { + const room = await prisma.room.findFirst({ + where: { + room_id: room_id, + }, + }); + return room != null; +} + +export async function getRoom(room_id: string): Promise { + const room = await prisma.room.findUnique({ + where: { + room_id: room_id, + }, + }); + return room!; +} + +export async function getRoomText(room_id: string): Promise { + const room = await prisma.room.findUnique({ + where: { + room_id: room_id, + }, + }); + if (room) { + return room.text; + } else { + return ""; + } +} + +export async function getSavedRoomText( + room_id: string +): Promise { + const room = await prisma.room.findUnique({ + where: { + room_id: room_id, + }, + }); + if (room) { + return room.saved_text; + } else { + return null; + } +} + +export async function updateRoomStatus(room_id: string): Promise { + const room = await prisma.room.findUnique({ + where: { + room_id: room_id, + }, + }); + if (!room) return; + + if (room.users.length === 0) { + room.status = "inactive"; + } else { + room.status = "active"; + } +} + +export async function createOrUpdateRoomWithUser( + room_id: string, + user_id: string +): Promise { + await prisma.room.upsert({ + where: { + room_id: room_id, + }, + update: { + status: "active", + users: { + push: user_id, + }, + }, + create: { + room_id: room_id, + text: "", + status: "active", + users: [user_id], + }, + }); +} + +export async function updateRoomText( + room_id: string, + text: string +): Promise { + await prisma.room.update({ + where: { + room_id: room_id, + }, + data: { + text: text, + }, + }); +} + +export async function saveRoomText( + room_id: string, + text: string +): Promise { + await prisma.room.update({ + where: { + room_id: room_id, + }, + data: { + text: text, + saved_text: text, + }, + }); +} + +export async function removeUserFromRoom( + room_id: string, + user_id: string +): Promise { + const existingRoom = await prisma.room.findUnique({ + where: { + room_id: room_id, + }, + }); + + if (!existingRoom) return; + + const userIndex = existingRoom.users.indexOf(user_id); + + if (userIndex > -1) { + existingRoom.users.splice(userIndex, 1); + + await prisma.room.update({ + where: { + room_id: room_id, + }, + data: { + users: { + set: existingRoom.users, + }, + }, + }); + } +} diff --git a/services/collaboration-service/src/ot.ts b/services/collaboration-service/src/ot.ts new file mode 100644 index 00000000..2ed2d6fb --- /dev/null +++ b/services/collaboration-service/src/ot.ts @@ -0,0 +1,274 @@ +import { diff_match_patch } from "diff-match-patch"; +import { type, insert, remove, TextOp } from "ot-text-unicode"; + +export interface TextOperationSet { + version: number; + operations: TextOp; +} + +export interface TextOperationSetWithCursor extends TextOperationSet { + cursor?: number; +} + +class CircularArray { + private array: Array; + private last: number; // index of last element + + constructor(capacity: number) { + this.array = new Array(capacity); + this.last = -1; + } + + public add(value: T): void { + this.last = (this.last + 1) % this.array.length; + this.array[this.last] = value; + } + + public getLatest(): T { + return this.array[this.last]; + } + + public search(predicate: (value: T) => boolean): T | null { + for (let i = 0; i < this.array.length; i++) { + const index = (this.last - i) % this.array.length; + if (predicate(this.array[index])) { + return this.array[index]; + } + } + return null; + } + + public reduceFromMatchedPredicateToLatest( + predicate: (value: T) => boolean, + callbackFn: (previousValue: T, currentValue: T) => T, + initialValue: T + ): T; + public reduceFromMatchedPredicateToLatest( + predicate: (value: T) => boolean, + callbackFn: ( + previousValue: T, + currentValue: T, + currentIndex: number, + array: T[] + ) => T, + initialValue: T + ): T { + for (let i = 0; i < this.array.length; i++) { + const index = (this.last - i + this.array.length) % this.array.length; + if (predicate(this.array[index])) { + const startIndex = index; + const endIndex = this.last; + if (startIndex <= endIndex) { + return this.array + .slice(startIndex, endIndex + 1) + .reduce(callbackFn, initialValue); + } else { + return this.array + .slice(startIndex) + .concat(this.array.slice(0, endIndex + 1)) + .reduce(callbackFn, initialValue); + } + } + } + const start = (this.last + 1) % this.array.length; + return this.array + .slice(start) + .concat(this.array.slice(0, this.last + 1)) + .reduce(callbackFn, initialValue); + } + + public get length(): number { + return this.array.length; + } +} + +export class OpHistoryMap { + private map: Record> = {}; + + public add(room_id: string, opHistory: TextOperationSet): void { + if (!this.map[room_id]) { + this.map[room_id] = new CircularArray(10); + } + this.map[room_id].add(opHistory); + } + + public getLatest(room_id: string): TextOperationSet | null { + if (!this.map[room_id]) { + return null; + } + return this.map[room_id].getLatest(); + } + + public getCombinedTextOpFromVersionToLatest( + room_id: string, + version: number + ): TextOp { + const room = this.map[room_id]; + const latestVersion = room.getLatest().version; + + if (version - 1 === latestVersion) { + return room.getLatest()!.operations; + } + + // Combine operations from the given version to the latest version + return room.reduceFromMatchedPredicateToLatest( + (opHistory) => { + return version === opHistory.version; + }, + (x, y) => { + return { + operations: type.compose(x.operations, y.operations), + version: y.version, + }; + }, + { operations: [], version: 0 } + ).operations; + } + + public checkIfLatestVersion(room_id: string, version: number): boolean { + if (!this.map[room_id]) { + return true; + } + const latest = this.map[room_id].getLatest(); + if (!latest) { + return true; + } + return latest.version === version; + } + + public search(room_id: string, version: number): TextOperationSet | null { + if (!this.map[room_id]) { + return null; + } + return this.map[room_id].search( + (opHistory) => opHistory.version === version + ); + } +} + +export function createTextOpFromTexts(text1: string, text2: string): TextOp { + const dmp = new diff_match_patch(); + const diffs = dmp.diff_main(text1, text2); + //dmp.diff_cleanupSemantic(diffs); + + var textop: TextOp = []; + + var skipn: number = 0; + + for (const [operation, text] of diffs) { + if (operation === 0) { + skipn += text.length; + } else if (operation === -1) { + textop = [...textop, ...remove(skipn, text)]; + skipn = 0; + } else { + textop = [...textop, ...insert(skipn, text)]; + skipn = 0; + } + } + return textop; +} + +/** + * Returns transformed operations + * @param latestOp Text 1 to 3 + * @param mergedOp Text 1 to 2 + * @returns (transformed Text 1 to 3, transformed Text 1 to 2) + * Transformed text 1 to 3 to apply to text 2 + * Transformed text 1 to 2 to apply to text 3 + */ +export function getTransformedOperations(latestOp: TextOp, mergedOp: TextOp) { + return [ + type.transform(latestOp, mergedOp, "left"), + type.transform(mergedOp, latestOp, "right"), + ]; +} + +export function transformPosition(cursor: number, op: TextOp): number { + return type.transformPosition(cursor, op); +} + +function test() { + const text1 = "hello world"; + // console.log(type.apply(text1, remove(6, 1))); + // console.log( + // type.apply(type.apply(text1, remove(6, "w")), insert(9, "asdadasdk")) + // ); + // console.log( + // type.apply(text1, (remove(6, "w") as TextOp).concat(insert(3, "asdadasdk"))) + // ); + const text2 = "good day hi everyone and the world"; + const text3 = "good morning to the world and all who are in it"; + const expected = + "hi everyone good morning to the world and all who are in it"; /// or some gibberish similiar to this + // const textOp = createTextOpFromTexts(text1, text2); + // console.log(textOp); + // console.log(type.apply(text1, textOp)); + + const history_db = new OpHistoryMap(); + + history_db.add("room1", { + version: 0.1, + operations: insert(0, text1), + }); + + /// Text 3 sent on version 0.1 + history_db.search("room1", 0.1); + + const text1to2op = createTextOpFromTexts(text1, text2); + console.log(text1to2op); + + history_db.add("room1", { + version: 0.2, + operations: text1to2op, + }); + + const text1to3op = createTextOpFromTexts(text1, text3); + console.log(text1to3op); + + const newOp = type.transform(text1to3op, text1to2op, "left"); + console.log(newOp); + console.log(type.apply(text2, newOp)); + + // console.log( + // type.transform( + // (remove(0, "w") as TextOp).concat(insert(3, "asdadasdk")), + // (insert(1, "hello") as TextOp).concat(remove(3, "ak")), + // "left" + // ) + // ); + + const newOp2 = type.transform(text1to2op, text1to3op, "right"); + console.log(newOp2); + console.log(type.apply(text3, newOp2)); + + // favour text1to3op over text1to2op on side param + // outcome is same + + console.log("------------------"); + + //Compose + const x = createTextOpFromTexts("Hi", "hai"); + const y = createTextOpFromTexts("hai", "hbaye"); + console.log(x); + console.log(y); + const z = type.compose(x, y); + console.log(z); + console.log(type.apply("Hi", z)); + + const z2 = type.compose(y, x); + console.log(z2); + console.log(type.apply("Hi", z2)); + + console.log("------------------"); + + console.log(text1to2op); + console.log(text1to3op); + const newOp3 = type.compose(text1to2op, text1to3op); + console.log(newOp3); + console.log(type.apply(text1, newOp3)); +} + +if (require.main === module) { + test(); +} diff --git a/services/collaboration-service/src/routes/demo.html b/services/collaboration-service/src/routes/demo.html index c5ec6f52..17093611 100644 --- a/services/collaboration-service/src/routes/demo.html +++ b/services/collaboration-service/src/routes/demo.html @@ -6,7 +6,26 @@

Text Collaboration Room

- +

Enter in textbox the TextOperation in this format:

+
    +
  • + Ops are lists of components which iterate over the document. Components + are either: +
  • +
      +
    • A number N: Skip N characters in the original document
    • +
    • "str": Insert "str" at the current position in the document
    • +
    • + {d:N}: Delete N characters at the current position in the document +
    • +
    • + {d:"str"}: Delete "str" at the current position in the document. This + is equivalent to {d:N} but provides extra information for operation + invertability. +
    • +
    +
  • Eg: [3, 'hi', 5, {d:8}]
  • +
@@ -21,38 +40,7 @@

Text Collaboration Room

const socket = io("http://localhost:5003/"); - if (api === "rest") { - joinRoomByRestApi(room, user); - } else { - joinRoomBySocket(socket, room, user); - } - - /** Using REST API -- not recommended **/ - // Sets the server connection to accept the next incoming connection to room 1. - // Fine as long as all calls to GET API proceeds with immediate socket connection. - function joinRoomByRestApi(room, user) { - var apiUrl = - "http://localhost:5003/api/collaboration-service/room/join"; - - fetch(apiUrl, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ room_id: room, user_id: user }), - cache: "default", - }) - .then((response) => { - return response.json(); - }) - .then((data) => { - console.log(data); - }) - .catch((err) => { - console.error("Error:", err); - }); - } + joinRoomBySocket(socket, room, user); function saveRoomByRestApi(room, text) { var apiUrl = @@ -80,28 +68,39 @@

Text Collaboration Room

/** Recommended: Using Socket.io EventEmitters as API **/ function joinRoomBySocket(socket, room, user) { - socket.emit("/room/join", room, user); + socket.emit("api/collaboration-service/room/join", room, user); } function saveRoomBySocket(socket, text) { - socket.emit("/room/save", text); + socket.emit("api/collaboration-service/room/save", text); } // Socket EventListeners and EventEmitters - const textEditor = document.getElementById("textEditor"); + const textEditor = document.getElementById("textEditor"); // text is now textop format const saveButton = document.getElementById("saveButton"); const loadButton = document.getElementById("loadButton"); + var vers = 0; window.onload = () => { - socket.on("/room/update", ({ text }) => { - textEditor.value = text; - console.log("/room/update"); - }); + socket.on( + "api/collaboration-service/room/update", + ({ version, text }) => { + console.log(version); + vers = version; + textEditor.value = text; + console.log("api/collaboration-service/room/update"); + } + ); textEditor.addEventListener("change", () => { const text = textEditor.value; console.log(text); - socket.emit("/room/update", text); + console.log(vers); + textOp = eval(text); + socket.emit("api/collaboration-service/room/update", { + version: vers, + operations: textOp, + }); }); saveButton.addEventListener("click", () => { @@ -117,7 +116,7 @@

Text Collaboration Room

loadButton.addEventListener("click", () => { console.log("to load"); - socket.emit("/room/load"); + socket.emit("api/collaboration-service/room/load"); }); disconnectButton.addEventListener("click", () => { diff --git a/services/collaboration-service/src/routes/room.ts b/services/collaboration-service/src/routes/room.ts index 691bd22e..f45bd895 100644 --- a/services/collaboration-service/src/routes/room.ts +++ b/services/collaboration-service/src/routes/room.ts @@ -1,20 +1,41 @@ import express, { Request, Response } from "express"; +import { type } from "ot-text-unicode"; import { Socket, Server } from "socket.io"; - -interface Room { - users: Array; - status: "active" | "inactive"; - text: string; - saved_text?: string; -} +import { Room } from "@prisma/client"; +import { + createOrUpdateRoomWithUser, + removeUserFromRoom, + updateRoomText, + updateRoomStatus, + getRoomText, + saveRoomText, + isRoomExists, + getRoom, + getSavedRoomText, +} from "../db/prisma-db"; + +import { + OpHistoryMap, + TextOperationSet, + TextOperationSetWithCursor, + getTransformedOperations, + transformPosition, +} from "../ot"; interface SocketDetails { room_id: string; user_id: string; } -const sessions: Record = {}; +enum SocketEvents { + ROOM_JOIN = "api/collaboration-service/room/join", + ROOM_UPDATE = "api/collaboration-service/room/update", + ROOM_SAVE = "api/collaboration-service/room/save", + ROOM_LOAD = "api/collaboration-service/room/load", +} + const socketMap: Record = {}; +const opMap: OpHistoryMap = new OpHistoryMap(); // Data Access Layer function mapSocketToRoomAndUser( @@ -33,55 +54,7 @@ function updateStatus(socket_id: string) { return; } const { room_id } = socketMap[socket_id]; - const session = sessions[room_id]; - if (!session) { - return; - } - - if (session.users.length === 0) { - session.status = "inactive"; - } else { - session.status = "active"; - } -} - -function joinRoom(room_id: string, user_id: string): void { - if (!sessions[room_id]) { - sessions[room_id] = { - users: [user_id], - status: "active", - text: "", - }; - } else { - sessions[room_id].users.push(user_id); - sessions[room_id].status = "active"; - } -} - -function saveRoom(room_id: string, text: string): void { - if (!sessions[room_id]) { - sessions[room_id] = { - users: [], - status: "active", - text: text, - }; - } else { - sessions[room_id].text = text; - } -} - -function saveText(room_id: string, text: string): void { - if (!sessions[room_id]) { - sessions[room_id] = { - users: [], - status: "active", - text: text, - saved_text: text, - }; - } else { - sessions[room_id].text = text; - sessions[room_id].saved_text = text; - } + updateRoomStatus(room_id); } function disconnectUserFromDb(socket_id: string): void { @@ -89,16 +62,7 @@ function disconnectUserFromDb(socket_id: string): void { return; } const { room_id, user_id } = socketMap[socket_id]; - const session = sessions[room_id]; - - if (!session) { - return; - } else { - const index = session.users.indexOf(user_id); - if (index > -1) { - sessions[room_id].users.splice(index, 1); - } - } + removeUserFromRoom(room_id, user_id); } // Socket callbacks @@ -109,22 +73,99 @@ function roomUpdate( text: string ): void { console.log(room_id + " " + socket.id + " text changed:", text); - io.to(room_id).emit("/room/update", { text }); - saveRoom(room_id, text); + const version = opMap.getLatest(room_id)?.version ?? 1; + io.to(room_id).emit(SocketEvents.ROOM_UPDATE, { version, text }); + updateRoomText(room_id, text); } -function roomUpdateFromDb(io: Server, socket: Socket, room_id: string): void { - if (sessions[room_id]) { - const text = sessions[room_id].text; - roomUpdate(io, socket, room_id, text); +function roomUpdateWithCursor( + io: Server, + socket: Socket, + room_id: string, + text: string, + cursor: number +): void { + console.log( + room_id + " " + socket.id + " text changed:", + text, + " cursor:" + cursor + ); + const version = opMap.getLatest(room_id)?.version ?? 1; + socket.broadcast + .to(room_id) + .emit(SocketEvents.ROOM_UPDATE, { version, text }); + socket.emit(SocketEvents.ROOM_UPDATE, { version, text, cursor }); + updateRoomText(room_id, text); +} + +async function handleTextOp( + textOpSet: TextOperationSetWithCursor, + room_id: string +): Promise<{ text: string; cursor: number }> { + console.log(textOpSet); + console.log(opMap.getLatest(room_id)?.version); + var resultTextOps = textOpSet.operations; + + if (opMap.checkIfLatestVersion(room_id, textOpSet.version)) { + textOpSet.version++; + opMap.add(room_id, textOpSet); + } else { + const latestOp = textOpSet.operations; + const mergedOp = opMap.getCombinedTextOpFromVersionToLatest( + room_id, + textOpSet.version + 1 + ); + const [transformedLatestOp, transformedMergedOp] = getTransformedOperations( + latestOp, + mergedOp + ); + opMap.add(room_id, { + version: textOpSet.version + 1, + operations: transformedLatestOp, + }); + console.log(transformedLatestOp); + resultTextOps = transformedLatestOp; } + + return getRoomText(room_id).then((text) => { + var resultText = text; + + try { + resultText = type.apply(text, resultTextOps); + } catch (error) { + // gracefully skip transforming + console.log(error); + } + + return { + text: resultText, + cursor: textOpSet.cursor + ? transformPosition(textOpSet.cursor, resultTextOps) + : -1, + }; + }); } -function loadTextFromDb(io: Server, socket: Socket, room_id: string): void { - if (sessions[room_id] && sessions[room_id].saved_text) { - const text = sessions[room_id].saved_text!; +async function roomUpdateWithTextFromDb( + io: Server, + socket: Socket, + room_id: string +): Promise { + await getRoomText(room_id).then((text) => { roomUpdate(io, socket, room_id, text); - } + }); +} + +async function loadTextFromDb( + io: Server, + socket: Socket, + room_id: string +): Promise { + await getSavedRoomText(room_id).then((text) => { + if (text) { + roomUpdate(io, socket, room_id, text); + } + }); } function userDisconnect(socket: Socket): void { @@ -134,86 +175,62 @@ function userDisconnect(socket: Socket): void { } function initSocketListeners(io: Server, socket: Socket, room_id: string) { - socket.on("/room/update", (text: string) => - roomUpdate(io, socket, room_id, text) + socket.on( + SocketEvents.ROOM_UPDATE, + async (textOpSet: TextOperationSetWithCursor, ackCallback) => { + await handleTextOp(textOpSet, room_id).then(({ text, cursor }) => { + if (cursor > -1) { + roomUpdateWithCursor(io, socket, room_id, text, cursor); + } else { + roomUpdate(io, socket, room_id, text); + } + ackCallback(); + }); + } ); - socket.on("/room/save", (text: string) => saveText(room_id, text)); + socket.on(SocketEvents.ROOM_SAVE, (text: string) => + saveRoomText(room_id, text) + ); - socket.on("/room/load", () => loadTextFromDb(io, socket, room_id)); + socket.on(SocketEvents.ROOM_LOAD, () => loadTextFromDb(io, socket, room_id)); } export const roomRouter = (io: Server) => { const router = express.Router(); - // API to get room details router.get("/:room_id", (req: Request, res: Response) => { const room_id = req.params.room_id as string; - if (!sessions[room_id]) { - return res.status(404).json({ error: "Session not found" }); + if (!isRoomExists(room_id)) { + return res.status(404).json({ error: "Room not found" }); } return res.status(200).json({ - message: "Session exists", + message: "Room exists", room_id: room_id, - info: sessions[room_id], - }); - }); - - // API to join a room - router.post("/join", (req: Request, res: Response) => { - const room_id = req.body.room_id as string; - const user_id = req.body.user_id as string; - - if (!room_id) { - return res.status(400).json({ error: "Invalid input parameters" }); - } - - try { - joinRoom(room_id, user_id); - - res.status(201).json({ - message: "Session created successfully", - room_id: room_id, - info: sessions[room_id], - }); - } catch (error) { - console.error(error); - res.status(500).json({ message: "Error saving session" }); - } - - io.once("connection", (socket: Socket) => { - console.log("Room.ts: User connected:", socket.id); - - socket.join(room_id); - mapSocketToRoomAndUser(socket.id, room_id, user_id); - console.log(socket.id + " joined room:", room_id); - roomUpdateFromDb(io, socket, room_id); - - initSocketListeners(io, socket, room_id); + info: getRoom(room_id), }); }); - // API to save text router.post("/save", (req: Request, res: Response) => { try { const room_id = req.body.room_id as string; const text = req.body.text as string; - if (!(room_id in sessions)) { + if (!isRoomExists(room_id)) { return res.status(400).json({ error: "Invalid roomId provided" }); } - saveText(room_id, text); + saveRoomText(room_id, text); res.status(201).json({ - message: "Session saved successfully", - info: sessions[room_id], + message: "Room saved successfully", + info: getRoom(room_id), }); } catch (error) { console.error(error); - res.status(500).json({ message: "Error saving session" }); + res.status(500).json({ message: "Error saving room" }); } }); @@ -221,12 +238,12 @@ export const roomRouter = (io: Server) => { io.on("connection", (socket: Socket) => { console.log("Room.ts: User connected:", socket.id); - socket.on("/room/join", (room_id: string, user_id: string) => { + socket.on(SocketEvents.ROOM_JOIN, (room_id: string, user_id: string) => { socket.join(room_id); console.log(socket.id + " joined room:", room_id); - joinRoom(room_id, user_id); + createOrUpdateRoomWithUser(room_id, user_id); mapSocketToRoomAndUser(socket.id, room_id, user_id); - roomUpdateFromDb(io, socket, room_id); + roomUpdateWithTextFromDb(io, socket, room_id); initSocketListeners(io, socket, room_id); }); diff --git a/services/collaboration-service/swagger-output.json b/services/collaboration-service/swagger-output.json index c528fca6..d968c3e9 100644 --- a/services/collaboration-service/swagger-output.json +++ b/services/collaboration-service/swagger-output.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost:5001/" + "url": "http://localhost:5003/" } ], "paths": { @@ -44,39 +44,6 @@ } } }, - "/api/collaboration-service/room/join": { - "post": { - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "room_id": { - "example": "any" - }, - "user_id": { - "example": "any" - } - } - } - } - } - } - } - }, "/api/collaboration-service/room/save": { "post": { "description": "", diff --git a/services/collaboration-service/swagger.ts b/services/collaboration-service/swagger.ts index 6b292eba..42f84443 100644 --- a/services/collaboration-service/swagger.ts +++ b/services/collaboration-service/swagger.ts @@ -6,7 +6,7 @@ const doc = { description: "Provides the mechanism for real-time collaboration (e.g., concurrent code editing) between the authenticated and matched users in the collaborative space", }, - host: "localhost:5001", + host: "localhost:5003", schemes: ["http"], }; @@ -17,9 +17,9 @@ const endpointsFiles = ["./src/app.ts"]; 'endpointsFiles' only the root file where the route starts, such as index.js, app.js, routes.js, ... */ -swaggerAutogen({ openapi: "3.0.0" })(outputFile, endpointsFiles, doc) - /*.then( +swaggerAutogen({ openapi: "3.0.0" })(outputFile, endpointsFiles, doc); +/*.then( async () => { await import("./src/app"); // Your project's root file } - );*/ // to run it after swagger-autogen + );*/ // to run it after swagger-autogen diff --git a/services/collaboration-service/tsconfig.json b/services/collaboration-service/tsconfig.json index 20471660..415c3b3d 100644 --- a/services/collaboration-service/tsconfig.json +++ b/services/collaboration-service/tsconfig.json @@ -6,7 +6,7 @@ "strict": true, "esModuleInterop": true, "resolveJsonModule": true, - "types": [] + "types": ["node"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules"] diff --git a/utils/shared-ot.ts b/utils/shared-ot.ts new file mode 100644 index 00000000..a1b4c33e --- /dev/null +++ b/utils/shared-ot.ts @@ -0,0 +1,37 @@ +import { diff_match_patch } from "diff-match-patch"; +import { insert, remove, TextOp } from "ot-text-unicode"; + +export interface TextOperationSet { + version: number; + operations: TextOp; +} + +export interface TextOperationSetWithCursor extends TextOperationSet { + cursor?: number; +} + +export function createTextOpFromTexts( + prevText: string, + currentText: string +): TextOp { + const dmp = new diff_match_patch(); + const diffs = dmp.diff_main(prevText, currentText); + //dmp.diff_cleanupSemantic(diffs); + + var textop: TextOp = []; + + var skipn: number = 0; + + for (const [operation, text] of diffs) { + if (operation === 0) { + skipn += text.length; + } else if (operation === -1) { + textop = [...textop, ...remove(skipn, text)]; + skipn = 0; + } else { + textop = [...textop, ...insert(skipn, text)]; + skipn = 0; + } + } + return textop; +} diff --git a/yarn.lock b/yarn.lock index 4f2ee18c..4d7161b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2821,6 +2821,11 @@ dependencies: "@types/node" "*" +"@types/diff-match-patch@^1.0.34": + version "1.0.34" + resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.34.tgz#a4c1bbf2f992ac272047a76f3de4da6f867fde18" + integrity sha512-GPT65LkqMpttT0BrYBzSv4FYgiEh7TXYxxFW8ufxn3+d6PhEJKdD4OAS4s0n8reeEku1ki56J2zj5FIPi5unVQ== + "@types/duplexify@^3.6.0": version "3.6.2" resolved "https://registry.yarnpkg.com/@types/duplexify/-/duplexify-3.6.2.tgz#6b6253ceacb9c18f507102e8ff2dd7c2b0e048a8" @@ -2962,7 +2967,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^20.6.2", "@types/node@^20.6.3": +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^20.6.2": version "20.8.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.3.tgz#c4ae2bb1cfab2999ed441a95c122bbbe1567a66d" integrity sha512-jxiZQFpb+NlH5kjW49vXxvxTjeeqlbsnTAdBTKpzEdPs9itay7MscYXz3Fo9VYFEsfQ6LJFitHad3faerLAjCw== @@ -2972,6 +2977,18 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16" integrity sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg== +"@types/node@^20.6.3": + version "20.8.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" + integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w== + +"@types/node@^20.8.4": + version "20.8.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.4.tgz#0e9ebb2ff29d5c3302fc84477d066fa7c6b441aa" + integrity sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A== + dependencies: + undici-types "~5.25.1" + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -4822,6 +4839,18 @@ deep-eql@^4.1.3: dependencies: type-detect "^4.0.0" +deep-equal@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -4951,6 +4980,11 @@ didyoumean@^1.2.2: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -7190,6 +7224,14 @@ is-accessor-descriptor@^1.0.0: dependencies: kind-of "^6.0.0" +is-arguments@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe" @@ -7433,7 +7475,7 @@ is-plain-object@^5.0.0: resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== -is-regex@^1.1.4: +is-regex@^1.0.4, is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -7821,6 +7863,13 @@ json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== +json0-ot-diff@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/json0-ot-diff/-/json0-ot-diff-1.1.2.tgz#3565b8b016992b750c364558f5b5ffd56a0749c2" + integrity sha512-je6cDbmPc+BkbfyvKo7y1jgQLTrX81L8fkKEIPXRUGFSxK4HTSF6u44ELR35i12tEIWh5+8KfIJH2aJgzKrFww== + dependencies: + deep-equal "^1.0.1" + json5-writer@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/json5-writer/-/json5-writer-0.1.8.tgz#98e1934ef6002f8ac12f36438e2b39c49af213fd" @@ -8909,6 +8958,14 @@ object-inspect@^1.12.3, object-inspect@^1.9.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== +object-is@^1.0.1: + version "1.1.5" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" + integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -9186,6 +9243,20 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +ot-json1@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ot-json1/-/ot-json1-1.0.2.tgz#319c98d29af2d0344b84c9b99cbbd95826b16ef7" + integrity sha512-IhxkqVWQqlkWULoi/Q2AdzKk0N5vQRbUMUwubFXFCPcY4TsOZjmp2YKrk0/z1TeiECPadWEK060sdFdQ3Grokg== + dependencies: + ot-text-unicode "4" + +ot-text-unicode@4, ot-text-unicode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ot-text-unicode/-/ot-text-unicode-4.0.0.tgz#778a327535c81ed265b36ebe1bd677f31bae1e32" + integrity sha512-W7ZLU8QXesY2wagYFv47zErXud3E93FGImmSGJsQnBzE+idcPPyo2u2KMilIrTwBh4pbCizy71qRjmmV6aDhcQ== + dependencies: + unicount "1.1" + p-defer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-3.0.0.tgz#d1dceb4ee9b2b604b1d94ffec83760175d4e6f83" @@ -10029,7 +10100,7 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexp.prototype.flags@^1.5.0, regexp.prototype.flags@^1.5.1: +regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.5.0, regexp.prototype.flags@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz#90ce989138db209f81492edd734183ce99f9677e" integrity sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg== @@ -11615,6 +11686,11 @@ underscore@~1.13.2: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== +undici-types@~5.25.1: + version "5.25.3" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.25.3.tgz#e044115914c85f0bcbb229f346ab739f064998c3" + integrity sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -11638,6 +11714,11 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unicount@1.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unicount/-/unicount-1.1.0.tgz#396a3df661c19675a93861ac878c2c9c0042abf0" + integrity sha512-RlwWt1ywVW4WErPGAVHw/rIuJ2+MxvTME0siJ6lk9zBhpDfExDbspe6SRlWT3qU6AucNjotPl9qAJRVjP7guCQ== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"