Adds base for multi-room

Co-authored-by: roesnerb <roesnerb@users.noreply.github.com>
Co-authored-by: MathieuSevignyLavallee <MathieuSevignyLavallee@users.noreply.github.com>
This commit is contained in:
Gabriel Matte 2024-10-29 16:47:10 -04:00
parent c8f9c5470c
commit 32bcb67f33
13 changed files with 5546 additions and 10 deletions

2
.gitignore vendored
View file

@ -129,3 +129,5 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
db-backup/ db-backup/
deployments

View file

@ -49,6 +49,13 @@ services:
- mongodb_data:/data/db - mongodb_data:/data/db
restart: always restart: always
# Ce conteneur contient la base de donnée cache nécessaire au bon fonctionnement des salles de quiz
valkey:
image: valkey/valkey:alpine
container_name: valkey
volumes:
- ./deployments/valkey.conf:/usr/local/etc/valkey/valkey.conf
# Ce conteneur assure que l'application est à jour en allant chercher s'il y a des mises à jours à chaque heure # Ce conteneur assure que l'application est à jour en allant chercher s'il y a des mises à jours à chaque heure
watchtower: watchtower:
image: containrrr/watchtower image: containrrr/watchtower

18
quizRoom/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
# Use the Node base image
FROM node:18
# Create a working directory
WORKDIR /usr/src/app
# Copy package.json and install dependencies
COPY package*.json ./
RUN npm install
# Copy all source code to the container
COPY . .
# Expose WebSocket server port
EXPOSE 4500
# Start the WebSocket server
CMD ["node", "app.js"]

27
quizRoom/app.ts Normal file
View file

@ -0,0 +1,27 @@
import http from "http";
import { Server, ServerOptions } from "socket.io";
// Import setupWebsocket
import { setupWebsocket } from "./socket/setupWebSocket";
const port = process.env.WS_PORT ? parseInt(process.env.WS_PORT) : 4500;
// Create HTTP and WebSocket server
const server = http.createServer();
const ioOptions: Partial<ServerOptions> = {
path: "/socket.io",
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true,
},
};
const io = new Server(server, ioOptions);
// Initialize WebSocket setup
setupWebsocket(io);
server.listen(port, () => {
console.log(`WebSocket server is running on port ${port}`);
});

4784
quizRoom/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
quizRoom/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "ets-pfe004-evaluetonsavoir-quizroom",
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "jest --colors"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.7.2"
},
"devDependencies": {
"jest": "^29.7.0",
"jest-mock": "^29.7.0"
}
}

View file

@ -0,0 +1,101 @@
import { Server, Socket } from "socket.io";
const MAX_USERS_PER_ROOM = 60;
const MAX_TOTAL_CONNECTIONS = 2000;
export const setupWebsocket = (io: Server): void => {
let totalConnections = 0;
io.on("connection", (socket: Socket) => {
if (totalConnections >= MAX_TOTAL_CONNECTIONS) {
console.log("Connection limit reached. Disconnecting client.");
socket.emit("join-failure", "Le nombre maximum de connexions a été atteint");
socket.disconnect(true);
return;
}
totalConnections++;
console.log("A user connected:", socket.id, "| Total connections:", totalConnections);
socket.on("create-room", (sentRoomName?: string) => {
const roomName = sentRoomName ? sentRoomName.toUpperCase() : generateRoomName();
if (!io.sockets.adapter.rooms.get(roomName)) {
socket.join(roomName);
socket.emit("create-success", roomName);
} else {
socket.emit("create-failure");
}
});
socket.on("join-room", ({ enteredRoomName, username }: { enteredRoomName: string; username: string }) => {
if (io.sockets.adapter.rooms.has(enteredRoomName)) {
const clientsInRoom = io.sockets.adapter.rooms.get(enteredRoomName)?.size || 0;
if (clientsInRoom <= MAX_USERS_PER_ROOM) {
socket.join(enteredRoomName);
socket.to(enteredRoomName).emit("user-joined", { id: socket.id, name: username, answers: [] });
socket.emit("join-success");
} else {
socket.emit("join-failure", "La salle est remplie");
}
} else {
socket.emit("join-failure", "Le nom de la salle n'existe pas");
}
});
socket.on("next-question", ({ roomName, question }: { roomName: string; question: string }) => {
socket.to(roomName).emit("next-question", question);
});
socket.on("launch-student-mode", ({ roomName, questions }: { roomName: string; questions: string[] }) => {
socket.to(roomName).emit("launch-student-mode", questions);
});
socket.on("end-quiz", ({ roomName }: { roomName: string }) => {
socket.to(roomName).emit("end-quiz");
});
socket.on("message", (data: string) => {
console.log("Received message from", socket.id, ":", data);
});
socket.on("disconnect", () => {
totalConnections--;
console.log("A user disconnected:", socket.id, "| Total connections:", totalConnections);
for (const [room] of io.sockets.adapter.rooms) {
if (room !== socket.id) {
io.to(room).emit("user-disconnected", socket.id);
}
}
});
socket.on("submit-answer", ({
roomName,
username,
answer,
idQuestion,
}: {
roomName: string;
username: string;
answer: string;
idQuestion: string;
}) => {
socket.to(roomName).emit("submit-answer-room", {
idUser: socket.id,
username,
answer,
idQuestion,
});
});
});
const generateRoomName = (length = 6): string => {
const characters = "0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
};
};

