Merge branch 'develop' of github.com:thecodingmachine/workadventure into feature/global-message

+ migrating to protobuf messages

# Conflicts:
#	back/src/App.ts
#	back/src/Controller/IoSocketController.ts
#	back/yarn.lock
#	front/src/Connection.ts
#	front/src/Phaser/Game/GameScene.ts
#	front/src/Phaser/Login/EnableCameraScene.ts
#	front/src/WebRtc/SimplePeer.ts
This commit is contained in:
David Négrier 2020-10-01 14:11:34 +02:00
commit d3fa901691
87 changed files with 7429 additions and 1891 deletions

View file

@ -1,61 +1,32 @@
// lib/app.ts
import {IoSocketController} from "./Controller/IoSocketController"; //TODO fix import by "_Controller/..."
import {AuthenticateController} from "./Controller/AuthenticateController"; //TODO fix import by "_Controller/..."
import express from "express";
import {Application, Request, Response} from 'express';
import bodyParser = require('body-parser');
import * as http from "http";
import {MapController} from "./Controller/MapController";
import {PrometheusController} from "./Controller/PrometheusController";
import {FileController} from "./Controller/FileController";
import {AdminController} from "./Controller/AdminController";
import {DebugController} from "./Controller/DebugController";
import {App as uwsApp} from "./Server/sifrr.server";
class App {
public app: Application;
public server: http.Server;
public app: uwsApp;
public ioSocketController: IoSocketController;
public authenticateController: AuthenticateController;
public fileController: FileController;
public mapController: MapController;
public prometheusController: PrometheusController;
private adminController: AdminController;
private debugController: DebugController;
constructor() {
this.app = express();
//config server http
this.server = http.createServer(this.app);
this.config();
this.crossOrigin();
//TODO add middleware with access token to secure api
this.app = new uwsApp();
//create socket controllers
this.ioSocketController = new IoSocketController(this.server);
this.ioSocketController = new IoSocketController(this.app);
this.authenticateController = new AuthenticateController(this.app);
this.fileController = new FileController(this.app);
this.mapController = new MapController(this.app);
this.prometheusController = new PrometheusController(this.app, this.ioSocketController);
this.adminController = new AdminController(this.app);
}
// TODO add session user
private config(): void {
this.app.use(bodyParser.json());
this.app.use(bodyParser.urlencoded({extended: false}));
}
private crossOrigin(){
this.app.use((req: Request, res: Response, next) => {
res.setHeader("Access-Control-Allow-Origin", "*"); // update to match the domain you will make the request from
// Request methods you wish to allow
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
// Request headers you wish to allow
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
this.debugController = new DebugController(this.app, this.ioSocketController);
}
}
export default new App().server;
export default new App().app;

View file

@ -1,36 +0,0 @@
import {Application, Request, Response} from "express";
import {OK} from "http-status-codes";
import {ADMIN_API_TOKEN, ADMIN_API_URL} from "../Enum/EnvironmentVariable";
import Axios from "axios";
export class AdminController {
App : Application;
constructor(App : Application) {
this.App = App;
this.getLoginUrlByToken();
}
getLoginUrlByToken(){
this.App.get("/register/:token", async (req: Request, res: Response) => {
if (!ADMIN_API_URL) {
return res.status(500).send('No admin backoffice set!');
}
const token:string = req.params.token;
let response = null
try {
response = await Axios.get(ADMIN_API_URL+'/api/login-url/'+token, { headers: {"Authorization" : `${ADMIN_API_TOKEN}`} })
} catch (e) {
console.log(e.message)
return res.status(e.status || 500).send('An error happened');
}
const organizationSlug = response.data.organizationSlug;
const worldSlug = response.data.worldSlug;
const roomSlug = response.data.roomSlug;
return res.status(OK).send({organizationSlug, worldSlug, roomSlug});
});
}
}

View file

@ -1,40 +1,95 @@
import {Application, Request, Response} from "express";
import Jwt from "jsonwebtoken";
import {BAD_REQUEST, OK} from "http-status-codes";
import {SECRET_KEY, URL_ROOM_STARTED} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..."
import {ADMIN_API_TOKEN, ADMIN_API_URL, SECRET_KEY, URL_ROOM_STARTED} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..."
import { uuid } from 'uuidv4';
import {HttpRequest, HttpResponse, TemplatedApp} from "uWebSockets.js";
import {BaseController} from "./BaseController";
import Axios from "axios";
export interface TokenInterface {
name: string,
userId: string
userUuid: string
}
export class AuthenticateController {
App : Application;
interface AdminApiData {
organizationSlug: string
worldSlug: string
roomSlug: string
mapUrlStart: string
userUuid: string
}
constructor(App : Application) {
this.App = App;
export class AuthenticateController extends BaseController {
constructor(private App : TemplatedApp) {
super();
this.login();
}
//permit to login on application. Return token to connect on Websocket IO.
login(){
// For now, let's completely forget the /login route.
this.App.post("/login", (req: Request, res: Response) => {
const param = req.body;
/*if(!param.name){
return res.status(BAD_REQUEST).send({
message: "email parameter is empty"
});
}*/
//TODO check user email for The Coding Machine game
const userId = uuid();
const token = Jwt.sign({name: param.name, userId: userId} as TokenInterface, SECRET_KEY, {expiresIn: '24h'});
return res.status(OK).send({
token: token,
mapUrlStart: URL_ROOM_STARTED,
userId: userId,
});
this.App.options("/login", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.post("/login", (res: HttpResponse, req: HttpRequest) => {
(async () => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('Login request was aborted');
})
const host = req.getHeader('host');
const param = await res.json();
//todo: what to do if the organizationMemberToken is already used?
const organizationMemberToken:string|null = param.organizationMemberToken;
try {
let userUuid;
let mapUrlStart;
let newUrl: string|null = null;
if (organizationMemberToken) {
if (!ADMIN_API_URL) {
return res.status(401).send('No admin backoffice set!');
}
//todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case.
const data = await Axios.get(ADMIN_API_URL+'/api/login-url/'+organizationMemberToken,
{ headers: {"Authorization" : `${ADMIN_API_TOKEN}`} }
).then((res): AdminApiData => res.data);
userUuid = data.userUuid;
mapUrlStart = data.mapUrlStart;
newUrl = this.getNewUrlOnAdminAuth(data)
} else {
userUuid = uuid();
mapUrlStart = host.replace('api.', 'maps.') + URL_ROOM_STARTED;
newUrl = null;
}
const authToken = Jwt.sign({userUuid: userUuid}, SECRET_KEY, {expiresIn: '24h'});
res.writeStatus("200 OK").end(JSON.stringify({
authToken,
userUuid,
mapUrlStart,
newUrl,
}));
} catch (e) {
console.log("An error happened", e)
res.writeStatus(e.status || "500 Internal Server Error").end('An error happened');
}
})();
});
}
private getNewUrlOnAdminAuth(data:AdminApiData): string {
const organizationSlug = data.organizationSlug;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
return '/@/'+organizationSlug+'/'+worldSlug+'/'+roomSlug;
}
}

View file

@ -0,0 +1,10 @@
import {HttpResponse} from "uWebSockets.js";
export class BaseController {
protected addCorsHeaders(res: HttpResponse): void {
res.writeHeader('access-control-allow-headers', 'Origin, X-Requested-With, Content-Type, Accept');
res.writeHeader('access-control-allow-methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
res.writeHeader('access-control-allow-origin', '*');
}
}

View file

@ -0,0 +1,44 @@
import {ADMIN_API_TOKEN} from "../Enum/EnvironmentVariable";
import {IoSocketController} from "_Controller/IoSocketController";
import {stringify} from "circular-json";
import {HttpRequest, HttpResponse} from "uWebSockets.js";
import { parse } from 'query-string';
import {App} from "../Server/sifrr.server";
export class DebugController {
constructor(private App : App, private ioSocketController: IoSocketController) {
this.getDump();
}
getDump(){
this.App.get("/dump", (res: HttpResponse, req: HttpRequest) => {
const query = parse(req.getQuery());
if (query.token !== ADMIN_API_TOKEN) {
return res.status(401).send('Invalid token sent!');
}
return res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(stringify(
this.ioSocketController.getWorlds(),
(key: unknown, value: unknown) => {
if(value instanceof Map) {
const obj: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
for (const [mapKey, mapValue] of value.entries()) {
obj[mapKey] = mapValue;
}
return obj;
} else if(value instanceof Set) {
const obj: Array<unknown> = [];
for (const [setKey, setValue] of value.entries()) {
obj.push(setValue);
}
return obj;
} else {
return value;
}
}
));
});
}
}

View file

@ -1,59 +1,161 @@
const multer = require('multer');
import {Application, Request, RequestHandler, Response} from "express";
import {OK} from "http-status-codes";
import {URL_ROOM_STARTED} from "_Enum/EnvironmentVariable";
import {App} from "../Server/sifrr.server";
import {uuid} from "uuidv4";
import fs from "fs";
import {HttpRequest, HttpResponse} from "uWebSockets.js";
import {BaseController} from "./BaseController";
import { Readable } from 'stream'
const upload = multer({ dest: 'dist/files/' });
class FileUpload{
path: string
constructor(path : string) {
this.path = path;
}
interface UploadedFileBuffer {
buffer: Buffer,
expireDate: Date
}
interface RequestFileHandlerInterface extends Request{
file: FileUpload
}
export class FileController extends BaseController {
private uploadedFileBuffers: Map<string, UploadedFileBuffer> = new Map<string, UploadedFileBuffer>();
export class FileController {
App : Application;
constructor(App : Application) {
constructor(private App : App) {
super();
this.App = App;
this.uploadAudioMessage();
this.downloadAudioMessage();
// Cleanup every 1 minute
setInterval(this.cleanup.bind(this), 60000);
}
/**
* Clean memory from old files
*/
cleanup(): void {
const now = new Date();
for (const [id, file] of this.uploadedFileBuffers) {
if (file.expireDate < now) {
this.uploadedFileBuffers.delete(id);
}
}
}
uploadAudioMessage(){
this.App.post("/upload-audio-message", (upload.single('file') as RequestHandler), (req: Request, res: Response) => {
//TODO check user connected and admin role
//TODO upload audio message
const audioMessageId = uuid();
this.App.options("/upload-audio-message", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
fs.copyFileSync((req as RequestFileHandlerInterface).file.path, `dist/files/${audioMessageId}`);
fs.unlinkSync((req as RequestFileHandlerInterface).file.path);
res.end();
});
return res.status(OK).send({
id: audioMessageId,
path: `/download-audio-message/${audioMessageId}`
});
this.App.post("/upload-audio-message", (res: HttpResponse, req: HttpRequest) => {
(async () => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('upload-audio-message request was aborted');
})
try {
const audioMessageId = uuid();
const params = await res.formData({
onFile: (fieldname: string,
file: NodeJS.ReadableStream,
filename: string,
encoding: string,
mimetype: string) => {
(async () => {
console.log('READING FILE', fieldname)
const chunks: Buffer[] = []
for await (let chunk of file) {
if (!(chunk instanceof Buffer)) {
throw new Error('Unexpected chunk');
}
chunks.push(chunk)
}
// Let's expire in 1 minute.
const expireDate = new Date();
expireDate.setMinutes(expireDate.getMinutes() + 1);
this.uploadedFileBuffers.set(audioMessageId, {
buffer: Buffer.concat(chunks),
expireDate
});
})();
}
});
res.writeStatus("200 OK").end(JSON.stringify({
id: audioMessageId,
path: `/download-audio-message/${audioMessageId}`
}));
} catch (e) {
console.log("An error happened", e)
res.writeStatus(e.status || "500 Internal Server Error").end('An error happened');
}
})();
});
}
downloadAudioMessage(){
this.App.get("/download-audio-message/:id", (req: Request, res: Response) => {
//TODO check user connected and admin role
//TODO upload audio message
const audiMessageId = req.params.id;
this.App.options("/download-audio-message/*", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
const fs = require('fs');
const path = `dist/files/${audiMessageId}`;
const file = fs.createReadStream(path);
res.writeHead(200);
file.pipe(res);
res.end();
});
this.App.get("/download-audio-message/:id", (res: HttpResponse, req: HttpRequest) => {
(async () => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('upload-audio-message request was aborted');
})
const id = req.getParameter(0);
const file = this.uploadedFileBuffers.get(id);
if (file === undefined) {
res.writeStatus("404 Not found").end("Cannot find file");
return;
}
const readable = new Readable()
readable._read = () => {} // _read is required but you can noop it
readable.push(file.buffer);
readable.push(null);
const size = file.buffer.byteLength;
res.writeStatus("200 OK");
readable.on('data', buffer => {
const chunk = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
lastOffset = res.getWriteOffset();
// First try
const [ok, done] = res.tryEnd(chunk, size);
if (done) {
readable.destroy();
} else if (!ok) {
// pause because backpressure
readable.pause();
// Save unsent chunk for later
res.ab = chunk;
res.abOffset = lastOffset;
// Register async handlers for drainage
res.onWritable(offset => {
const [ok, done] = res.tryEnd(res.ab.slice(offset - res.abOffset), size);
if (done) {
readable.destroy();
} else if (ok) {
readable.resume();
}
return ok;
});
}
});
})();
});
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,34 @@
import express from "express";
import {Application, Request, Response} from "express";
import {OK} from "http-status-codes";
import {URL_ROOM_STARTED} from "../Enum/EnvironmentVariable";
import {HttpRequest, HttpResponse, TemplatedApp} from "uWebSockets.js";
import {BaseController} from "./BaseController";
export class MapController {
App: Application;
//todo: delete this
export class MapController extends BaseController{
constructor(App: Application) {
constructor(private App : TemplatedApp) {
super();
this.App = App;
this.getStartMap();
this.assetMaps();
}
assetMaps() {
this.App.use('/map/files', express.static('src/Assets/Maps'));
}
// Returns a map mapping map name to file name of the map
getStartMap() {
this.App.get("/start-map", (req: Request, res: Response) => {
const url = req.headers.host?.replace('api.', 'maps.') + URL_ROOM_STARTED;
res.status(OK).send({
this.App.options("/start-map", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.get("/start-map", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
const url = req.getHeader('host').replace('api.', 'maps.') + URL_ROOM_STARTED;
res.writeStatus("200 OK").end(JSON.stringify({
mapUrlStart: url,
startInstance: "global"
});
}));
});
}
}

View file

@ -1,10 +1,11 @@
import {Application, Request, Response} from "express";
import {App} from "../Server/sifrr.server";
import {IoSocketController} from "_Controller/IoSocketController";
import {HttpRequest, HttpResponse} from "uWebSockets.js";
const register = require('prom-client').register;
const collectDefaultMetrics = require('prom-client').collectDefaultMetrics;
export class PrometheusController {
constructor(private App: Application, private ioSocketController: IoSocketController) {
constructor(private App: App, private ioSocketController: IoSocketController) {
collectDefaultMetrics({
timeout: 10000,
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // These are the default buckets.
@ -13,8 +14,8 @@ export class PrometheusController {
this.App.get("/metrics", this.metrics.bind(this));
}
private metrics(req: Request, res: Response): void {
res.set('Content-Type', register.contentType);
private metrics(res: HttpResponse, req: HttpRequest): void {
res.writeHeader('Content-Type', register.contentType);
res.end(register.metrics());
}
}

View file

@ -5,6 +5,7 @@ const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS)
const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false;
const ADMIN_API_URL = process.env.ADMIN_API_URL || null;
const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || null;
const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80;
export {
SECRET_KEY,
@ -14,4 +15,5 @@ export {
ADMIN_API_TOKEN,
GROUP_RADIUS,
ALLOW_ARTILLERY,
}
CPU_OVERHEAT_THRESHOLD,
}

1
back/src/Messages/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/generated/

View file

@ -3,32 +3,36 @@ import { User } from "./User";
import {PositionInterface} from "_Model/PositionInterface";
import {uuid} from "uuidv4";
import {Movable} from "_Model/Movable";
import {PositionNotifier} from "_Model/PositionNotifier";
export class Group implements Movable {
static readonly MAX_PER_GROUP = 4;
private id: string;
private static nextId: number = 1;
private id: number;
private users: Set<User>;
private connectCallback: ConnectCallback;
private disconnectCallback: DisconnectCallback;
private x!: number;
private y!: number;
constructor(users: User[], connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback) {
constructor(users: User[], private connectCallback: ConnectCallback, private disconnectCallback: DisconnectCallback, private positionNotifier: PositionNotifier) {
this.users = new Set<User>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.id = uuid();
this.id = Group.nextId;
Group.nextId++;
users.forEach((user: User) => {
this.join(user);
});
this.updatePosition();
}
getUsers(): User[] {
return Array.from(this.users.values());
}
getId() : string{
getId() : number {
return this.id;
}
@ -36,19 +40,40 @@ export class Group implements Movable {
* Returns the barycenter of all users (i.e. the center of the group)
*/
getPosition(): PositionInterface {
return {
x: this.x,
y: this.y
};
}
/**
* Computes the barycenter of all users (i.e. the center of the group)
*/
updatePosition(): void {
const oldX = this.x;
const oldY = this.y;
let x = 0;
let y = 0;
// Let's compute the barycenter of all users.
this.users.forEach((user: User) => {
x += user.position.x;
y += user.position.y;
const position = user.getPosition();
x += position.x;
y += position.y;
});
x /= this.users.size;
y /= this.users.size;
return {
x,
y
};
if (this.users.size === 0) {
throw new Error("EMPTY GROUP FOUND!!!");
}
this.x = x;
this.y = y;
if (oldX === undefined) {
this.positionNotifier.enter(this);
} else {
this.positionNotifier.updatePosition(this, {x, y}, {x: oldX, y: oldY});
}
}
isFull(): boolean {
@ -62,7 +87,7 @@ export class Group implements Movable {
join(user: User): void
{
// Broadcast on the right event
this.connectCallback(user.id, this);
this.connectCallback(user, this);
this.users.add(user);
user.group = this;
}
@ -75,8 +100,12 @@ export class Group implements Movable {
}
user.group = undefined;
if (this.users.size !== 0) {
this.updatePosition();
}
// Broadcast on the right event
this.disconnectCallback(user.id, this);
this.disconnectCallback(user, this);
}
/**

View file

@ -74,6 +74,13 @@ export class PositionNotifier {
return things;
}
public enter(thing: Movable): void {
const position = thing.getPosition();
const zoneDesc = this.getZoneDescriptorFromCoordinates(position.x, position.y);
const zone = this.getZone(zoneDesc.i, zoneDesc.j);
zone.enter(thing, null, position);
}
public updatePosition(thing: Movable, newPosition: PositionInterface, oldPosition: PositionInterface): void {
// Did we change zone?
const oldZoneDesc = this.getZoneDescriptorFromCoordinates(oldPosition.x, oldPosition.y);
@ -117,7 +124,7 @@ export class PositionNotifier {
let zone = this.zones[j][i];
if (zone === undefined) {
zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves);
zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves, i, j);
this.zones[j][i] = zone;
}
return zone;

View file

@ -3,21 +3,32 @@ import { PointInterface } from "./Websocket/PointInterface";
import {Zone} from "_Model/Zone";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
import {PositionNotifier} from "_Model/PositionNotifier";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
export class User implements Movable {
public listenedZones: Set<Zone>;
public group?: Group;
public constructor(
public id: string,
public position: PointInterface,
public id: number,
private position: PointInterface,
public silent: boolean,
private positionNotifier: PositionNotifier,
public readonly socket: ExSocketInterface
) {
this.listenedZones = new Set<Zone>();
this.positionNotifier.enter(this);
}
public getPosition(): PositionInterface {
public getPosition(): PointInterface {
return this.position;
}
public setPosition(position: PointInterface): void {
const oldPosition = this.position;
this.position = position;
this.positionNotifier.updatePosition(this, position, oldPosition);
}
}

View file

@ -1,23 +1,23 @@
import {Socket} from "socket.io";
import {PointInterface} from "./PointInterface";
import {Identificable} from "./Identificable";
import {TokenInterface} from "../../Controller/AuthenticateController";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {BatchMessage, SubMessage} from "../../Messages/generated/messages_pb";
import {WebSocket} from "uWebSockets.js"
export interface ExSocketInterface extends Socket, Identificable {
export interface ExSocketInterface extends WebSocket, Identificable {
token: string;
roomId: string;
webRtcRoomId: string;
userId: string;
userId: number; // A temporary (autoincremented) identifier for this user
userUuid: string; // A unique identifier for this user
name: string;
characterLayers: string[];
position: PointInterface;
viewport: ViewportInterface;
isArtillery: boolean; // Whether this socket is opened by Artillery for load testing (hack)
/**
* Pushes an event that will be sent in the next batch of events
*/
emitInBatch: (event: string | symbol, payload: unknown) => void;
batchedMessages: Array<{ event: string | symbol, payload: unknown }>;
emitInBatch: (payload: SubMessage) => void;
batchedMessages: BatchMessage;
batchTimeout: NodeJS.Timeout|null;
disconnecting: boolean
}

View file

@ -2,5 +2,5 @@ import {PositionInterface} from "_Model/PositionInterface";
export interface GroupUpdateInterface {
position: PositionInterface,
groupId: string,
groupId: number,
}

View file

@ -1,3 +1,3 @@
export interface Identificable {
userId: string;
userId: number;
}

View file

@ -1,6 +1,6 @@
import {PointInterface} from "_Model/Websocket/PointInterface";
export class MessageUserJoined {
constructor(public userId: string, public name: string, public characterLayers: string[], public position: PointInterface) {
constructor(public userId: number, public name: string, public characterLayers: string[], public position: PointInterface) {
}
}

View file

@ -1,6 +0,0 @@
import {PointInterface} from "./PointInterface";
export class MessageUserMoved {
constructor(public userId: string, public position: PointInterface) {
}
}

View file

@ -6,6 +6,6 @@ export class Point implements PointInterface{
}
export class MessageUserPosition {
constructor(public userId: string, public name: string, public characterLayers: string[], public position: PointInterface) {
constructor(public userId: number, public name: string, public characterLayers: string[], public position: PointInterface) {
}
}

View file

@ -0,0 +1,92 @@
import {PointInterface} from "./PointInterface";
import {ItemEventMessage, PointMessage, PositionMessage} from "../../Messages/generated/messages_pb";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import Direction = PositionMessage.Direction;
import {ItemEventMessageInterface} from "_Model/Websocket/ItemEventMessage";
import {PositionInterface} from "_Model/PositionInterface";
export class ProtobufUtils {
public static toPositionMessage(point: PointInterface): PositionMessage {
let direction: PositionMessage.DirectionMap[keyof PositionMessage.DirectionMap];
switch (point.direction) {
case 'up':
direction = Direction.UP;
break;
case 'down':
direction = Direction.DOWN;
break;
case 'left':
direction = Direction.LEFT;
break;
case 'right':
direction = Direction.RIGHT;
break;
default:
throw new Error('unexpected direction');
}
const position = new PositionMessage();
position.setX(point.x);
position.setY(point.y);
position.setMoving(point.moving);
position.setDirection(direction);
return position;
}
public static toPointInterface(position: PositionMessage): PointInterface {
let direction: string;
switch (position.getDirection()) {
case Direction.UP:
direction = 'up';
break;
case Direction.DOWN:
direction = 'down';
break;
case Direction.LEFT:
direction = 'left';
break;
case Direction.RIGHT:
direction = 'right';
break;
default:
throw new Error("Unexpected direction");
}
// sending to all clients in room except sender
return {
x: position.getX(),
y: position.getY(),
direction,
moving: position.getMoving(),
};
}
public static toPointMessage(point: PositionInterface): PointMessage {
const position = new PointMessage();
position.setX(Math.floor(point.x));
position.setY(Math.floor(point.y));
return position;
}
public static toItemEvent(itemEventMessage: ItemEventMessage): ItemEventMessageInterface {
return {
itemId: itemEventMessage.getItemid(),
event: itemEventMessage.getEvent(),
parameters: JSON.parse(itemEventMessage.getParametersjson()),
state: JSON.parse(itemEventMessage.getStatejson()),
}
}
public static toItemEventProtobuf(itemEvent: ItemEventMessageInterface): ItemEventMessage {
const itemEventMessage = new ItemEventMessage();
itemEventMessage.setItemid(itemEvent.itemId);
itemEventMessage.setEvent(itemEvent.event);
itemEventMessage.setParametersjson(JSON.stringify(itemEvent.parameters));
itemEventMessage.setStatejson(JSON.stringify(itemEvent.state));
return itemEventMessage;
}
}

View file

@ -1,5 +1,5 @@
export interface UserInGroupInterface {
userId: string,
userId: number,
name: string,
initiator: boolean
}

View file

@ -1,11 +0,0 @@
import * as tg from "generic-type-guard";
import {isPointInterface} from "./PointInterface";
import {isViewport} from "./ViewportMessage";
export const isUserMovesInterface =
new tg.IsInterface().withProperties({
position: isPointInterface,
viewport: isViewport,
}).get();
export type UserMovesInterface = tg.GuardedType<typeof isUserMovesInterface>;

View file

@ -7,12 +7,12 @@ export const isSignalData =
export const isWebRtcSignalMessageInterface =
new tg.IsInterface().withProperties({
receiverId: tg.isString,
receiverId: tg.isNumber,
signal: isSignalData
}).get();
export const isWebRtcScreenSharingStartMessageInterface =
new tg.IsInterface().withProperties({
userId: tg.isString,
userId: tg.isNumber,
roomId: tg.isString
}).get();
export type WebRtcSignalMessageInterface = tg.GuardedType<typeof isWebRtcSignalMessageInterface>;

View file

@ -11,15 +11,15 @@ import {PositionNotifier} from "./PositionNotifier";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {Movable} from "_Model/Movable";
export type ConnectCallback = (user: string, group: Group) => void;
export type DisconnectCallback = (user: string, group: Group) => void;
export type ConnectCallback = (user: User, group: Group) => void;
export type DisconnectCallback = (user: User, group: Group) => void;
export class World {
private readonly minDistance: number;
private readonly groupRadius: number;
// Users, sorted by ID
private readonly users: Map<string, User>;
private readonly users: Map<number, User>;
private readonly groups: Set<Group>;
private readonly connectCallback: ConnectCallback;
@ -37,7 +37,7 @@ export class World {
onMoves: MovesCallback,
onLeaves: LeavesCallback)
{
this.users = new Map<string, User>();
this.users = new Map<number, User>();
this.groups = new Set<Group>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
@ -51,14 +51,16 @@ export class World {
return Array.from(this.groups.values());
}
public getUsers(): Map<string, User> {
public getUsers(): Map<number, User> {
return this.users;
}
public join(socket : Identificable, userPosition: PointInterface): void {
this.users.set(socket.userId, new User(socket.userId, userPosition, false));
public join(socket : ExSocketInterface, userPosition: PointInterface): void {
const user = new User(socket.userId, userPosition, false, this.positionNotifier, socket);
this.users.set(socket.userId, user);
// Let's call update position to trigger the join / leave room
this.updatePosition(socket, userPosition);
//this.updatePosition(socket, userPosition);
this.updateUserGroup(user);
}
public leave(user : Identificable){
@ -72,8 +74,8 @@ export class World {
this.users.delete(user.userId);
if (userObj !== undefined) {
this.positionNotifier.leave(userObj);
this.positionNotifier.removeViewport(userObj);
this.positionNotifier.leave(userObj);
}
}
@ -87,11 +89,13 @@ export class World {
return;
}
this.positionNotifier.updatePosition(user, userPosition, user.position);
user.setPosition(userPosition);
const oldGroupPosition = user.group?.getPosition();
this.updateUserGroup(user);
}
user.position = userPosition;
private updateUserGroup(user: User): void {
user.group?.updatePosition();
if (user.silent) {
return;
@ -111,7 +115,7 @@ export class World {
const group: Group = new Group([
user,
closestUser
], this.connectCallback, this.disconnectCallback);
], this.connectCallback, this.disconnectCallback, this.positionNotifier);
this.groups.add(group);
}
}
@ -119,16 +123,11 @@ export class World {
} else {
// If the user is part of a group:
// should he leave the group?
const distance = World.computeDistanceBetweenPositions(user.position, user.group.getPosition());
const distance = World.computeDistanceBetweenPositions(user.getPosition(), user.group.getPosition());
if (distance > this.groupRadius) {
this.leaveGroup(user);
}
}
// At the very end, if the user is part of a group, let's call the callback to update group position
if (typeof user.group !== 'undefined') {
this.positionNotifier.updatePosition(user.group, user.group.getPosition(), oldGroupPosition ? oldGroupPosition : user.group.getPosition());
}
}
setSilent(socket: Identificable, silent: boolean) {
@ -147,7 +146,7 @@ export class World {
}
if (!silent) {
// If we are back to life, let's trigger a position update to see if we can join some group.
this.updatePosition(socket, user.position);
this.updatePosition(socket, user.getPosition());
}
}
@ -158,9 +157,10 @@ export class World {
*/
private leaveGroup(user: User): void {
const group = user.group;
if (typeof group === 'undefined') {
if (group === undefined) {
throw new Error("The user is part of no group");
}
const oldPosition = group.getPosition();
group.leave(user);
if (group.isEmpty()) {
this.positionNotifier.leave(group);
@ -170,7 +170,8 @@ export class World {
}
this.groups.delete(group);
} else {
this.positionNotifier.updatePosition(group, group.getPosition(), group.getPosition());
group.updatePosition();
//this.positionNotifier.updatePosition(group, group.getPosition(), oldPosition);
}
}
@ -244,7 +245,7 @@ export class World {
if (group.isFull()) {
return;
}
const distance = World.computeDistanceBetweenPositions(user.position, group.getPosition());
const distance = World.computeDistanceBetweenPositions(user.getPosition(), group.getPosition());
if(distance <= minimumDistanceFound && distance <= this.groupRadius) {
minimumDistanceFound = distance;
matchingItem = group;
@ -256,7 +257,9 @@ export class World {
public static computeDistance(user1: User, user2: User): number
{
return Math.sqrt(Math.pow(user2.position.x - user1.position.x, 2) + Math.pow(user2.position.y - user1.position.y, 2));
const user1Position = user1.getPosition();
const user2Position = user2.getPosition();
return Math.sqrt(Math.pow(user2Position.x - user1Position.x, 2) + Math.pow(user2Position.y - user1Position.y, 2));
}
public static computeDistanceBetweenPositions(position1: PositionInterface, position2: PositionInterface): number

View file

@ -1,6 +1,7 @@
import {User} from "./User";
import {PositionInterface} from "_Model/PositionInterface";
import {Movable} from "_Model/Movable";
import {Movable} from "./Movable";
import {Group} from "./Group";
export type EntersCallback = (thing: Movable, listener: User) => void;
export type MovesCallback = (thing: Movable, position: PositionInterface, listener: User) => void;
@ -10,14 +11,27 @@ export class Zone {
private things: Set<Movable> = new Set<Movable>();
private listeners: Set<User> = new Set<User>();
constructor(private onEnters: EntersCallback, private onMoves: MovesCallback, private onLeaves: LeavesCallback) {
/**
* @param x For debugging purpose only
* @param y For debugging purpose only
*/
constructor(private onEnters: EntersCallback, private onMoves: MovesCallback, private onLeaves: LeavesCallback, private x: number, private y: number) {
}
/**
* A user/thing leaves the zone
*/
public leave(thing: Movable, newZone: Zone|null) {
this.things.delete(thing);
const result = this.things.delete(thing);
if (!result) {
if (thing instanceof User) {
throw new Error('Could not find user in zone '+thing.id);
}
if (thing instanceof Group) {
throw new Error('Could not find group '+thing.getId()+' in zone ('+this.x+','+this.y+'). Position of group: ('+thing.getPosition().x+','+thing.getPosition().y+')');
}
}
this.notifyLeft(thing, newZone);
}
@ -34,13 +48,13 @@ export class Zone {
public enter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
this.things.add(thing);
this.notifyUserEnter(thing, oldZone, position);
this.notifyEnter(thing, oldZone, position);
}
/**
* Notify listeners of this zone that this user entered
*/
private notifyUserEnter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
private notifyEnter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
for (const listener of this.listeners) {
if (listener === thing) {
continue;
@ -56,8 +70,7 @@ export class Zone {
public move(thing: Movable, position: PositionInterface) {
if (!this.things.has(thing)) {
this.things.add(thing);
const foo = this.things;
this.notifyUserEnter(thing, null, position);
this.notifyEnter(thing, null, position);
return;
}

View file

@ -0,0 +1,13 @@
import { App as _App, AppOptions } from 'uWebSockets.js';
import BaseApp from './baseapp';
import { extend } from './utils';
import { UwsApp } from './types';
class App extends (<UwsApp>_App) {
constructor(options: AppOptions = {}) {
super(options); // eslint-disable-line constructor-super
extend(this, new BaseApp());
}
}
export default App;

View file

@ -0,0 +1,116 @@
import { Readable } from 'stream';
import { us_listen_socket_close, TemplatedApp, HttpResponse, HttpRequest } from 'uWebSockets.js';
import formData from './formdata';
import { stob } from './utils';
import { Handler } from './types';
import {join} from "path";
const contTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
const noOp = () => true;
const handleBody = (res: HttpResponse, req: HttpRequest) => {
const contType = req.getHeader('content-type');
res.bodyStream = function() {
const stream = new Readable();
stream._read = noOp; // eslint-disable-line @typescript-eslint/unbound-method
this.onData((ab: ArrayBuffer, isLast: boolean) => {
// uint and then slicing is bit faster than slice and then uint
stream.push(new Uint8Array(ab.slice((ab as any).byteOffset, ab.byteLength))); // eslint-disable-line @typescript-eslint/no-explicit-any
if (isLast) {
stream.push(null);
}
});
return stream;
};
res.body = () => stob(res.bodyStream());
if (contType.includes('application/json'))
res.json = async () => JSON.parse(await res.body());
if (contTypes.map(t => contType.indexOf(t) > -1).indexOf(true) > -1)
res.formData = formData.bind(res, contType);
};
class BaseApp {
_sockets = new Map();
ws!: TemplatedApp['ws'];
get!: TemplatedApp['get'];
_post!: TemplatedApp['post'];
_put!: TemplatedApp['put'];
_patch!: TemplatedApp['patch'];
_listen!: TemplatedApp['listen'];
post(pattern: string, handler: Handler) {
if (typeof handler !== 'function')
throw Error(`handler should be a function, given ${typeof handler}.`);
this._post(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
put(pattern: string, handler: Handler) {
if (typeof handler !== 'function')
throw Error(`handler should be a function, given ${typeof handler}.`);
this._put(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
patch(pattern: string, handler: Handler) {
if (typeof handler !== 'function')
throw Error(`handler should be a function, given ${typeof handler}.`);
this._patch(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
listen(h: string | number, p: Function | number = noOp, cb?: Function) {
if (typeof p === 'number' && typeof h === 'string') {
this._listen(h, p, socket => {
this._sockets.set(p, socket);
if (cb === undefined) {
throw new Error('cb undefined');
}
cb(socket);
});
} else if (typeof h === 'number' && typeof p === 'function') {
this._listen(h, socket => {
this._sockets.set(h, socket);
p(socket);
});
} else {
throw Error(
'Argument types: (host: string, port: number, cb?: Function) | (port: number, cb?: Function)'
);
}
return this;
}
close(port: null | number = null) {
if (port) {
this._sockets.has(port) && us_listen_socket_close(this._sockets.get(port));
this._sockets.delete(port);
} else {
this._sockets.forEach(app => {
us_listen_socket_close(app);
});
this._sockets.clear();
}
return this;
}
}
export default BaseApp;

View file

@ -0,0 +1,100 @@
import { createWriteStream } from 'fs';
import { join, dirname } from 'path';
import Busboy from 'busboy';
import mkdirp from 'mkdirp';
function formData(
contType: string,
options: busboy.BusboyConfig & {
abortOnLimit?: boolean;
tmpDir?: string;
onFile?: (
fieldname: string,
file: NodeJS.ReadableStream,
filename: string,
encoding: string,
mimetype: string
) => string;
onField?: (fieldname: string, value: any) => void;
filename?: (oldName: string) => string;
} = {}
) {
console.log('Enter form data');
options.headers = {
'content-type': contType
};
return new Promise((resolve, reject) => {
const busb = new Busboy(options);
const ret = {};
this.bodyStream().pipe(busb);
busb.on('limit', () => {
if (options.abortOnLimit) {
reject(Error('limit'));
}
});
busb.on('file', function(fieldname, file, filename, encoding, mimetype) {
const value: { filePath: string|undefined, filename: string, encoding:string, mimetype: string } = {
filename,
encoding,
mimetype,
filePath: undefined
};
if (typeof options.tmpDir === 'string') {
if (typeof options.filename === 'function') filename = options.filename(filename);
const fileToSave = join(options.tmpDir, filename);
mkdirp(dirname(fileToSave));
file.pipe(createWriteStream(fileToSave));
value.filePath = fileToSave;
}
if (typeof options.onFile === 'function') {
value.filePath =
options.onFile(fieldname, file, filename, encoding, mimetype) || value.filePath;
}
setRetValue(ret, fieldname, value);
});
busb.on('field', function(fieldname, value) {
if (typeof options.onField === 'function') options.onField(fieldname, value);
setRetValue(ret, fieldname, value);
});
busb.on('finish', function() {
resolve(ret);
});
busb.on('error', reject);
});
}
function setRetValue(
ret: { [x: string]: any },
fieldname: string,
value: { filename: string; encoding: string; mimetype: string; filePath?: string } | any
) {
if (fieldname.slice(-2) === '[]') {
fieldname = fieldname.slice(0, fieldname.length - 2);
if (Array.isArray(ret[fieldname])) {
ret[fieldname].push(value);
} else {
ret[fieldname] = [value];
}
} else {
if (Array.isArray(ret[fieldname])) {
ret[fieldname].push(value);
} else if (ret[fieldname]) {
ret[fieldname] = [ret[fieldname], value];
} else {
ret[fieldname] = value;
}
}
}
export default formData;

View file

@ -0,0 +1,13 @@
import { SSLApp as _SSLApp, AppOptions } from 'uWebSockets.js';
import BaseApp from './baseapp';
import { extend } from './utils';
import { UwsApp } from './types';
class SSLApp extends (<UwsApp>_SSLApp) {
constructor(options: AppOptions) {
super(options); // eslint-disable-line constructor-super
extend(this, new BaseApp());
}
}
export default SSLApp;

View file

@ -0,0 +1,11 @@
import { AppOptions, TemplatedApp, HttpResponse, HttpRequest } from 'uWebSockets.js';
export type UwsApp = {
(options: AppOptions): TemplatedApp;
new (options: AppOptions): TemplatedApp;
prototype: TemplatedApp;
};
export type Handler = (res: HttpResponse, req: HttpRequest) => void;
export {};

View file

@ -0,0 +1,37 @@
import { ReadStream } from 'fs';
function extend(who: any, from: any, overwrite = true) { // eslint-disable-line @typescript-eslint/no-explicit-any
const ownProps = Object.getOwnPropertyNames(Object.getPrototypeOf(from)).concat(
Object.keys(from)
);
ownProps.forEach(prop => {
if (prop === 'constructor' || from[prop] === undefined) return;
if (who[prop] && overwrite) {
who[`_${prop}`] = who[prop];
}
if (typeof from[prop] === 'function') who[prop] = from[prop].bind(who);
else who[prop] = from[prop];
});
}
function stob(stream: ReadStream): Promise<Buffer> {
return new Promise(resolve => {
const buffers: Buffer[] = [];
stream.on('data', buffers.push.bind(buffers));
stream.on('end', () => {
switch (buffers.length) {
case 0:
resolve(Buffer.allocUnsafe(0));
break;
case 1:
resolve(buffers[0]);
break;
default:
resolve(Buffer.concat(buffers));
}
});
});
}
export { extend, stob };

View file

@ -0,0 +1,19 @@
import { parse } from 'query-string';
import { HttpRequest } from 'uWebSockets.js';
import App from './server/app';
import SSLApp from './server/sslapp';
import * as types from './server/types';
const getQuery = (req: HttpRequest) => {
return parse(req.getQuery());
};
export { App, SSLApp, getQuery };
export * from './server/types';
export default {
App,
SSLApp,
getQuery,
...types
};

View file

@ -0,0 +1,55 @@
import {CPU_OVERHEAT_THRESHOLD} from "../Enum/EnvironmentVariable";
function secNSec2ms(secNSec: Array<number>|number) {
if (Array.isArray(secNSec)) {
return secNSec[0] * 1000 + secNSec[1] / 1000000;
}
return secNSec / 1000;
}
class CpuTracker {
private cpuPercent: number = 0;
private overHeating: boolean = false;
constructor() {
let time = process.hrtime.bigint()
let usage = process.cpuUsage()
setInterval(() => {
const elapTime = process.hrtime.bigint();
const elapUsage = process.cpuUsage(usage)
usage = process.cpuUsage()
const elapTimeMS = elapTime - time;
const elapUserMS = secNSec2ms(elapUsage.user)
const elapSystMS = secNSec2ms(elapUsage.system)
this.cpuPercent = Math.round(100 * (elapUserMS + elapSystMS) / Number(elapTimeMS) * 1000000)
time = elapTime;
if (!this.overHeating && this.cpuPercent > CPU_OVERHEAT_THRESHOLD) {
this.overHeating = true;
console.warn('CPU high threshold alert. Going in "overheat" mode');
} else if (this.overHeating && this.cpuPercent <= CPU_OVERHEAT_THRESHOLD) {
this.overHeating = false;
console.log('CPU is back to normal. Canceling "overheat" mode');
}
/*console.log('elapsed time ms: ', elapTimeMS)
console.log('elapsed user ms: ', elapUserMS)
console.log('elapsed system ms:', elapSystMS)
console.log('cpu percent: ', this.cpuPercent)*/
}, 100);
}
public getCpuPercent(): number {
return this.cpuPercent;
}
public isOverHeating(): boolean {
return this.overHeating;
}
}
const cpuTracker = new CpuTracker();
export { cpuTracker };