wsmini
Version:
Minimalist WebSocket client and server for real-time applications with RPC, PubSub, Rooms and Game state synchronization.
526 lines (438 loc) • 17.7 kB
JavaScript
import WSServerPubSub from "./WSServerPubSub.mjs";
import WSServerError from "./WSServerError.mjs";
import WSServerRoom from "./WSServerRoom.mjs";
import crypto from 'crypto';
import WSServerGameRoom from "./WSServerGameRoom.mjs";
export default class WSServerRoomManager extends WSServerPubSub {
rooms = new Map();
prefix = '__room-';
actionsRoom = ['pub-room', 'pub-room-cmd'];
syncModes = ['immediate', 'immediate-other', 'patch'];
constructor({
maxUsersByRoom = 10,
usersCanCreateRoom = true,
usersCanNameRoom = true,
usersCanListRooms = true,
usersCanGetRoomUsers = true,
roomClass = class extends WSServerRoom {},
autoJoinCreatedRoom = true,
autoDeleteEmptyRoom = true,
autoSendRoomListOnUsersChange = true,
syncMode = null,
port = 443,
maxNbOfClients = 1000,
maxInputSize = 100000, // 100kb
origins = '*',
pingTimeout = 30000,
authCallback = (token, request, wsServer) => ({}),
logLevel = 'info',
logger = null,
} = {}) {
if (!roomClass.prototype instanceof WSServerRoom) throw new Error('Invalid room class');
super({ port, maxNbOfClients, maxInputSize, origins, pingTimeout, authCallback, logLevel, logger });
this.maxUsersByRoom = maxUsersByRoom;
this.usersCanCreateRoom = usersCanCreateRoom;
this.usersCanNameRoom = usersCanNameRoom;
this.usersCanListRooms = usersCanListRooms;
this.usersCanGetRoomUsers = usersCanGetRoomUsers;
this.roomClass = roomClass;
this.autoJoinCreatedRoom = autoJoinCreatedRoom;
this.autoDeleteEmptyRoom = autoDeleteEmptyRoom;
this.autoSendRoomListOnUsersChange = autoSendRoomListOnUsersChange;
const isGameRoom = this.roomClass.prototype instanceof WSServerGameRoom;
if (syncMode === null) syncMode = isGameRoom ? 'patch' : 'immediate';
if (!this.syncModes.includes(syncMode)) throw new Error('Invalid sync mode');
if (isGameRoom && syncMode !== 'patch') throw new Error('Invalid sync mode for game room, must be "patch"');
this.syncMode = syncMode;
if (this.syncMode === 'patch' && !(this.roomClass.prototype instanceof WSServerGameRoom)) {
throw new Error("Room class for 'patch' mode must be an instance of WSServerGameRoom");
}
if (this.usersCanCreateRoom) {
this.clientCreateRoom = this.clientCreateRoom.bind(this);
this.addRpc(this.prefix + 'create', this.clientCreateRoom);
}
this.clientJoinRoom = this.clientJoinRoom.bind(this);
this.addRpc(this.prefix + 'join', this.clientJoinRoom);
this.clientCreateOrJoinRoom = this.clientCreateOrJoinRoom.bind(this);
this.addRpc(this.prefix + 'createOrJoin', this.clientCreateOrJoinRoom);
this.clientLeaveRoom = this.clientLeaveRoom.bind(this);
this.addRpc(this.prefix + 'leave', this.clientLeaveRoom);
this.addChannel(this.prefix + 'list', {
usersCanPub: false,
usersCanSub: this.usersCanListRooms,
});
if (this.usersCanListRooms) {
this.clientListRooms = this.clientListRooms.bind(this);
this.addRpc(this.prefix + 'list', this.clientListRooms);
}
}
isActionValid(action) {
return super.isActionValid(action) || this.actionsRoom.includes(action);
}
onMessage(client, message) {
const data = super.onMessage(client, message);
if (typeof data === 'boolean') return data;
if (this.actionsRoom.includes(data.action)) {
return this.manageRoomActions(client, data);
}
return data;
}
manageRoomActions(client, data) {
if (data.action === 'pub-room' || data.action === 'pub-room-cmd') {
if (typeof data?.msg === 'undefined') return this.sendError(client, 'Invalid message');
if (typeof data?.room !== 'string') return this.sendError(client, 'Invalid room');
if (!this.rooms.has(data.room)) return this.sendError(client, 'Unknown room');
const room = this.rooms.get(data.room);
if (!room.chan.clients.has(client)) return this.sendError(client, 'Client not in room');
const clientMeta = this.clients.get(client);
let toCall = 'onMsg';
if (data.action === 'pub-room-cmd') {
if (typeof data?.cmd !== 'string') return this.sendError(client, 'Invalid command name');
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(data.cmd)) return this.sendError(client, 'Invalid command name');
data.cmd = data.cmd.charAt(0).toUpperCase() + data.cmd.slice(1);
toCall = `onCmd${data.cmd}`;
if (typeof room.manager[toCall] != 'function') return this.sendError(client, 'Unknown command');
}
try {
var msg = room.manager[toCall](data.msg, clientMeta, client);
} catch (e) {
if (!(e instanceof WSServerError)) this.log(e.name + ': ' + e.message, 'error');
const response = e instanceof WSServerError ? e.message : 'Server error';
return this.sendError(client, response);
}
if (this.syncMode === 'immediate') {
return this.broadcastRoom(room, msg);
}
if (this.syncMode === 'immediate-other') {
return this.broadcastOtherRoom(room, msg, client);
}
}
return false;
}
clientListRooms(data, clientMeta, client) {
return this.prepareRoomList();
}
clientLeaveRoom(data, clientMeta, client) {
if (!data.name || typeof data.name !== 'string') throw new WSServerError('Invalid room name');
data.name = data.name.trim();
if (!this.rooms.has(data.name)) throw new WSServerError('Room not found');
const room = this.rooms.get(data.name);
if (!room.chan.clients.has(client)) throw new WSServerError('Client not in room');
return this.removeClientFromRoom(data.name, client);
}
clientCreateOrJoinRoom(data, clientMeta, client) {
if (this.usersCanCreateRoom && typeof data?.name === 'string' && this.rooms.has(data.name)) {
return this.clientJoinRoom(data, clientMeta, client);
}
return this.clientCreateRoom(data, clientMeta, client);
}
clientJoinRoom(data, clientMeta, client) {
if (!data.name || typeof data.name !== 'string') throw new WSServerError('Invalid room name');
data.name = data.name.trim();
if (!this.rooms.has(data.name)) throw new WSServerError('Room not found');
const room = this.rooms.get(data.name);
if (room.chan.clients.size >= room.maxUsers) throw new WSServerError('Room is full');
if (room.chan.clients.has(client)) throw new WSServerError('Client already in room');
try {
var meta = room.manager.onJoin(data.msg, clientMeta, client);
} catch (e) {
if (!(e instanceof WSServerError)) this.log(e.name + ': ' + e.message, 'error');
const response = e instanceof WSServerError ? e.message : 'Server error';
throw new WSServerError(response);
}
if (meta === false) throw new WSServerError('Room join aborted');
if (typeof meta !== 'object') meta = {};
Object.assign(clientMeta, meta);
this.addClientToRoom(data.name, clientMeta, client);
let roomMeta = {};
try {
roomMeta = room.manager.onSendRoom();
if (typeof roomMeta !== 'object') roomMeta = {};
} catch (e) {
this.log(e.name + ': ' + e.message, 'error');
}
const response = { name: room.name, meta: roomMeta };
if (this.usersCanGetRoomUsers) response.clients = this.prepareRoomClients(room);
return response;
}
clientCreateRoom(data, clientMeta, client) {
if (this.usersCanNameRoom && data?.name) {
if (typeof data.name !== 'string') throw new WSServerError('Invalid room name');
data.name = data.name.trim();
if (this.rooms.has(data.name)) throw new WSServerError('Room already exists');
} else {
data.name = null;
}
const roomInstance = new this.roomClass(data.name, this);
try {
var meta = roomInstance.onCreate(data.name, data.msg, clientMeta, client);
} catch (e) {
if (!(e instanceof WSServerError)) this.log(e.name + ': ' + e.message, 'error');
const response = e instanceof WSServerError ? e.message : 'Server error';
throw new WSServerError(response);
}
if (meta === false) throw new WSServerError('Room creation aborted');
if (typeof meta !== 'object') meta = {};
if (meta.name && typeof meta.name === 'string') data.name = meta.name;
const roomName = this.createRoom(data.name ?? null);
const room = this.rooms.get(roomName);
room.manager = roomInstance;
room.manager.name = roomName;
Object.assign(room.meta, meta);
if (this.autoJoinCreatedRoom) {
try {
var metaUser = room.manager.onJoin(data.msg, clientMeta, client);
} catch (e) {
if (!(e instanceof WSServerError)) this.log(e.name + ': ' + e.message, 'error');
const response = e instanceof WSServerError ? e.message : 'Server error';
this.deleteRoom(roomName);
throw new WSServerError(response);
}
if (metaUser === false) {
this.deleteRoom(roomName);
throw new WSServerError('Room join aborted');
}
if (typeof metaUser !== 'object') metaUser = {};
Object.assign(clientMeta, metaUser);
this.addClientToRoom(roomName, clientMeta, client);
}
let roomMeta = {};
try {
roomMeta = room.manager.onSendRoom(room.meta);
if (typeof roomMeta !== 'object') roomMeta = {};
} catch (e) {
this.log(e.name + ': ' + e.message, 'error');
}
this.pubRoomList();
const response = { name: roomName, meta: roomMeta };
if (this.usersCanGetRoomUsers) response.clients = this.prepareRoomClients(room);
return response;
}
getClientsOfRoom(roomName) {
const clients = [];
if (!this.rooms.has(roomName)) return clients;
const room = this.rooms.get(roomName);
for (const client of room.chan.clients) {
clients.push(this.clients.get(client));
}
return clients;
}
isRoomFull(roomName) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
return room.chan.clients.size >= room.maxUsers;
}
getRoomMeta(roomName) {
if (!this.rooms.has(roomName)) return false;
return this.rooms.get(roomName).meta;
}
addClientToRoom(roomName, clientMeta, client) {
const room = this.rooms.get(roomName);
const chan = room.chan;
const chanClients = room.chanClients;
if (chan.clients.has(client)) return false;
this.log('Client ' + clientMeta.id + ' joined room ' + roomName);
chan.clients.add(client);
if (this.usersCanGetRoomUsers) chanClients.clients.add(client);
this.pubRoomClients(room);
if (this.autoSendRoomListOnUsersChange) this.pubRoomList();
return true;
}
removeClientFromRoom(roomName, client) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
const chan = room.chan;
const chanClients = room.chanClients;
if (!chan.clients.has(client)) return false;
const clientMeta = this.clients.get(client);
try {
room.manager.onLeave(clientMeta, client);
} catch (e) {
this.log(e.name + ': ' + e.message, 'error');
}
if (clientMeta) this.log('Client ' + clientMeta.id + ' left room ' + roomName);
chan.clients.delete(client);
chanClients.clients.delete(client);
this.pubRoomClients(room);
if (!this.autoDeleteEmptyRoom || chan.clients.size > 0) {
if (this.autoSendRoomListOnUsersChange) this.pubRoomList();
return true;
}
return this.deleteRoom(roomName);
}
deleteRoom(roomName) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
try {
room.manager.onDispose();
} catch (e) { this.log(e.name + ': ' + e.message); }
this.log('Room deleted: ' + roomName);
room.manager.dispose();
this.rooms.delete(roomName);
this.pubRoomList();
this.channels.delete(this.prefix + roomName + '-clients');
return this.channels.delete(this.prefix + roomName);
}
createRoom(roomName = null, withHook = false) {
roomName = roomName ?? crypto.randomUUID();
if (this.rooms.has(roomName)) return false;
let meta = {};
let managerInstance = new this.roomClass(roomName, this);
if (withHook) {
try {
meta = managerInstance.onCreate(roomName, null, null, null);
} catch (e) {
if (!(e instanceof WSServerError)) this.log(e.name + ': ' + e.message, 'error');
meta = false;
}
if (meta === false) {
this.log('Room creation aborted');
return false;
}
if (typeof meta !== 'object') meta = {};
if (meta.name && typeof meta.name === 'string') roomName = meta.name;
}
const chanName = this.prefix + roomName;
const chanNameClients = this.prefix + roomName + '-clients';
this.addChannel(chanName, { usersCanPub: false, usersCanSub: false });
this.addChannel(chanNameClients, { usersCanPub: false, usersCanSub: false });
this.rooms.set(roomName, {
name: roomName,
chan: this.channels.get(chanName),
chanClients: this.channels.get(chanNameClients),
maxUsers: this.maxUsersByRoom,
meta: { name: roomName, ...meta },
manager: managerInstance,
});
this.log('Room created: ' + roomName);
if (withHook) this.pubRoomList();
return roomName;
}
onClose(client) {
for (const room of this.rooms.values()) {
this.removeClientFromRoom(room.name, client);
}
super.onClose(client);
}
close() {
this.rooms.clear();
super.close();
}
preparePubMessage(room, msg) {
return JSON.stringify({
action: 'pub',
chan: this.prefix + room.name,
msg
});
}
preparePubCmd(room, cmd, data = {}) {
return JSON.stringify({
action: 'pub-cmd',
chan: this.prefix + room.name,
msg : { cmd, data }
});
}
prepareRoomList() {
let rooms = [];
for (const room of this.rooms.values()) {
let meta = {};
try {
meta = room.manager.onSendRoom(room.meta);
if (typeof meta !== 'object') meta = {};
} catch (e) { this.log(e.name + ': ' + e.message, 'error'); }
rooms.push({
name: room.name,
meta,
nbUsers: room.chan.clients.size,
maxUsers: room.maxUsers
});
}
try {
const roomsListHook = this.roomClass.onSendRoomsList(rooms);
if (Array.isArray(roomsListHook)) rooms = roomsListHook;
} catch (e) { this.log(e.name + ': ' + e.message, 'error'); }
return rooms;
}
pubRoomList() {
const roomsList = this.prepareRoomList();
this.pub(this.prefix + 'list', roomsList);
}
prepareRoomClients(room) {
const clients = [];
for (const client of room.chan.clients) {
let clientMeta = {};
try {
clientMeta = room.manager.onSendClient(this.clients.get(client));
if (typeof clientMeta !== 'object') clientMeta = {};
} catch (e) { this.log(e.name + ': ' + e.message, 'error'); }
clients.push(clientMeta);
}
return clients;
}
pubRoomClients(room) {
const clients = this.prepareRoomClients(room);
return this.pub(this.prefix + room.name + '-clients', clients);
}
sendRoomName(roomName, client, msg) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
return this.sendRoom(room, client, msg);
}
sendRoom(room, client, msg) {
if (!room.chan.clients.has(client)) return false;
const message = this.preparePubMessage(room, msg);
this.send(client, message);
return true;
}
broadcastRoomName(roomName, msg) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
return this.broadcastRoom(room, msg);
}
broadcastRoom(room, msg) {
const message = this.preparePubMessage(room, msg);
for (const client of room.chan.clients) {
this.send(client, message);
}
return true;
}
broadcastOtherRoom(room, msg, client) {
const message = this.preparePubMessage(room, msg);
for (const other of room.chan.clients) {
if (other === client) continue;
this.send(other, message);
}
return true;
}
sendRoomNameCmd(roomName, client, cmd, data = {}) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
return this.sendRoomCmd(room, client, cmd, data);
}
sendRoomCmd(room, client, cmd, data = {}) {
if (!room.chan.clients.has(client)) return false;
const message = this.preparePubCmd(room, cmd, data);
this.send(client, message);
return true;
}
broadcastRoomNameCmd(roomName, cmd, data = {}) {
if (!this.rooms.has(roomName)) return false;
const room = this.rooms.get(roomName);
return this.broadcastRoomCmd(room, cmd, data);
}
broadcastRoomCmd(room, cmd, data = {}) {
const message = this.preparePubCmd(room, cmd, data);
for (const client of room.chan.clients) {
this.send(client, message);
}
return true;
}
broadcastRoomCmdOther(room, cmd, data = {}, client) {
const message = this.preparePubCmd(room, cmd, data);
for (const other of room.chan.clients) {
if (other === client) continue;
this.send(other, message);
}
return true;
}
}