View file

@ -0,0 +1,75 @@
import { Redis } from 'ioredis';
import {
RoomInfo,
RoomOptions,
ProviderType,
ProviderConfig
} from '../../types/room';
import { BaseRoomProvider } from './providers/base-provider';
import { ClusterRoomProvider } from './providers/cluster-provider';
import { DockerRoomProvider } from './providers/docker-provider';
import { KubernetesRoomProvider } from './providers/kubernetes-provider';
interface RoomManagerOptions {
redisUrl?: string;
provider?: ProviderType;
providerOptions?: ProviderConfig;
}
export class RoomManager {
private redis: Redis;
private provider: BaseRoomProvider<RoomInfo>;
constructor(options: RoomManagerOptions = {}) {
this.redis = new Redis(options.redisUrl || process.env.REDIS_URL);
this.provider = this.createProvider(
options.provider || process.env.ROOM_PROVIDER as ProviderType || 'cluster',
options.providerOptions
);
this.setupCleanup();
}
private createProvider(
type: ProviderType,
options?: ProviderConfig
): BaseRoomProvider<RoomInfo> {
switch (type) {
case 'cluster':
return new ClusterRoomProvider(this.redis, options);
case 'docker':
return new DockerRoomProvider(this.redis, options);
case 'kubernetes':
return new KubernetesRoomProvider(this.redis, options);
default:
throw new Error(`Unknown provider type: ${type}`);
}
}
private setupCleanup(): void {
setInterval(() => {
this.provider.cleanup().catch(console.error);
}, 30000);
}
async createRoom(options: RoomOptions = {}): Promise<RoomInfo> {
const roomId = options.roomId || this.generateRoomId();
return await this.provider.createRoom(roomId, options);
}
async deleteRoom(roomId: string): Promise<void> {
return await this.provider.deleteRoom(roomId);
}
async getRoomStatus(roomId: string): Promise<RoomInfo | null> {
return await this.provider.getRoomStatus(roomId);
}
async listRooms(): Promise<RoomInfo[]> {
return await this.provider.listRooms();
}
private generateRoomId(): string {
return `room-${Math.random().toString(36).substr(2, 9)}`;
}
}

