mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-12 12:04:16 +00:00
591 lines
18 KiB
JavaScript
591 lines
18 KiB
JavaScript
import socketio from "socket.io"
|
|
import { createAdapter } from "@socket.io/cluster-adapter"
|
|
import { setupWorker } from "@socket.io/sticky"
|
|
|
|
import withWsAuth from "@middlewares/withWsAuth"
|
|
|
|
function generateFnHandler(fn, socket) {
|
|
return async (...args) => {
|
|
if (typeof socket === "undefined") {
|
|
socket = arguments[0]
|
|
}
|
|
|
|
try {
|
|
fn(socket, ...args)
|
|
} catch (error) {
|
|
console.error(`[HANDLER_ERROR] ${error.message} >`, error.stack)
|
|
|
|
if (typeof socket.emit !== "function") {
|
|
return false
|
|
}
|
|
|
|
return socket.emit("error", {
|
|
message: error.message,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function composePayloadData(socket, data = {}) {
|
|
return {
|
|
user: {
|
|
user_id: socket.userData._id,
|
|
username: socket.userData.username,
|
|
fullName: socket.userData.fullName,
|
|
avatar: socket.userData.avatar,
|
|
},
|
|
command_issuer: data.command_issuer ?? socket.userData._id,
|
|
...data
|
|
}
|
|
}
|
|
|
|
class Room {
|
|
constructor(io, roomId, roomOptions = { title: "Untitled Room" }) {
|
|
if (!io) {
|
|
throw new Error("io is required")
|
|
}
|
|
|
|
this.io = io
|
|
this.roomId = roomId
|
|
this.roomOptions = roomOptions
|
|
}
|
|
|
|
// declare the maximum audio offset from owner
|
|
static maxOffsetFromOwner = 1
|
|
|
|
ownerUserId = null
|
|
|
|
connections = []
|
|
|
|
limitations = {
|
|
maxConnections: 10,
|
|
}
|
|
|
|
currentState = null
|
|
|
|
events = {
|
|
"music:player:start": (socket, data) => {
|
|
// dispached when someone start playing a new track
|
|
// if not owner, do nothing
|
|
if (socket.userData._id !== this.ownerUserId) {
|
|
return false
|
|
}
|
|
|
|
if (data.state) {
|
|
this.currentState = data.state
|
|
}
|
|
|
|
this.io.to(this.roomId).emit("music:player:start", composePayloadData(socket, data))
|
|
},
|
|
"music:player:seek": (socket, data) => {
|
|
// dispached when someone seek the track
|
|
// if not owner, do nothing
|
|
if (socket.userData._id !== this.ownerUserId) {
|
|
return false
|
|
}
|
|
|
|
if (data.state) {
|
|
this.currentState = data.state
|
|
}
|
|
|
|
this.io.to(this.roomId).emit("music:player:seek", composePayloadData(socket, data))
|
|
},
|
|
"music:player:loading": (socket, data) => {
|
|
// TODO: Softmode and Hardmode
|
|
// Ignore if is the owner
|
|
if (socket.userData._id === this.ownerUserId) {
|
|
return false
|
|
}
|
|
|
|
// if not loading, check if need to sync
|
|
if (!data.loading) {
|
|
// try to sync with current state
|
|
if (data.state.time > this.currentState.time + Room.maxOffsetFromOwner) {
|
|
socket.emit("music:player:seek", composePayloadData(socket, {
|
|
position: this.currentState.time,
|
|
command_issuer: this.ownerUserId,
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
"music:player:status": (socket, data) => {
|
|
if (socket.userData._id !== this.ownerUserId) {
|
|
return false
|
|
}
|
|
|
|
if (data.state) {
|
|
this.currentState = data.state
|
|
}
|
|
|
|
this.io.to(this.roomId).emit("music:player:status", composePayloadData(socket, data))
|
|
},
|
|
// UPDATE TICK
|
|
"music:state:update": (socket, data) => {
|
|
if (socket.userData._id === this.ownerUserId) {
|
|
// update current state
|
|
this.currentState = data
|
|
|
|
return true
|
|
}
|
|
|
|
if (!this.currentState) {
|
|
return false
|
|
}
|
|
|
|
if (data.loading) {
|
|
return false
|
|
}
|
|
|
|
// check if match with current manifest
|
|
if (!data.manifest || data.manifest._id !== this.currentState.manifest._id) {
|
|
socket.emit("music:player:start", composePayloadData(socket, {
|
|
manifest: this.currentState.manifest,
|
|
time: this.currentState.time,
|
|
command_issuer: this.ownerUserId,
|
|
}))
|
|
}
|
|
|
|
if (data.firstSync) {
|
|
// if not owner, try to sync with current state
|
|
if (data.time > this.currentState.time + Room.maxOffsetFromOwner) {
|
|
socket.emit("music:player:seek", composePayloadData(socket, {
|
|
position: this.currentState.time,
|
|
command_issuer: this.ownerUserId,
|
|
}))
|
|
}
|
|
|
|
// check if match with current playing status
|
|
if (data.playbackStatus !== this.currentState.playbackStatus && data.firstSync) {
|
|
socket.emit("music:player:status", composePayloadData(socket, {
|
|
status: this.currentState.playbackStatus,
|
|
command_issuer: this.ownerUserId,
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
// ROOM MODERATION CONTROL
|
|
"room:moderation:kick": (socket, data) => {
|
|
if (socket.userData._id !== this.ownerUserId) {
|
|
return socket.emit("error", {
|
|
message: "You are not the owner of this room, cannot kick this user",
|
|
})
|
|
}
|
|
|
|
const { room_id, user_id } = data
|
|
|
|
if (this.roomId !== room_id) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to room ${room_id}, cannot kick`)
|
|
|
|
return socket.emit("error", {
|
|
message: "You are not connected to requested room, cannot kick this user",
|
|
})
|
|
}
|
|
|
|
const socket_conn = this.connections.find((socket_conn) => {
|
|
return socket_conn.userData._id === user_id
|
|
})
|
|
|
|
if (!socket_conn) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] not found user ${user_id} in room ${room_id}, cannot kick`)
|
|
|
|
return socket.emit("error", {
|
|
message: "User not found in room, cannot kick",
|
|
})
|
|
}
|
|
|
|
socket_conn.emit("room:moderation:kicked", {
|
|
room_id,
|
|
})
|
|
|
|
this.leave(socket_conn)
|
|
},
|
|
"room:moderation:transfer_ownership": (socket, data) => {
|
|
if (socket.userData._id !== this.ownerUserId) {
|
|
return socket.emit("error", {
|
|
message: "You are not the owner of this room, cannot transfer ownership",
|
|
})
|
|
}
|
|
|
|
const { room_id, user_id } = data
|
|
|
|
if (this.roomId !== room_id) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to room ${room_id}, cannot transfer ownership`)
|
|
|
|
return socket.emit("error", {
|
|
message: "You are not connected to requested room, cannot transfer ownership",
|
|
})
|
|
}
|
|
|
|
const socket_conn = this.connections.find((socket_conn) => {
|
|
return socket_conn.userData._id === user_id
|
|
})
|
|
|
|
if (!socket_conn) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] not found user ${user_id} in room ${room_id}, cannot transfer ownership`)
|
|
|
|
return socket.emit("error", {
|
|
message: "User not found in room, cannot transfer ownership",
|
|
})
|
|
}
|
|
|
|
this.transferOwner(socket_conn)
|
|
}
|
|
}
|
|
|
|
join = (socket) => {
|
|
// set connected room name
|
|
socket.connectedRoomId = this.roomId
|
|
|
|
// join room
|
|
socket.join(this.roomId)
|
|
|
|
// add to connections
|
|
this.connections.push(socket)
|
|
|
|
// emit to self
|
|
socket.emit("room:joined", this.composeRoomData())
|
|
|
|
// emit to others
|
|
this.io.to(this.roomId).emit("room:user:joined", {
|
|
user: {
|
|
user_id: socket.userData._id,
|
|
username: socket.userData.username,
|
|
fullName: socket.userData.fullName,
|
|
avatar: socket.userData.avatar,
|
|
}
|
|
})
|
|
|
|
// register events
|
|
for (const [event, fn] of Object.entries(this.events)) {
|
|
const handler = generateFnHandler(fn, socket)
|
|
|
|
if (!Array.isArray(socket.handlers)) {
|
|
socket.handlers = []
|
|
}
|
|
|
|
socket.handlers.push([event, handler])
|
|
|
|
socket.on(event, handler)
|
|
}
|
|
|
|
// send current state
|
|
this.sendRoomData()
|
|
|
|
console.log(`[${socket.id}][@${socket.userData.username}] joined room ${this.roomId}`)
|
|
}
|
|
|
|
leave = (socket) => {
|
|
// if not connected to any room, do nothing
|
|
if (!socket.connectedRoomId) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to any room`)
|
|
return
|
|
}
|
|
|
|
// if not connected to this room, do nothing
|
|
if (socket.connectedRoomId !== this.roomId) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] not connected to room ${this.roomId}, cannot leave`)
|
|
return false
|
|
}
|
|
|
|
// leave room
|
|
socket.leave(this.roomId)
|
|
|
|
// remove from connections
|
|
const connIndex = this.connections.findIndex((socket_conn) => socket_conn.id === socket.id)
|
|
|
|
if (connIndex !== -1) {
|
|
this.connections.splice(connIndex, 1)
|
|
}
|
|
|
|
// remove connected room name
|
|
socket.connectedRoomId = null
|
|
|
|
// emit to self
|
|
socket.emit("room:left", this.composeRoomData())
|
|
|
|
// emit to others
|
|
this.io.to(this.roomId).emit("room:user:left", {
|
|
user: {
|
|
user_id: socket.userData._id,
|
|
username: socket.userData.username,
|
|
fullName: socket.userData.fullName,
|
|
avatar: socket.userData.avatar,
|
|
},
|
|
})
|
|
|
|
// unregister events
|
|
for (const [event, handler] of socket.handlers) {
|
|
socket.off(event, handler)
|
|
}
|
|
|
|
// send current state
|
|
this.sendRoomData()
|
|
|
|
console.log(`[${socket.id}][@${socket.userData.username}] left room ${this.roomId}`)
|
|
}
|
|
|
|
composeRoomData = () => {
|
|
return {
|
|
roomId: this.roomId,
|
|
limitations: this.limitations,
|
|
ownerUserId: this.ownerUserId,
|
|
options: this.roomOptions,
|
|
connectedUsers: this.connections.map((socket_conn) => {
|
|
return {
|
|
user_id: socket_conn.userData._id,
|
|
username: socket_conn.userData.username,
|
|
fullName: socket_conn.userData.fullName,
|
|
avatar: socket_conn.userData.avatar,
|
|
}
|
|
}),
|
|
currentState: this.currentState,
|
|
}
|
|
}
|
|
|
|
sendRoomData = () => {
|
|
this.io.to(this.roomId).emit("room:current-data", this.composeRoomData())
|
|
}
|
|
|
|
transferOwner = (socket) => {
|
|
if (!socket || !socket.userData) {
|
|
console.warn(`[${socket.id}] cannot transfer owner for room [${this.roomId}], no user data`)
|
|
return false
|
|
}
|
|
|
|
this.ownerUserId = socket.userData._id
|
|
|
|
console.log(`[${socket.id}][@${socket.userData.username}] is now the owner of the room [${this.roomId}]`)
|
|
|
|
this.io.to(this.roomId).emit("room:owner:changed", {
|
|
ownerUserId: this.ownerUserId,
|
|
})
|
|
|
|
this.sendRoomData()
|
|
}
|
|
|
|
destroy = () => {
|
|
for (const socket of this.connections) {
|
|
this.leave(socket)
|
|
}
|
|
|
|
this.connections = []
|
|
|
|
this.io.to(this.roomId).emit("room:destroyed", {
|
|
room: this.roomId,
|
|
})
|
|
|
|
console.log(`Room ${this.roomId} destroyed`)
|
|
}
|
|
|
|
makeOwner = (socket) => {
|
|
this.ownerUserId = socket.userData._id
|
|
}
|
|
}
|
|
|
|
class RoomsController {
|
|
constructor(io) {
|
|
if (!io) {
|
|
throw new Error("io is required")
|
|
}
|
|
|
|
this.io = io
|
|
}
|
|
|
|
rooms = []
|
|
|
|
checkRoomExists = (roomId) => {
|
|
return this.rooms.some((room) => room.roomId === roomId)
|
|
}
|
|
|
|
createRoom = async (roomId, roomOptions) => {
|
|
if (this.checkRoomExists(roomId)) {
|
|
throw new Error(`Room ${roomId} already exists`)
|
|
}
|
|
|
|
const room = new Room(this.io, roomId, roomOptions)
|
|
|
|
this.rooms.push(room)
|
|
|
|
return room
|
|
}
|
|
|
|
connectSocketToRoom = async (socket, roomId, roomOptions) => {
|
|
let room = null
|
|
|
|
if (!this.checkRoomExists(roomId)) {
|
|
room = await this.createRoom(roomId, roomOptions)
|
|
|
|
// make owner
|
|
room.makeOwner(socket)
|
|
}
|
|
|
|
// check if user is already connected to a room
|
|
if (socket.connectedRoomId) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] already connected to room ${socket.connectedRoomId}`)
|
|
|
|
this.disconnectSocketFromRoom(socket)
|
|
}
|
|
|
|
if (!room) {
|
|
room = this.rooms.find((room) => room.roomId === roomId)
|
|
}
|
|
|
|
return room.join(socket)
|
|
}
|
|
|
|
disconnectSocketFromRoom = async (socket, roomId) => {
|
|
if (!roomId) {
|
|
roomId = socket.connectedRoomId
|
|
}
|
|
|
|
if (!this.checkRoomExists(roomId)) {
|
|
console.warn(`Cannot disconnect socket [${socket.id}][@${socket.userData.username}] from room ${roomId}, room does not exists`)
|
|
return false
|
|
}
|
|
|
|
const room = this.rooms.find((room) => room.roomId === roomId)
|
|
|
|
// if owners leaves, rotate owner to the next user
|
|
if (socket.userData._id === room.ownerUserId) {
|
|
if (room.connections.length > 0 && room.connections[1]) {
|
|
room.transferOwner(room.connections[1])
|
|
}
|
|
}
|
|
|
|
// leave
|
|
room.leave(socket)
|
|
|
|
// if room is empty, destroy it
|
|
if (room.connections.length === 0) {
|
|
await this.destroyRoom(roomId)
|
|
|
|
return true
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
destroyRoom = async (roomId) => {
|
|
if (!this.checkRoomExists(roomId)) {
|
|
throw new Error(`Room ${roomId} does not exists`)
|
|
}
|
|
|
|
const room = this.rooms.find((room) => room.roomId === roomId)
|
|
|
|
room.destroy()
|
|
|
|
this.rooms.splice(this.rooms.indexOf(room), 1)
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
export default class RoomsServer {
|
|
constructor(server) {
|
|
this.io = socketio(server, {
|
|
cors: {
|
|
origin: "*",
|
|
methods: ["GET", "POST"],
|
|
credentials: true,
|
|
}
|
|
})
|
|
|
|
if (global.ioAdapter) {
|
|
this.io.adapter(global.ioAdapter)
|
|
}
|
|
|
|
this.RoomsController = new RoomsController(this.io)
|
|
}
|
|
|
|
connectionPool = []
|
|
|
|
events = {
|
|
|
|
}
|
|
|
|
inviteUserToRoom = async (socket, data) => {
|
|
try {
|
|
// find sockets with matching user_id
|
|
const invitedSockets = this.connectionPool.filter((client) => client.userData._id === data.user_id)
|
|
|
|
if (invitedSockets.length === 0) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] cannot invite user ${data.user_id}, user not found in connection pool`)
|
|
return socket.emit("error", {
|
|
message: `User ${data.user_id} not found`,
|
|
})
|
|
}
|
|
|
|
for (const invitedSocket of invitedSockets) {
|
|
// check if user is already connected to the room
|
|
if (invitedSocket.connectedRoomId === data.roomId) {
|
|
console.warn(`[${socket.id}][@${socket.userData.username}] cannot invite user ${data.user_id}, user already connected to room ${data.roomId}`)
|
|
return false
|
|
}
|
|
|
|
console.log(`[${socket.id}][@${socket.userData.username}] inviting user ${data.user_id} to room ${data.roomId}`)
|
|
|
|
invitedSocket.emit("invite:received", {
|
|
roomId: data.roomId,
|
|
invitedBy: {
|
|
_id: socket.userData._id,
|
|
username: socket.userData.username,
|
|
fullName: socket.userData.fullName,
|
|
avatar: socket.userData.avatar,
|
|
},
|
|
})
|
|
}
|
|
} catch (error) {
|
|
return socket.emit("error", {
|
|
message: error.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
initialize = async () => {
|
|
this.io.use(withWsAuth)
|
|
|
|
this.io.on("connection", (socket) => {
|
|
try {
|
|
console.log(`[${socket.id}][${socket.userData.username}] connected to hub.`)
|
|
|
|
this.connectionPool.push(socket)
|
|
|
|
socket.on("disconnect", () => this.events.disconnect)
|
|
|
|
// Rooms
|
|
socket.on("join:room", (data) => this.RoomsController.connectSocketToRoom(socket, data.room, data.options))
|
|
socket.on("leave:room", (data) => this.RoomsController.disconnectSocketFromRoom(socket, data?.room ?? socket.connectedRoomId, data?.options ?? {}))
|
|
socket.on("invite:user", generateFnHandler(this.inviteUserToRoom, socket))
|
|
|
|
socket.on("ping", (callback) => {
|
|
callback()
|
|
})
|
|
|
|
socket.on("disconnect", (_socket) => {
|
|
console.log(`[${socket.id}][@${socket.userData.username}] disconnected to hub.`)
|
|
|
|
if (socket.connectedRoomId) {
|
|
console.log(`[${socket.id}][@${socket.userData.username}] was connected to room [${socket.connectedRoomId}], leaving...`)
|
|
this.RoomsController.disconnectSocketFromRoom(socket)
|
|
}
|
|
|
|
// remove from connection pool
|
|
this.connectionPool = this.connectionPool.filter((client) => client.id !== socket.id)
|
|
})
|
|
|
|
Object.entries(this.events).forEach(([event, handler]) => {
|
|
socket.on(event, (data) => {
|
|
try {
|
|
handler(socket, data)
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
})
|
|
})
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
})
|
|
}
|
|
}
|