299
server/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@valkey/valkey-glide": "^1.1.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.4", "dotenv": "^16.4.4",
@ -21,6 +22,7 @@
"socket.io-client": "^4.7.2" "socket.io-client": "^4.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-mock": "^29.7.0", "jest-mock": "^29.7.0",
@ -1156,6 +1158,80 @@
"sparse-bitfield": "^3.0.3" "sparse-bitfield": "^3.0.3"
} }
}, },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.8", "version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -1273,11 +1349,12 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.8.7", "version": "22.8.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.4.tgz",
"integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", "integrity": "sha512-SpNNxkftTJOPk0oN+y2bIqurEXHTA2AOZ3EJDDKeJ5VzkvvORSvmQXGQarcOzWV1ac7DCaPBEdMDxBsM+d8jWw==",
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~5.25.1" "undici-types": "~6.19.8"
} }
}, },
"node_modules/@types/stack-utils": { "node_modules/@types/stack-utils": {
@ -1314,6 +1391,155 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"dev": true "dev": true
}, },
"node_modules/@valkey/valkey-glide": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide/-/valkey-glide-1.1.0.tgz",
"integrity": "sha512-7+NJxMiCfE/p5p7HpLnEKOVaYEcrMnTbGNT9ZBjP0QC8KO7BwSLnWlRYnnb4/6j6zEBc7ugVZ0CvJjJvTslcjw==",
"license": "Apache-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"optionalDependencies": {
"@valkey/valkey-glide-darwin-arm64": "1.1.0",
"@valkey/valkey-glide-darwin-x64": "1.1.0",
"@valkey/valkey-glide-linux-arm64": "1.1.0",
"@valkey/valkey-glide-linux-musl-arm64": "1.1.0",
"@valkey/valkey-glide-linux-musl-x64": "1.1.0",
"@valkey/valkey-glide-linux-x64": "1.1.0"
}
},
"node_modules/@valkey/valkey-glide-darwin-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide-darwin-arm64/-/valkey-glide-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-0ekMcRVcC+VhNG3ZeiLFNrIi9nxq2TM8Y0qoyGPpcb3q0+4AaSnHQR2YPnvn5Befq75ODJ+RctxQ6fbYNaArDg==",
"bundleDependencies": [
"glide-rs"
],
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"glide-rs": "file:rust-client",
"long": "^5.2.3",
"npmignore": "^0.3.1",
"protobufjs": "^7.4.0"
}
},
"node_modules/@valkey/valkey-glide-darwin-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide-darwin-x64/-/valkey-glide-darwin-x64-1.1.0.tgz",
"integrity": "sha512-fFfV5YPQ3RAnwlyjvyvfq6gxFo1FZMhChqINxxtKClwzIFg7493p3WNuOhH0M+CNyIGCle9tMx8dLPNWVdVk0Q==",
"bundleDependencies": [
"glide-rs"
],
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"glide-rs": "file:rust-client",
"long": "^5.2.3",
"npmignore": "^0.3.1",
"protobufjs": "^7.4.0"
}
},
"node_modules/@valkey/valkey-glide-linux-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide-linux-arm64/-/valkey-glide-linux-arm64-1.1.0.tgz",
"integrity": "sha512-jY9MOeu0Ck9yLMv+LUTDCytDi8JMuWd6w27t2BKWvdnFSOLmZcKqBhkfIhQWBoXqo42uhcxzHULC5Pwtl0jLQg==",
"bundleDependencies": [
"glide-rs"
],
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"dependencies": {
"glide-rs": "file:rust-client",
"long": "^5.2.3",
"npmignore": "^0.3.1",
"protobufjs": "^7.4.0"
}
},
"node_modules/@valkey/valkey-glide-linux-musl-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide-linux-musl-arm64/-/valkey-glide-linux-musl-arm64-1.1.0.tgz",
"integrity": "sha512-8H8BLG4ZEZuZSvuoYGKO157r/gMI3QnfjGMv3J+uzP2yKj2vZdqh9I46wiJsHl6B3gxFxtrleX7zoo5HY5NcoQ==",
"bundleDependencies": [
"glide-rs"
],
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"dependencies": {
"glide-rs": "file:rust-client",
"long": "^5.2.3",
"npmignore": "^0.3.1",
"protobufjs": "^7.4.0"
}
},
"node_modules/@valkey/valkey-glide-linux-musl-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide-linux-musl-x64/-/valkey-glide-linux-musl-x64-1.1.0.tgz",
"integrity": "sha512-ljpgbfH124GeoFGIYzdoK5C8xsDCehm2U9RwYVJq2SAc5XyIyI80d7s0ZrjVAOhV77ebbVYn2i/j5bL/v/u/cw==",
"bundleDependencies": [
"glide-rs"
],
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"dependencies": {
"glide-rs": "file:rust-client",
"long": "^5.2.3",
"npmignore": "^0.3.1",
"protobufjs": "^7.4.0"
}
},
"node_modules/@valkey/valkey-glide-linux-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@valkey/valkey-glide-linux-x64/-/valkey-glide-linux-x64-1.1.0.tgz",
"integrity": "sha512-TIvJxMTCRa551u6HHwvLsJjJ+RSi8V4FiSTMJ9GOa5WAlT07A3bSDbMzxF7JqfvfDFFRk4vO1B/k+1JE3SlV6A==",
"bundleDependencies": [
"glide-rs"
],
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"dependencies": {
"glide-rs": "file:rust-client",
"long": "^5.2.3",
"npmignore": "^0.3.1",
"protobufjs": "^7.4.0"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -2214,9 +2440,10 @@
} }
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -4075,6 +4302,13 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
}, },
"node_modules/long": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -4501,6 +4735,25 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/npmignore": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/npmignore/-/npmignore-0.3.1.tgz",
"integrity": "sha512-bBDWyDhP/p7fFlAvKrN1gl/q0nsxkouezRBJmfzvJNHnWbRlC8j2xV9zteIkS9tlFuECgaV3nlJixQpJRe5EQg==",
"license": "MIT",
"optional": true,
"dependencies": {
"minimist": "^1.2.8"
},
"bin": {
"npmignore": "bin/npmignore"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/npmlog": { "node_modules/npmlog": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
@ -4747,6 +5000,31 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/protobufjs": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -5598,9 +5876,10 @@
"dev": true "dev": true
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.25.3", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==" "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",

View file

@ -13,6 +13,7 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@valkey/valkey-glide": "^1.1.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.4", "dotenv": "^16.4.4",
@ -25,6 +26,7 @@
"socket.io-client": "^4.7.2" "socket.io-client": "^4.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-mock": "^29.7.0", "jest-mock": "^29.7.0",

View file

@ -0,0 +1,34 @@
import { GlideClient, GlideString } from "@valkey/valkey-glide";
import { RoomInfo, RoomOptions, BaseProviderConfig } from '../../types/room';
export abstract class BaseRoomProvider<T extends RoomInfo> {
protected valkey: GlideClient;
constructor(valkey: GlideClient, protected config: BaseProviderConfig = {}) {
this.valkey = valkey;
}
abstract createRoom(roomId: string, options?: RoomOptions): Promise<T>;
abstract deleteRoom(roomId: string): Promise<void>;
abstract getRoomStatus(roomId: string): Promise<T | null>;
abstract listRooms(): Promise<T[]>;
abstract cleanup(): Promise<void>;
protected async updateRoomInfo(roomId: string, info: Partial<RoomInfo>): Promise<boolean> {
let room = await this.getRoomInfo(roomId);
if(!room) return false;
for(let key in Object.keys(room)){
room[key]= info[key];
}
const result = await this.valkey.set(`room:${roomId}`,room as Object as GlideString);
return result != null;
}
protected async getRoomInfo(roomId: string): Promise<RoomInfo | null> {
const info = (await this.valkey.get(`room:${roomId}`)) as RoomInfo | null;
return info;
}
}

View file

@ -0,0 +1,117 @@
import * as cluster from 'node:cluster';
import { cpus } from 'node:os';
import { GlideClient } from "@valkey/valkey-glide";
import {
ClusterRoomInfo,
RoomOptions,
ClusterProviderConfig
} from '../../types/room';
import { BaseRoomProvider } from './base-provider';
export class ClusterRoomProvider extends BaseRoomProvider<ClusterRoomInfo> {
private workers: Map<number, { rooms: Set<string> }>;
constructor(valkey: GlideClient, config: ClusterProviderConfig = {}) {
super(valkey, config);
this.workers = new Map();
if (cluster.default.isPrimary) {
this.initializeCluster();
}
}
private initializeCluster(): void {
const numCPUs = cpus().length;
for (let i = 0; i < numCPUs; i++) {
const worker = cluster.default.fork();
this.handleWorkerMessages(worker);
}
cluster.default.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Starting new worker...`);
const newWorker = cluster.default.fork();
this.handleWorkerMessages(newWorker);
});
}
private handleWorkerMessages(worker: cluster.Worker): void {
worker.on('message', async (msg: {
type: string;
roomId: string;
status: string;
}) => {
if (msg.type === 'room_status') {
await this.updateRoomInfo(msg.roomId, {
status: msg.status as any,
workerId: worker.id,
lastUpdate: Date.now()
} as Partial<ClusterRoomInfo>);
}
});
}
async createRoom(roomId: string, options: RoomOptions = {}): Promise<ClusterRoomInfo> {
const workerLoads = Array.from(this.workers.entries())
.map(([id, data]) => ({
id,
rooms: data.rooms.size
}))
.sort((a, b) => a.rooms - b.rooms);
const workerId = workerLoads[0].id;
const worker = cluster.default.workers?.[workerId];
if (!worker) {
throw new Error('No available workers');
}
worker.send({ type: 'create_room', roomId, options });
const roomInfo: ClusterRoomInfo = {
roomId,
provider: 'cluster',
status: 'creating',
workerId,
pid: worker.process.pid!,
createdAt: Date.now()
};
await this.updateRoomInfo(roomId, roomInfo);
return roomInfo;
}
async deleteRoom(roomId: string): Promise<void> {
const roomInfo = await this.getRoomInfo(roomId) as ClusterRoomInfo;
if (roomInfo?.workerId && cluster.Worker?.[roomInfo.workerId]) {
cluster.Worker[roomInfo.workerId].send({
type: 'delete_room',
roomId
});
}
await this.valkey.del(['room',roomId]);
}
async getRoomStatus(roomId: string): Promise<ClusterRoomInfo | null> {
return await this.getRoomInfo(roomId) as ClusterRoomInfo | null;
}
async listRooms(): Promise<ClusterRoomInfo[]> {
const keys = await this.valkey.hkeys('room:*');
const rooms = await Promise.all(
keys.map(key => this.getRoomInfo(key.toString().split(':')[1]))
);
return rooms.filter((room): room is ClusterRoomInfo => room !== null);
}
async cleanup(): Promise<void> {
const rooms = await this.listRooms();
const staleTimeout = 30000; // 30 seconds
for (const room of rooms) {
if (Date.now() - (room.lastUpdate || room.createdAt) > staleTimeout) {
await this.deleteRoom(room.roomId);
}
}
}
}

69
types/room.ts Normal file
View file

@ -0,0 +1,69 @@
export interface RoomInfo {
roomId: string;
status: RoomStatus;
createdAt: number;
lastUpdate?: number;
provider: ProviderType;
error?: string;
}
export interface RoomOptions {
roomId?: string;
maxUsers?: number;
timeout?: number;
[key: string]: any;
}
export type RoomStatus = 'creating' | 'running' | 'error' | 'terminated';
export type ProviderType = 'cluster' | 'docker' | 'kubernetes';
// Provider-specific room information
export interface ClusterRoomInfo extends RoomInfo {
workerId: number;
pid: number;
}
export interface DockerRoomInfo extends RoomInfo {
containerId: string;
containerIp: string;
containerStatus?: {
Running: boolean;
StartedAt: string;
FinishedAt: string;
};
}
export interface KubernetesRoomInfo extends RoomInfo {
deploymentName: string;
namespace: string;
deploymentStatus?: {
availableReplicas: number;
readyReplicas: number;
replicas: number;
};
}
// Provider configuration interfaces
export interface BaseProviderConfig {
redisUrl?: string;
}
export interface ClusterProviderConfig extends BaseProviderConfig {
maxWorkersPerRoom?: number;
}
export interface DockerProviderConfig extends BaseProviderConfig {
dockerConfig?: any;
networkName?: string;
containerImage?: string;
}
export interface KubernetesProviderConfig extends BaseProviderConfig {
namespace?: string;
kubeConfig?: any;
}
export type ProviderConfig =
| ClusterProviderConfig
| DockerProviderConfig
| KubernetesProviderConfig;