Page MenuHomePhorge

D2978.1775190742.diff
No OneTemporary

Authored By
Unknown
Size
585 KB
Referenced Files
None
Subscribers
None

D2978.1775190742.diff

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/bin/quickstart.sh b/bin/quickstart.sh
--- a/bin/quickstart.sh
+++ b/bin/quickstart.sh
@@ -52,7 +52,7 @@
bin/regen-certs
-docker-compose up -d coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis
+docker-compose up -d coturn kolab mariadb meet pdns-sql proxy redis
pushd ${base_dir}/src/
diff --git a/docker-compose.openvidu.yml b/docker-compose.openvidu.yml
deleted file mode 100644
--- a/docker-compose.openvidu.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-version: '3'
-services:
- kurento-media-server2:
- build:
- context: ./docker/kurento-media-server/
- container_name: kolab-kurento-media-server2
- environment:
- - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4
- - KMS_PORT=8889
- hostname: kurento-media-server.hosted.com
- image: apheleia/kurento-media-server:6.15.0
- network_mode: host
- openvidu:
- build:
- context: ./docker/openvidu-dev/
- privileged: true
- container_name: kolab-openvidu
- depends_on:
- - kurento-media-server
- - kurento-media-server2
- environment:
- - KMS_URIS=["ws://localhost:8888/kurento"]
- #- KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"]
- volumes:
- - /etc/letsencrypt/:/etc/letsencrypt/:ro
- - ~/src/openvidu:/src/openvidu/:ro
- - ./docker/openvidu-dev/build/.m2:/root/.m2/
diff --git a/docker-compose.yml b/docker-compose.yml
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,15 +1,15 @@
version: '3'
services:
coturn:
+ build:
+ context: ./docker/coturn/
container_name: kolab-coturn
environment:
- - DB_NAME=${OPENVIDU_COTURN_REDIS_DATABASE}
- - DB_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD}
- - REDIS_IP=${OPENVIDU_COTURN_REDIS_IP}
- - TURN_PUBLIC_IP=${OPENVIDU_COTURN_IP}
+ - TURN_PUBLIC_IP=${COTURN_PUBLIC_IP}
- TURN_LISTEN_PORT=3478
+ - TURN_STATIC_SECRET==${COTURN_STATIC_SECRET}
hostname: sturn.mgmt.com
- image: openvidu/openvidu-coturn:1.0.0
+ image: kolab-coturn
network_mode: host
restart: on-failure
tty: true
@@ -49,15 +49,6 @@
- ./docker/kolab/utils:/root/utils:ro
- ./src/.env:/.dockerenv:ro
- /sys/fs/cgroup:/sys/fs/cgroup:ro
- kurento-media-server:
- build:
- context: ./docker/kurento-media-server/
- container_name: kolab-kurento-media-server
- environment:
- - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4
- hostname: kurento-media-server.hosted.com
- image: apheleia/kurento-media-server:6.15.0
- network_mode: host
mariadb:
container_name: kolab-mariadb
environment:
@@ -88,39 +79,6 @@
volumes:
- ./docker/certs/imap.hosted.com.cert:/etc/pki/tls/certs/imap.hosted.com.cert
- ./docker/certs/imap.hosted.com.key:/etc/pki/tls/private/imap.hosted.com.key
- openvidu:
- build:
- context: ./docker/openvidu/
- container_name: kolab-openvidu
- depends_on:
- - kurento-media-server
- environment:
- - APP_DOMAIN=${APP_DOMAIN}
- - CERTIFICATE_TYPE=letsencrypt
- - COTURN_IP=${OPENVIDU_COTURN_IP}
- - COTURN_REDIS_DBNAME=${OPENVIDU_COTURN_REDIS_DATABASE}
- - COTURN_REDIS_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD}
- - COTURN_REDIS_IP=${OPENVIDU_COTURN_REDIS_IP}
- - DOMAIN_OR_PUBLIC_IP=${OPENVIDU_PUBLIC_IP}
- - SERVER_PORT=${OPENVIDU_SERVER_PORT}
- - KMS_STUN_IP=${OPENVIDU_COTURN_IP}
- - KMS_STUN_PORT=3478
- - KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"]
- - OPENVIDU_SECRET=${OPENVIDU_API_PASSWORD}
- - OPENVIDU_WEBHOOK=${OPENVIDU_WEBHOOK}
- - OPENVIDU_WEBHOOK_ENDPOINT=${OPENVIDU_WEBHOOK_ENDPOINT}
- - SERVER_SSL_ENABLED=false
- hostname: openvidu.hosted.com
- image: apheleia/openvidu:2.18.0
- network_mode: host
- tmpfs:
- - /run
- - /tmp
- - /var/run
- - /var/tmp
- tty: true
- volumes:
- - /etc/letsencrypt/:/etc/letsencrypt/:ro
pdns-sql:
build:
context: ./docker/pdns-sql/
@@ -187,3 +145,26 @@
volumes:
- ./src:/home/worker/src.orig:ro
- /sys/fs/cgroup:/sys/fs/cgroup:ro
+ meet:
+ build:
+ context: ./docker/meet/
+ environment:
+ - WEBRTC_LISTEN_IP=${MEET_WEBRTC_LISTEN_IP:?err}
+ - PUBLIC_DOMAIN=${MEET_PUBLIC_DOMAIN:?err}
+ - LISTENING_HOST=0.0.0.0
+ - LISTENING_PORT=12443
+ - TURN_SERVER=${MEET_TURN_SERVER}
+ - TURN_STATIC_SECRET=${COTURN_STATIC_SECRET}
+ - AUTH_TOKEN=${MEET_SERVER_TOKEN:?err}
+ - WEBHOOK_TOKEN=${MEET_WEBHOOK_TOKEN:?err}
+ - WEBHOOK_URL=${APP_PUBLIC_URL:?err}/api/webhooks/meet
+ - SSL_CERT=/etc/pki/tls/certs/meet.${APP_WEBSITE_DOMAIN:?err}.cert
+ - SSL_KEY=/etc/pki/tls/private/meet.${APP_WEBSITE_DOMAIN:?err}.key
+ network_mode: host
+ container_name: kolab-meet
+ image: kolab-meet
+ volumes:
+ - ./meet/server:/src/meet/:ro
+ - ./docker/meet/build/node_modules:/root/node_modules
+ - ./docker/certs/meet.${APP_WEBSITE_DOMAIN}.cert:/etc/pki/tls/certs/meet.${APP_WEBSITE_DOMAIN}.cert
+ - ./docker/certs/meet.${APP_WEBSITE_DOMAIN}.key:/etc/pki/tls/private/meet.${APP_WEBSITE_DOMAIN}.key
diff --git a/docker/coturn/Dockerfile b/docker/coturn/Dockerfile
--- a/docker/coturn/Dockerfile
+++ b/docker/coturn/Dockerfile
@@ -1,36 +1,10 @@
-FROM fedora:31
+FROM fedora:34
MAINTAINER Jeroen van Meeuwen <vanmeeuwen@kolabsys.com>
RUN dnf -y install \
--setopt 'tsflags=nodocs' \
- bash-completion \
- bind-utils \
- coturn \
- curl \
- dhcp-client \
- iproute \
- iptraf-ng \
- iputils \
- less \
- lsof \
- mtr \
- net-tools \
- NetworkManager \
- NetworkManager-tui \
- network-scripts \
- nmap-ncat \
- openssh-clients \
- openssh-server \
- procps-ng \
- redis \
- strace \
- systemd-udev \
- tcpdump \
- telnet \
- traceroute \
- vim-enhanced \
- wget && \
+ coturn && \
dnf clean all
COPY rootfs/ /
diff --git a/docker/coturn/rootfs/usr/local/bin/coturn.sh b/docker/coturn/rootfs/usr/local/bin/coturn.sh
--- a/docker/coturn/rootfs/usr/local/bin/coturn.sh
+++ b/docker/coturn/rootfs/usr/local/bin/coturn.sh
@@ -6,14 +6,25 @@
external-ip=${TURN_PUBLIC_IP:-127.0.0.1}
listening-port=${TURN_LISTEN_PORT:-3478}
fingerprint
-lt-cred-mech
+
+# For testing
+#allow-loopback-peers
+#cli-password=qwerty
+
+# Disabled by default to avoid DoS attacks. Logs all bind attempts in verbose log mode (useful for debugging)
+#log-binding
+
max-port=${MAX_PORT:-65535}
min-port=${MIN_PORT:-40000}
pidfile="$(pwd)/turnserver.pid"
-realm=openvidu
-simple-log
-redis-userdb="ip=${REDIS_IP:-127.0.0.1} dbname=${DB_NAME:-2} password=${DB_PASSWORD:-turn} connect_timeout=30"
-verbose
+realm=kolabmeet
+log-file=stdout
+
+# Dynamically generate username/password for turn
+use-auth-secret
+static-auth-secret=${TURN_STATIC_SECRET:-uzYguvIl9tpZFMuQOE78DpOi6Jc7VFSD0UAnvgMsg5n4e74MgIf6vQvbc6LWzZjz}
+
+# verbose
EOF
/usr/bin/turnserver -c ./turnserver.conf
diff --git a/docker/meet/.gitignore b/docker/meet/.gitignore
new file mode 100644
--- /dev/null
+++ b/docker/meet/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/docker/meet/Dockerfile b/docker/meet/Dockerfile
new file mode 100644
--- /dev/null
+++ b/docker/meet/Dockerfile
@@ -0,0 +1,11 @@
+FROM fedora:34
+
+MAINTAINER Jeroen van Meeuwen <vanmeeuwen@kolabsys.com>
+
+RUN dnf -y install \
+ --setopt 'tsflags=nodocs' \
+ npm nodejs python3 python3-pip meson ninja-build make gcc g++ && \
+ dnf clean all
+
+COPY init.sh /init.sh
+CMD [ "/init.sh" ]
diff --git a/docker/meet/build/node_modules/.gitkeep b/docker/meet/build/node_modules/.gitkeep
new file mode 100644
diff --git a/docker/meet/init.sh b/docker/meet/init.sh
new file mode 100755
--- /dev/null
+++ b/docker/meet/init.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+cp -R /src/meet /src/meetsrc
+rm -Rf /src/meetsrc/node_modules
+ln -s /root/node_modules /src/meetsrc/node_modules
+cd /src/meetsrc
+npm install
+npm install -g nodemon
+export DEBUG="kolabmeet-server* mediasoup*"
+nodemon server.js
diff --git a/docker/proxy/rootfs/etc/nginx/nginx.conf b/docker/proxy/rootfs/etc/nginx/nginx.conf
--- a/docker/proxy/rootfs/etc/nginx/nginx.conf
+++ b/docker/proxy/rootfs/etc/nginx/nginx.conf
@@ -65,14 +65,26 @@
proxy_cache_bypass 1;
}
- location /openvidu {
- proxy_pass https://127.0.0.1:8443;
+ location /meetmedia {
+ proxy_pass https://127.0.0.1:12443;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
}
+ location /meetmedia/api {
+ proxy_pass https://127.0.0.1:12443;
+ proxy_redirect off;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_no_cache 1;
+ proxy_cache_bypass 1;
+ }
+
error_page 404 /404.html;
location = /40x.html {
}
diff --git a/meet/README.md b/meet/README.md
new file mode 100644
--- /dev/null
+++ b/meet/README.md
@@ -0,0 +1,114 @@
+This is the kolab meet server side component.
+
+Run it with nodejs (or use the meet container).
+
+It should become available on on port 12433 (curl -k -v http://localhost:12443/ping)
+
+# To get an interactive console
+/src/meetsrc/connect.js
+
+# To dump some stats
+/src/meetsrc/connect.js --stats
+
+# Test the websocket
+npm -g install wscat
+wscat --no-check -c "wss://172.20.0.2:12443/socket.io/?peerId=peer1&roomId=room1&EIO=3&transport=websocket"
+
+# Update code in container
+docker exec -ti kolab-meet /bin/bash -c "/bin/cp -rf /src/meet/* /src/meetsrc/"
+
+# Quick WebRTC overview
+
+In our setup there are the following components involved:
+* Client (Browser with some javascript)
+* Kolab 4 (Webserver runnnign the kolab 4 application)
+* Turn server (coturn)
+* Kolabmeet server (nodejs application)
+
+Kolabmeet itself has two 3 interaction points:
+* A webserver for the API
+* A websocket for signaling
+* Mediasoup for webrtc
+
+To join a meeting this is roughly what happens:
+* The Client asks the webserver to join a room
+* The webserver contacts kolabmeet to create the room and returns a url for a signaling websocket
+* The client connects to kolabmeet via the signaling websocket
+* The client now asks kolabmeet via the websocket to prepare the media channels
+* Mediasoup then ultimately establishes the webrtc connection, potentially routing the data via the configured turn server.
+
+This leads to the following topolgy:
+* Client <-> Kolab 4 API <-> Kolabmeet API
+* Client <-> Kolabmeet Websocket (Signaling)
+* Client <-> (Turn Server) <-> Kolabmeet WebRTC <-> (Turn Server) <-> Client
+
+
+# Troubleshooting
+
+* Socket.io (signaling) has a debug option that can be set in the browser local storage.
+* Mediasoup has a debug flag that can be set in the config.
+* Coturn has config flags to enable logging of all connection attempts.
+* Firefox has about:webrtc and chrome has chrome://webrtc-internals
+* wscat can be used to test websockets.
+* On the kolabmeet server you can connect to the server by executing connect.js, which allows to inspect the internal state.
+* The browser won't allow a ws: connection from a https:// site, but only chrome will tell you about it.
+* If in question, restart your browser. Sometimes things suddenly start working again.
+* Access to media (webcam and microphone) only works on https or localhost sites (secure context). Otherwise the client side will start to break.
+* 127.0.0.1 as webrtc listening host may not work for a local webrtc setup (and will not give you any warnings about it either). See also firefoxes media.peerconnection.ice.* config options. Use a local interfaces ip instead.
+
+## Connection setup
+
+In order:
+* The client first opens a room via the meet laravel controller. This should work as it's the regular Kolab4 API.
+* Next the controller needs to access the meet API, which it does via MEET_SERVER_URL
+* The client next opens a websocket with the meet server directly, which requires that the client has access to the meet server API.
+* Finally the client needs to establish a webrtc transport with the meet server (possibly via the turn server), and then create producers and consumers for audio/video/screenshare.
+
+Establishing the webrtc connection is the most unclear part, because there is a lot of hidden negotiation done by the browser.
+Important checks are:
+* Which ICE candidates does the meet server communicate to the client (typically a turn server with an IP that the client can reach directly, typically a public IP)
+* These ice candidates should then be visible in firefoxe's about:webrtc view, and one should be selected to establish the transport.
+* The mediasoup-client transport should reach the "connected" state. (This is also visible on the server as the "dtlsState" of the transport)
+* Ultimately you should see data packets on the server when enabling the trace messages, once a client is connected.
+
+# Scalability
+
+The number of participants a server can handle, greatly depends on the number of streams that need to be handled.
+In principle there's at least 2 streams per participant (ignoring screensharing) for audio + video incoming (upstream), and then each of those streams is sent to all participants (excluding the sender). This leads to 2n * (n - 1) streams when everyone is sending and receiving vide + audio. A single cpu core is expected to be able to handle ~500 streams, which leads to ~16 participants.
+
+This number can of course be greatly affected by reducing the number of streams that need to be handled, e.g. listeners not sending video.
+
+## Horizontal scaling
+
+Currently we can scale with the number of threads on a system by using multiple workers, but not across multiple servers.
+
+In the simplest form it would of course be possible to load balance and just distribute rooms on different nodes.
+
+To distribute a single room across different nodes more work is required:
+* A transport needs to be established between the nodes.
+* For each participant on the remote node all streams need to be proxied as well as the signaling.
+
+The benefits of this should be:
+* A room can grow beyond the limits of a server (which would be very large rooms).
+* If we assume peers join a geo-local server:
+** Instead of having to send N streams across to all peers, we can send 1 stream to the server which then distributes to N peers, reducing the required bandwidth on the path between servers.
+** If a reencoder is implemented in each server, latency for request of keyframes is reduced.
+** Local peers can use a more efficient direct path between each other and thus further relieve the server interconnection.
+
+## High availability
+
+In the simplest form the server is simply restarted and all clients reconnect. This results in a brief interruption and some state is lost (chat history), but everyone should be back in the same room relatively quickly.
+
+More advanced forms could potentially recover the internal state from e.g. redis, to recover quicker and relatively transparent to the user. I think the transports need to be reestablished, but webrtc should allow for this.
+
+## Reencoder
+
+The reencoder is a process (running on the server) that consumes a track and simply reencodes it and forwards it to the server again. The server will then have to serve that reencoded track to the end user instead of the direct track.
+That way a keyframe request can be handled by the reencoder instead of the original client, which is especially useful with geolocated nodes (due to latency), but in general protects the client from constantly having to generate keyframes whenevery a client looses connection.
+
+A reencoder can be implemented using libmediasoupclient and thus probably is a c++ endavour (or potentially rust?).
+
+## Ideas to explore
+
+* Automatically disable video for silent participants, and only enable for last N active speakers: https://docs.openvidu.io/en/2.19.0/openvidu-enterprise/#large-scale-sessions
+* Ensure we make use of simulcast (per peer adaptive stream quality, depending on available bandwidth and processing power)
diff --git a/meet/server/.eslintrc.json b/meet/server/.eslintrc.json
new file mode 100644
--- /dev/null
+++ b/meet/server/.eslintrc.json
@@ -0,0 +1,21 @@
+{
+ "env": {
+ "es6": true,
+ "node": true
+ },
+ "extends": [
+ "eslint:recommended"
+ ],
+ "settings": {},
+ "parserOptions": {
+ "ecmaVersion": 2018,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "impliedStrict": true
+ }
+ },
+ "rules": {
+ "brace-style": ["error", "1tbs"],
+ "indent": ["error", 4]
+ }
+}
diff --git a/meet/server/.gitignore b/meet/server/.gitignore
new file mode 100644
--- /dev/null
+++ b/meet/server/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/meet/server/config/config.js b/meet/server/config/config.js
new file mode 100644
--- /dev/null
+++ b/meet/server/config/config.js
@@ -0,0 +1,124 @@
+const os = require('os');
+
+module.exports =
+{
+ // Authentication token for API (not websocket) requests
+ authToken: process.env.AUTH_TOKEN,
+ // Turn server configuration
+ turn: process.env.TURN_SERVER === 'none' ? null : {
+ urls: [
+ // Using transport=tcp prevents the use of udp for the connection to the server, which is useful for testing,
+ // but most likely not desired for production: https://datatracker.ietf.org/doc/html/rfc5766#section-2.1
+ process.env.TURN_SERVER || 'turn:127.0.0.1:3478?transport=tcp'
+ ],
+ staticSecret: process.env.TURN_STATIC_SECRET || 'uzYguvIl9tpZFMuQOE78DpOi6Jc7VFSD0UAnvgMsg5n4e74MgIf6vQvbc6LWzZjz',
+ },
+ // Webhook URL
+ webhookURL: process.env.WEBHOOK_URL,
+ // Webhook authentication token
+ webhookToken: process.env.WEBHOOK_TOKEN,
+ // if you use encrypted private key the set the passphrase
+ tls: process.env.SSL_CERT === 'none' ? null : {
+ // passphrase: 'key_password'
+ cert: process.env.SSL_CERT || `/etc/pki/tls/certs/kolab.hosted.com.cert`,
+ key: process.env.SSL_KEY || `/etc/pki/tls/certs/kolab.hosted.com.key`,
+ },
+ // listening Host or IP
+ // Use "0.0.0.0" or "::") to listen on every IP.
+ listeningHost: process.env.LISTENING_HOST || "0.0.0.0",
+ // Listening port for https server.
+ listeningPort: process.env.LISTENING_PORT || 12443,
+ // Used to establish the websocket connection from the client.
+ publicDomain: process.env.PUBLIC_DOMAIN || '127.0.0.1:12443',
+ // API path prefix
+ pathPrefix: '/meetmedia',
+ // Room size before spreading to new router
+ routerScaleSize: process.env.ROUTER_SCALE_SIZE || 16,
+ // Socket timeout value
+ requestTimeout: 20000,
+ // Socket retries when timeout
+ requestRetries: 3,
+ // Mediasoup settings
+ mediasoup: {
+ numWorkers: process.env.MEDIASOUP_NUM_WORKERS || Object.keys(os.cpus()).length,
+ // mediasoup Worker settings.
+ worker: {
+ logLevel: 'warn',
+ logTags: [
+ 'info',
+ 'ice',
+ 'dtls',
+ 'rtp',
+ 'srtp',
+ 'rtcp'
+ ],
+ rtcMinPort: 40000,
+ rtcMaxPort: 49999
+ },
+ // mediasoup Router settings.
+ router: {
+ // Router media codecs.
+ mediaCodecs: [
+ {
+ kind : 'audio',
+ mimeType : 'audio/opus',
+ clockRate : 48000,
+ channels : 2
+ },
+ {
+ kind : 'video',
+ mimeType : 'video/VP8',
+ clockRate : 90000,
+ parameters :
+ {
+ 'x-google-start-bitrate' : 1000
+ }
+ },
+ {
+ kind : 'video',
+ mimeType : 'video/VP9',
+ clockRate : 90000,
+ parameters :
+ {
+ 'profile-id' : 2,
+ 'x-google-start-bitrate' : 1000
+ }
+ },
+ {
+ kind : 'video',
+ mimeType : 'video/h264',
+ clockRate : 90000,
+ parameters :
+ {
+ 'packetization-mode' : 1,
+ 'profile-level-id' : '4d0032',
+ 'level-asymmetry-allowed' : 1,
+ 'x-google-start-bitrate' : 1000
+ }
+ },
+ {
+ kind : 'video',
+ mimeType : 'video/h264',
+ clockRate : 90000,
+ parameters :
+ {
+ 'packetization-mode' : 1,
+ 'profile-level-id' : '42e01f',
+ 'level-asymmetry-allowed' : 1,
+ 'x-google-start-bitrate' : 1000
+ }
+ }
+ ]
+ },
+ // mediasoup WebRtcTransport settings.
+ webRtcTransport: {
+ listenIps: [
+ { ip: process.env.WEBRTC_LISTEN_IP, announcedIp: null }
+ ],
+ // Initial bitrate estimation
+ initialAvailableOutgoingBitrate: 1000000,
+ // Additional options that are not part of WebRtcTransportOptions.
+ maxIncomingBitrate: 1500000
+ }
+ }
+};
diff --git a/meet/server/connect.js b/meet/server/connect.js
new file mode 100755
--- /dev/null
+++ b/meet/server/connect.js
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+
+const interactiveClient = require('./lib/interactiveClient');
+
+interactiveClient();
diff --git a/meet/server/lib/Logger.js b/meet/server/lib/Logger.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/Logger.js
@@ -0,0 +1,44 @@
+const debug = require('debug');
+
+const APP_NAME = 'kolabmeet-server';
+
+class Logger {
+ constructor(prefix) {
+ if (prefix) {
+ this._debug = debug(`${APP_NAME}:${prefix}`);
+ this._info = debug(`${APP_NAME}:INFO:${prefix}`);
+ this._warn = debug(`${APP_NAME}:WARN:${prefix}`);
+ this._error = debug(`${APP_NAME}:ERROR:${prefix}`);
+ } else {
+ this._debug = debug(APP_NAME);
+ this._info = debug(`${APP_NAME}:INFO`);
+ this._warn = debug(`${APP_NAME}:WARN`);
+ this._error = debug(`${APP_NAME}:ERROR`);
+ }
+
+ /* eslint-disable no-console */
+ this._debug.log = console.info.bind(console);
+ this._info.log = console.info.bind(console);
+ this._warn.log = console.warn.bind(console);
+ this._error.log = console.error.bind(console);
+ /* eslint-enable no-console */
+ }
+
+ get debug() {
+ return this._debug;
+ }
+
+ get info() {
+ return this._info;
+ }
+
+ get warn() {
+ return this._warn;
+ }
+
+ get error() {
+ return this._error;
+ }
+}
+
+module.exports = Logger;
diff --git a/meet/server/lib/Peer.js b/meet/server/lib/Peer.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/Peer.js
@@ -0,0 +1,298 @@
+const EventEmitter = require('events').EventEmitter;
+const Logger = require('./Logger');
+const crypto = require('crypto');
+const Roles = require('./userRoles');
+const { v4: uuidv4 } = require('uuid');
+
+const logger = new Logger('Peer');
+
+class Peer extends EventEmitter {
+ constructor({ roomId }) {
+ logger.info('Peer constructor()');
+
+ super();
+
+ this._id = uuidv4();
+
+ this._roomId = roomId;
+
+ this._socket = null;
+
+ this._closed = false;
+
+ this._role = 0;
+
+ this._nickname = false;
+
+ this._language = null;
+
+ this._routerId = null;
+
+ this._rtpCapabilities = null;
+
+ this._raisedHand = false;
+
+ this._disconnected = false;
+
+ this._transports = new Map();
+
+ this._producers = new Map();
+
+ this._consumers = new Map();
+
+ this._authToken = crypto.randomBytes(16).toString('hex');
+ }
+
+ close() {
+ if (this._closed)
+ return;
+
+ logger.info('close()');
+
+ this._closed = true;
+
+ // Iterate and close all mediasoup Transport associated to this Peer, so all
+ // its Producers and Consumers will also be closed.
+ for (const transport of this.transports.values()) {
+ transport.close();
+ }
+
+ if (this.socket)
+ this.socket.disconnect(true);
+
+
+ if (this._selfDestructTimeout)
+ clearTimeout(this._selfDestructTimeout);
+
+ this._selfDestructTimeout = null;
+
+ this.emit('close');
+ }
+
+ get authToken() {
+ return this._authToken;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get roomId() {
+ return this._roomId;
+ }
+
+ get workerId() {
+ return this._workerId;
+ }
+
+ get socket() {
+ return this._socket;
+ }
+
+ set socket(socket) {
+ this._socket = socket;
+
+ if (this._socket) {
+ this._disconnectListener = (reason) => {
+ this._disconnected = true
+
+ logger.debug('"disconnect" event [id:%s, reason:%s]', this.id, reason);
+
+ if (reason === "client namespace disconnect") {
+ this.close();
+ } else {
+ //If this was a connection interruption we allow to reconnect within 10s
+ //TODO inform peers about disconnected state
+ clearTimeout(this._selfDestructTimeout);
+
+ this._selfDestructTimeout = setTimeout(() => {
+ logger.info(
+ 'Closing peer after reconnect timeout [id:"%s"]',
+ this.id);
+ this.close();
+ }, 10000);
+ }
+ }
+ this._socket.on('disconnect', this._disconnectListener);
+
+ this._requestListener = (request, cb) => {
+ this.emit('request', request, cb);
+ }
+
+ this._socket.on('request', this._requestListener);
+ }
+ }
+
+ get closed() {
+ return this._closed;
+ }
+
+ get disconnected() {
+ return this._disconnected;
+ }
+
+ get role() {
+ return this._role;
+ }
+
+ get nickname() {
+ return this._nickname;
+ }
+
+ get language() {
+ return this._language;
+ }
+
+ set nickname(nickname) {
+ if (nickname !== this._nickname) {
+ this._nickname = nickname;
+
+ this.emit('nicknameChanged');
+ }
+ }
+
+ set language(language) {
+ if (language != this._language) {
+ this._language = language;
+
+ this.emit('languageChanged');
+ }
+ }
+
+ get routerId() {
+ return this._routerId;
+ }
+
+ set routerId(routerId) {
+ this._routerId = routerId;
+ }
+
+ set workerId(workerId) {
+ this._workerId = workerId;
+ }
+
+ get rtpCapabilities() {
+ return this._rtpCapabilities;
+ }
+
+ set rtpCapabilities(rtpCapabilities) {
+ this._rtpCapabilities = rtpCapabilities;
+ }
+
+ get raisedHand() {
+ return this._raisedHand;
+ }
+
+ set raisedHand(raisedHand) {
+ if (this._raisedHand != raisedHand) {
+ this._raisedHand = raisedHand;
+
+ this.emit('raisedHandChanged');
+ }
+ }
+
+ get transports() {
+ return this._transports;
+ }
+
+ get producers() {
+ return this._producers;
+ }
+
+ get consumers() {
+ return this._consumers;
+ }
+
+ setRole(newRole) {
+ if (this._role != newRole) {
+ this._role = newRole;
+
+ this.emit('roleChanged');
+ }
+ }
+
+ isValidRole(newRole) {
+ Object.keys(Roles).forEach(roleId => {
+ const role = Roles[roleId]
+ if (newRole & role) {
+ newRole = newRole ^ role;
+ }
+ })
+
+ return newRole == 0;
+ }
+
+ hasRole(role) {
+ return !!(this._role & role);
+ }
+
+ addTransport(id, transport) {
+ this.transports.set(id, transport);
+ }
+
+ getTransport(id) {
+ return this.transports.get(id);
+ }
+
+ getConsumerTransport() {
+ return Array.from(this.transports.values())
+ .find((t) => t.appData.consuming);
+ }
+
+ removeTransport(id) {
+ this.transports.delete(id);
+ }
+
+ addProducer(id, producer) {
+ this.producers.set(id, producer);
+ }
+
+ getProducer(id) {
+ return this.producers.get(id);
+ }
+
+ removeProducer(id) {
+ this.producers.delete(id);
+ }
+
+ addConsumer(id, consumer) {
+ this.consumers.set(id, consumer);
+ }
+
+ getConsumer(id) {
+ return this.consumers.get(id);
+ }
+
+ removeConsumer(id) {
+ this.consumers.delete(id);
+ }
+
+ get peerInfo() {
+ const peerInfo =
+ {
+ id: this.id,
+ language: this.language,
+ nickname: this.nickname,
+ role: this.role,
+ raisedHand: this.raisedHand
+ };
+
+ return peerInfo;
+ }
+
+ joinRoom(newSocket) {
+ clearTimeout(this._selfDestructTimeout);
+
+ if (this._socket) {
+ logger.debug("Peer is reconnecting ", this.id)
+ this._socket.removeListener('disconnect', this._disconnectListener);
+ this._socket.removeListener('request', this._requestListener);
+ this._socket.disconnect();
+ }
+
+ this.socket = newSocket;
+ this.socket.join(this._roomId);
+ }
+}
+
+module.exports = Peer;
diff --git a/meet/server/lib/Room.js b/meet/server/lib/Room.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/Room.js
@@ -0,0 +1,1080 @@
+const EventEmitter = require('events').EventEmitter;
+const crypto = require('crypto');
+const Logger = require('./Logger');
+const { SocketTimeoutError } = require('./errors');
+const Roles = require('./userRoles');
+const { v4: uuidv4 } = require('uuid');
+
+const config = require('../config/config');
+
+const logger = new Logger('Room');
+
+const ROUTER_SCALE_SIZE = config.routerScaleSize;
+
+class Room extends EventEmitter {
+
+ routerLoad(routerId) {
+ let load = 0;
+ Object.values(this._peers).forEach(peer => {
+ if (peer.routerId == routerId) {
+ load++;
+ }
+ });
+ return load;
+ }
+
+ /*
+ * Find a router that is on a worker that is least loaded.
+ */
+ async getLeastLoadedRouter() {
+ //Look for an existing router with capacity
+ for (const router of this._mediasoupRouters.values()) {
+ if (this.routerLoad(router.id) < ROUTER_SCALE_SIZE) {
+ return router
+ }
+ }
+
+ const worker = await this._getLeastLoadedWorker();
+
+ //If we already have a router for this worker just reuse,
+ //there is no point in creating multiple routers on the same worker.
+ for (const router of this._mediasoupRouters.values()) {
+ if (router.appData.workerPid == worker.pid) {
+ return router;
+ }
+ }
+
+ //Create a new router in the least loaded worker
+ const newRouter = await worker.createRouter({
+ mediaCodecs: config.mediasoup.router.mediaCodecs,
+ appData : { workerPid: worker.pid }
+ });
+ this._mediasoupRouters.set(newRouter.id, newRouter);
+
+ //Pipe existing producers to new router
+ for (const peer of Object.values(this._peers)) {
+ const srcRouter = this._mediasoupRouters.get(peer.routerId);
+ for (const producerId of peer.producers.keys()) {
+ await srcRouter.pipeToRouter({
+ producerId : producerId,
+ router : newRouter
+ });
+ }
+ }
+
+ return newRouter;
+ }
+
+ /**
+ * Factory function that creates and returns Room instance.
+ *
+ * @async
+ *
+ * @param {callback} getLeastLoadedWorker - Callback to request a worker for a new router
+ */
+ static async create({ getLeastLoadedWorker }) {
+ const roomId = uuidv4();
+
+ logger.info('create() [roomId:"%s"]', roomId);
+
+ return new Room({
+ roomId,
+ getLeastLoadedWorker
+ });
+ }
+
+ constructor({
+ roomId,
+ getLeastLoadedWorker
+ }) {
+ logger.info('constructor() [roomId:"%s"]', roomId);
+
+ super();
+ this.setMaxListeners(Infinity);
+
+ this._getLeastLoadedWorker = getLeastLoadedWorker
+
+ this._roomId = roomId;
+
+ this._closed = false;
+
+ this._peers = {};
+
+ this._selfDestructTimeout = null;
+
+ this._mediasoupRouters = new Map();
+
+ this._createdAt = parseInt(Date.now() / 1000);
+ }
+
+ stats() {
+ const peers = this.getPeers();
+ return {
+ numberOfRouters: this._mediasoupRouters.size,
+ numberOfPeers: peers.length,
+ };
+ }
+
+ dumpStats() {
+ console.log(this.stats());
+ }
+
+ close() {
+ logger.debug('close()');
+
+ this._closed = true;
+
+ if (this._selfDestructTimeout)
+ clearTimeout(this._selfDestructTimeout);
+
+ this._selfDestructTimeout = null;
+
+ Object.values(this._peers).forEach(peer => {
+ peer.close();
+ });
+
+ this._peers = {};
+
+ for (const router of this._mediasoupRouters.values()) {
+ router.close();
+ }
+
+ this._mediasoupRouters.clear();
+ this.emit('close');
+ }
+
+ async joinRoom({ peer, socket }) {
+ logger.info('handlePeer() [peer:"%s", role:%s]', peer.id, peer.role);
+
+ if (peer.roomId != this._roomId) {
+ logger.info('handlePeer() Peer is in the wrong room [peer:"%s", peer roomId:%s, roomId: %s]', peer.id, peer.roomId, this._roomId);
+ return;
+ }
+
+ clearTimeout(this._selfDestructTimeout);
+
+ if (this._peers[peer.id]) {
+ peer.joinRoom(socket);
+ this._handlePeer(peer);
+ logger.info("Triggering a room back notification for peer %s", peer.id)
+ this._notification(peer.socket, 'roomBack', {});
+ } else {
+ peer.joinRoom(socket);
+ this._peers[peer.id] = peer;
+ const router = await this.getLeastLoadedRouter();
+ peer.routerId = router.id
+ peer.workerId = router.appData.workerPid;
+ this._handlePeer(peer);
+ const iceServers = this._getIceServers(peer);
+ this._notification(peer.socket, 'roomReady', { iceServers, roomId: this._roomId });
+ }
+ }
+
+ logStatus() {
+ logger.info(
+ 'logStatus() [room id:"%s", peers:"%s"]',
+ this._roomId,
+ Object.keys(this._peers).length
+ );
+ }
+
+ dump() {
+ return {
+ roomId : this._roomId,
+ peers : Object.keys(this._peers).length
+ };
+ }
+
+ get id() {
+ return this._roomId;
+ }
+
+ get createdAt() {
+ return this._createdAt;
+ }
+
+ selfDestructCountdown() {
+ logger.debug('selfDestructCountdown() started');
+
+ clearTimeout(this._selfDestructTimeout);
+
+ this._selfDestructTimeout = setTimeout(() => {
+ logger.info(
+ 'Room deserted for some time, closing the room [roomId:"%s"]',
+ this._roomId);
+ this.close();
+ }, 10000);
+ }
+
+ checkEmpty() {
+ return Object.keys(this._peers).length === 0;
+ }
+
+ _getTURNCredentials(name, secret) {
+ const unixTimeStamp = parseInt(Date.now()/1000) + 24*3600; // this credential would be valid for the next 24 hours
+ // If there is no name, the timestamp alone can also be used.
+ const username = name ? `${unixTimeStamp}:${name}` : `${unixTimeStamp}`;
+ const hmac = crypto.createHmac('sha1', secret);
+ hmac.setEncoding('base64');
+ hmac.write(username);
+ hmac.end();
+ const password = hmac.read();
+ return {
+ username,
+ password
+ };
+ }
+
+ _getIceServers(peer) {
+ if (config.turn) {
+ // Generate time-limited credentials. The name is only relevant for the logs.
+ const {username, password} = this._getTURNCredentials(peer.id, config.turn.staticSecret);
+
+ return [ {
+ urls : config.turn.urls,
+ username : username,
+ credential : password
+ } ];
+ }
+ return null;
+ }
+
+ _handlePeer(peer) {
+ logger.debug('_handlePeer() [peer:"%s"]', peer.id);
+
+ peer.on('close', () => {
+ this._handlePeerClose(peer);
+ });
+
+ peer.on('nicknameChanged', () => {
+ // Spread to others (and self)
+ const data = { peerId: peer.id, nickname: peer.nickname };
+ this._notification(peer.socket, 'changeNickname', data, true, true);
+ });
+
+ peer.on('languageChanged', () => {
+ // Spread to others (and self)
+ const data = { peerId: peer.id, language: peer.language };
+ this._notification(peer.socket, 'changeLanguage', data, true, true);
+ });
+
+ peer.on('roleChanged', () => {
+ // Spread to others (and self)
+ const data = { peerId: peer.id, role: peer.role };
+ this._notification(peer.socket, 'changeRole', data, true, true);
+ });
+
+ peer.on('raisedHandChanged', () => {
+ // Spread to others (and self)
+ const data = { peerId: peer.id, raisedHand: peer.raisedHand };
+ this._notification(peer.socket, 'changeRaisedHand', data, true, true);
+ });
+
+ peer.on('request', (request, cb) => {
+ logger.debug(
+ 'Peer "request" event [method:"%s", peerId:"%s"]',
+ request.method, peer.id);
+
+ this._handlePeerRequest(peer, request, cb)
+ .catch((error) => {
+ logger.error('"request" failed [error:"%o"]', error);
+
+ cb(error);
+ });
+ });
+
+ // Peer left before we were done joining
+ if (peer.closed)
+ this._handlePeerClose(peer);
+ }
+
+ _handlePeerClose(peer) {
+ logger.debug('_handlePeerClose() [peer:"%s"]', peer.id);
+
+ if (this._closed)
+ return;
+
+ this._notification(peer.socket, 'peerClosed', { peerId: peer.id }, true);
+
+ delete this._peers[peer.id];
+
+ // If this is the last Peer in the room close the room after a while.
+ if (this.checkEmpty())
+ this.selfDestructCountdown();
+ }
+
+ async _handlePeerRequest(peer, request, cb) {
+ const router = this._mediasoupRouters.get(peer.routerId);
+
+ switch (request.method) {
+ case 'ping':
+ {
+ console.warn("ping")
+ cb(null);
+ break;
+ }
+
+ case 'getRouterRtpCapabilities':
+ {
+ cb(null, router.rtpCapabilities);
+ break;
+ }
+
+ case 'dumpStats':
+ {
+ this.dumpStats()
+
+ cb(null);
+ break;
+ }
+
+ case 'join':
+ {
+ const {
+ nickname,
+ rtpCapabilities
+ } = request.data;
+
+ // Store client data into the Peer data object.
+ peer.nickname = nickname;
+ peer.rtpCapabilities = rtpCapabilities;
+
+ // Tell the new Peer about already joined Peers.
+ const otherPeers = this.getPeers(peer);
+
+ const peerInfos = otherPeers.map(otherPeer => otherPeer.peerInfo);
+
+ cb(null, { peers: peerInfos, ...peer.peerInfo });
+
+ // Create Consumers for existing Producers.
+ for (const otherPeer of otherPeers) {
+ for (const producer of otherPeer.producers.values()) {
+ this._createConsumer({
+ consumerPeer: peer,
+ producerPeer: otherPeer,
+ producer
+ });
+ }
+ }
+
+ // Notify the new Peer to all other Peers.
+ this._notification(peer.socket, 'newPeer', peer.peerInfo, true);
+
+ logger.debug(
+ 'peer joined [peer: "%s", nickname: "%s"]',
+ peer.id, nickname);
+
+ break;
+ }
+
+ case 'createPlainTransport':
+ {
+ const { producing, consuming } = request.data;
+
+ const transport = await router.createPlainTransport(
+ {
+ //When consuming we manually connect using connectPlainTransport,
+ //otherwise we let the port autodetection work.
+ comedia: producing,
+ // FFmpeg and GStreamer don't support RTP/RTCP multiplexing ("a=rtcp-mux" in SDP)
+ rtcpMux: false,
+ listenIp: config.mediasoup.webRtcTransport.listenIps[0],
+ appData : { producing, consuming }
+ }
+ );
+ // await transport.enableTraceEvent([ "probation", "bwe" ]);
+ // transport.on("trace", (trace) => {
+ // console.log(trace);
+ // });
+
+ peer.addTransport(transport.id, transport);
+
+ cb(
+ null,
+ {
+ id : transport.id,
+ ip : transport.tuple.localIp,
+ port : transport.tuple.localPort,
+ rtcpPort : transport.rtcpTuple ? transport.rtcpTuple.localPort : undefined
+ });
+
+ break;
+ }
+
+ case 'connectPlainTransport':
+ {
+ const { transportId, ip, port, rtcpPort } = request.data;
+ const transport = peer.getTransport(transportId);
+
+ if (!transport)
+ throw new Error(`transport with id "${transportId}" not found`);
+
+ await transport.connect({
+ ip: ip,
+ port: port,
+ rtcpPort: rtcpPort,
+ });
+
+ cb();
+
+ break;
+ }
+
+ case 'createWebRtcTransport':
+ {
+ // NOTE: Don't require that the Peer is joined here, so the client can
+ // initiate mediasoup Transports and be ready when he later joins.
+
+ const { forceTcp, producing, consuming } = request.data;
+
+ const webRtcTransportOptions =
+ {
+ ...config.mediasoup.webRtcTransport,
+ appData : { producing, consuming }
+ };
+
+ webRtcTransportOptions.enableTcp = true;
+
+ if (forceTcp)
+ webRtcTransportOptions.enableUdp = false;
+ else {
+ webRtcTransportOptions.enableUdp = true;
+ webRtcTransportOptions.preferUdp = true;
+ }
+
+ const transport = await router.createWebRtcTransport(
+ webRtcTransportOptions
+ );
+
+ transport.on('dtlsstatechange', (dtlsState) => {
+ if (dtlsState === 'failed' || dtlsState === 'closed') {
+ logger.warn('WebRtcTransport "dtlsstatechange" event [dtlsState:%s]', dtlsState);
+ }
+ });
+
+ // Store the WebRtcTransport into the Peer data Object.
+ peer.addTransport(transport.id, transport);
+
+ cb(
+ null,
+ {
+ id : transport.id,
+ iceParameters : transport.iceParameters,
+ iceCandidates : transport.iceCandidates,
+ dtlsParameters : transport.dtlsParameters
+ });
+
+ const { maxIncomingBitrate } = config.mediasoup.webRtcTransport;
+
+ // If set, apply max incoming bitrate limit.
+ if (maxIncomingBitrate) {
+ try {
+ await transport.setMaxIncomingBitrate(maxIncomingBitrate);
+ } catch (error) {
+ logger.info("Setting the incoming bitrate failed")
+ }
+ }
+
+ break;
+ }
+
+ case 'connectWebRtcTransport':
+ {
+ const { transportId, dtlsParameters } = request.data;
+ const transport = peer.getTransport(transportId);
+
+ if (!transport)
+ throw new Error(`transport with id "${transportId}" not found`);
+
+ await transport.connect({ dtlsParameters });
+
+ cb();
+
+ break;
+ }
+ case 'restartIce':
+ {
+ const { transportId } = request.data;
+ const transport = peer.getTransport(transportId);
+
+ if (!transport)
+ throw new Error(`transport with id "${transportId}" not found`);
+
+ const iceParameters = await transport.restartIce();
+
+ cb(null, iceParameters);
+
+ break;
+ }
+ case 'produce':
+ {
+ let { appData } = request.data;
+
+ if (!appData.source || ![ 'mic', 'webcam', 'screen' ].includes(appData.source))
+ throw new Error('invalid producer source');
+
+ if (appData.source === 'mic' && !peer.hasRole(Roles.PUBLISHER))
+ throw new Error('peer not authorized');
+
+ if (appData.source === 'webcam' && !peer.hasRole(Roles.PUBLISHER))
+ throw new Error('peer not authorized');
+
+ if (appData.source === 'screen' && !peer.hasRole(Roles.PUBLISHER))
+ throw new Error('peer not authorized');
+
+ const { transportId, kind, rtpParameters } = request.data;
+ const transport = peer.getTransport(transportId);
+
+ if (!transport)
+ throw new Error(`transport with id "${transportId}" not found`);
+
+ const producer = await transport.produce({ kind, rtpParameters, appData });
+
+ // Pipe new producer to all other routers (besides this router)
+ for (const routerId of this._getRoutersToPipeTo(peer.routerId)) {
+ const destinationRouter = this._mediasoupRouters.get(routerId)
+ await router.pipeToRouter({
+ producerId : producer.id,
+ router : destinationRouter
+ });
+ }
+
+ // Store the Producer into the Peer data Object.
+ peer.addProducer(producer.id, producer);
+
+ producer.on('videoorientationchange', (videoOrientation) => {
+ logger.debug(
+ 'producer "videoorientationchange" event [producerId:"%s", videoOrientation:"%o"]',
+ producer.id, videoOrientation);
+ });
+
+ // Trace individual packets for debugging
+ // await producer.enableTraceEvent([ "rtp", "pli", "keyframe", "nack" ]);
+ // producer.on("trace", (trace) => {
+ // console.log(`Trace on ${producer.id}`, trace);
+ // });
+
+ cb(null, { id: producer.id });
+
+ // Optimization: Create a server-side Consumer for each Peer.
+ for (const otherPeer of this.getPeers(peer)) {
+ this._createConsumer({
+ consumerPeer: otherPeer,
+ producerPeer: peer,
+ producer
+ });
+ }
+
+ break;
+ }
+
+ case 'closeProducer':
+ {
+ const { producerId } = request.data;
+ const producer = peer.getProducer(producerId);
+
+ if (!producer)
+ throw new Error(`producer with id "${producerId}" not found`);
+
+ producer.close();
+
+ // Remove from its map.
+ peer.removeProducer(producer.id);
+
+ cb();
+
+ break;
+ }
+
+ case 'pauseProducer':
+ {
+ const { producerId } = request.data;
+ const producer = peer.getProducer(producerId);
+
+ if (!producer)
+ throw new Error(`producer with id "${producerId}" not found`);
+
+ await producer.pause();
+
+ cb();
+
+ break;
+ }
+
+ case 'resumeProducer':
+ {
+ const { producerId } = request.data;
+ const producer = peer.getProducer(producerId);
+
+ if (!producer)
+ throw new Error(`producer with id "${producerId}" not found`);
+
+ await producer.resume();
+
+ cb();
+
+ break;
+ }
+
+ case 'pauseConsumer':
+ {
+ const { consumerId } = request.data;
+ const consumer = peer.getConsumer(consumerId);
+
+ if (!consumer)
+ throw new Error(`consumer with id "${consumerId}" not found`);
+
+ await consumer.pause();
+
+ cb();
+
+ break;
+ }
+
+ case 'resumeConsumer':
+ {
+ const { consumerId } = request.data;
+ const consumer = peer.getConsumer(consumerId);
+
+ if (!consumer)
+ throw new Error(`consumer with id "${consumerId}" not found`);
+
+ await consumer.resume();
+
+ cb();
+
+ break;
+ }
+
+ case 'changeNickname':
+ {
+ const { nickname } = request.data;
+
+ peer.nickname = nickname;
+
+ // This will be spread through events from the peer object
+
+ // Return no error
+ cb();
+
+ break;
+ }
+
+ case 'chatMessage':
+ {
+ const { message } = request.data;
+
+ // Spread to others
+ this._notification(peer.socket, 'chatMessage', {
+ peerId: peer.id,
+ nickname: peer.nickname,
+ message: message
+ }, true, true);
+
+ // Return no error
+ cb();
+
+ break;
+ }
+
+ case 'moderator:addRole':
+ {
+ if (!peer.hasRole(Roles.MODERATOR))
+ throw new Error('peer not authorized');
+
+ const { peerId, role } = request.data;
+
+ const rolePeer = this._peers[peerId];
+
+ if (!rolePeer)
+ throw new Error(`peer with id "${peerId}" not found`);
+
+ if (!rolePeer.isValidRole(role))
+ throw new Error('invalid role');
+
+ if (!rolePeer.hasRole(role)) {
+ // The 'owner' role is not assignable
+ if (role & Roles.OWNER)
+ throw new Error('the OWNER role is not assignable');
+
+ // Promotion to publisher? Put the user hand down
+ if (role & Roles.PUBLISHER && rolePeer.raisedHand)
+ rolePeer.raisedHand = false;
+
+ // This will propagate the event automatically
+ rolePeer.setRole(rolePeer.role | role);
+ }
+
+ // Return no error
+ cb();
+
+ break;
+ }
+
+ case 'moderator:removeRole':
+ {
+ if (!peer.hasRole(Roles.MODERATOR))
+ throw new Error('peer not authorized');
+
+ const { peerId, role } = request.data;
+
+ const rolePeer = this._peers[peerId];
+
+ if (!rolePeer)
+ throw new Error(`peer with id "${peerId}" not found`);
+
+ if (!rolePeer.isValidRole(role))
+ throw new Error('invalid role');
+
+ if (rolePeer.hasRole(role)) {
+ if (role & Roles.OWNER)
+ throw new Error('the OWNER role is not removable');
+
+ if (role & Roles.MODERATOR && rolePeer.role & Roles.OWNER)
+ throw new Error('the MODERATOR role cannot be removed from the OWNER');
+
+ // Non-publisher cannot be a language interpreter
+ if (role & Roles.PUBLISHER)
+ rolePeer.language = null;
+
+ // This will propagate the event automatically
+ rolePeer.setRole(rolePeer.role ^ role);
+ }
+
+ // Return no error
+ cb();
+
+ break;
+ }
+
+ case 'moderator:changeLanguage':
+ {
+ if (!peer.hasRole(Roles.MODERATOR))
+ throw new Error('peer not authorized');
+
+ const { peerId, language } = request.data;
+
+ if (language && !/^[a-z]{2}$/.test(language))
+ throw new Error('invalid language code');
+
+ const langPeer = this._peers[peerId];
+
+ if (!langPeer)
+ throw new Error(`peer with id "${peerId}" not found`);
+
+ langPeer.language = language;
+
+ // This will be spread through events from the peer object
+
+ // Return no error
+ cb();
+
+ break;
+ }
+
+ case 'moderator:joinRequestAccept':
+ {
+ if (!peer.hasRole(Roles.MODERATOR))
+ throw new Error('peer not authorized');
+
+ const { requestId } = request.data;
+
+ // Return no error
+ cb();
+
+ this.emit('joinRequestAccepted', requestId);
+
+ break;
+ }
+
+ case 'moderator:joinRequestDeny':
+ {
+ if (!peer.hasRole(Roles.MODERATOR))
+ throw new Error('peer not authorized');
+
+ const { requestId } = request.data;
+
+ // Return no error
+ cb();
+
+ this.emit('joinRequestDenied', requestId);
+
+ break;
+ }
+
+ case 'moderator:closeRoom':
+ {
+ if (!peer.hasRole(Roles.OWNER))
+ throw new Error('peer not authorized');
+
+ this._notification(peer.socket, 'moderator:closeRoom', null, true);
+
+ cb();
+
+ // Close the room
+ this.close();
+
+ break;
+ }
+
+ case 'moderator:kickPeer':
+ {
+ if (!peer.hasRole(Roles.MODERATOR))
+ throw new Error('peer not authorized');
+
+ const { peerId } = request.data;
+
+ const kickPeer = this._peers[peerId];
+
+ if (!kickPeer)
+ throw new Error(`peer with id "${peerId}" not found`);
+
+ this._notification(kickPeer.socket, 'moderator:kickPeer');
+
+ kickPeer.close();
+
+ cb();
+
+ break;
+ }
+
+ case 'raisedHand':
+ {
+ const { raisedHand } = request.data;
+
+ peer.raisedHand = raisedHand;
+
+ // This will be spread through events from the peer object
+
+ // Return no error
+ cb();
+
+ break;
+ }
+
+ default:
+ {
+ logger.error('unknown request.method "%s"', request.method);
+
+ cb(500, `unknown request.method "${request.method}"`);
+ }
+ }
+ }
+
+ /**
+ * Creates a mediasoup Consumer for the given mediasoup Producer.
+ *
+ * @async
+ */
+ async _createConsumer({ consumerPeer, producerPeer, producer }) {
+ logger.debug(
+ '_createConsumer() [consumerPeer:"%s", producerPeer:"%s", producer:"%s"]',
+ consumerPeer.id,
+ producerPeer.id,
+ producer.id
+ );
+
+ const router = this._mediasoupRouters.get(producerPeer.routerId);
+
+ // Optimization:
+ // - Create the server-side Consumer. If video, do it paused.
+ // - Tell its Peer about it and wait for its response.
+ // - Upon receipt of the response, resume the server-side Consumer.
+ // - If video, this will mean a single key frame requested by the
+ // server-side Consumer (when resuming it).
+
+ // NOTE: Don't create the Consumer if the remote Peer cannot consume it.
+ if (
+ !consumerPeer.rtpCapabilities ||
+ !router.canConsume(
+ {
+ producerId : producer.id,
+ rtpCapabilities : consumerPeer.rtpCapabilities
+ })
+ ) {
+ return;
+ }
+
+ // Must take the Transport the remote Peer is using for consuming.
+ const transport = consumerPeer.getConsumerTransport();
+
+ // This should not happen.
+ if (!transport) {
+ logger.warn('_createConsumer() | Transport for consuming not found');
+
+ return;
+ }
+
+ // Create the Consumer in paused mode.
+ let consumer;
+
+ try {
+ consumer = await transport.consume(
+ {
+ producerId : producer.id,
+ rtpCapabilities : consumerPeer.rtpCapabilities,
+ paused : producer.kind === 'video'
+ });
+
+ if (producer.kind === 'audio')
+ await consumer.setPriority(255);
+ } catch (error) {
+ logger.warn('_createConsumer() | [error:"%o"]', error);
+
+ return;
+ }
+
+ // Trace individual packets for debugging
+ // await consumer.enableTraceEvent([ "rtp", "pli", "fir" ]);
+ // consumer.on("trace", (trace) => {
+ // console.log(`Trace on ${consumer.id}`, trace);
+ // });
+
+ // Store the Consumer into the consumerPeer data Object.
+ consumerPeer.addConsumer(consumer.id, consumer);
+
+ // Set Consumer events.
+ consumer.on('transportclose', () => {
+ // Remove from its map.
+ consumerPeer.removeConsumer(consumer.id);
+ });
+
+ consumer.on('producerclose', () => {
+ // Remove from its map.
+ consumerPeer.removeConsumer(consumer.id);
+
+ this._notification(consumerPeer.socket, 'consumerClosed', { consumerId: consumer.id });
+ });
+
+ consumer.on('producerpause', () => {
+ this._notification(consumerPeer.socket, 'consumerPaused', { consumerId: consumer.id });
+ });
+
+ consumer.on('producerresume', () => {
+ this._notification(consumerPeer.socket, 'consumerResumed', { consumerId: consumer.id });
+ });
+
+ consumer.on("layerschange", (layers) => {
+ this._notification(consumerPeer.socket, 'consumerLayersChanged', { consumerId: consumer.id, layers: layers });
+ })
+
+ consumer.on("score", (score) => {
+ this._notification(consumerPeer.socket, 'consumerScoreChanged', { consumerId: consumer.id, score: score });
+ })
+
+ // Send a request to the remote Peer with Consumer parameters.
+ try {
+ await this._request(
+ consumerPeer.socket,
+ 'newConsumer',
+ {
+ peerId : producerPeer.id,
+ kind : consumer.kind,
+ producerId : producer.id,
+ id : consumer.id,
+ rtpParameters : consumer.rtpParameters,
+ type : consumer.type,
+ appData : producer.appData,
+ producerPaused : consumer.producerPaused
+ }
+ );
+
+ // Now that we got the positive response from the remote Peer and, if
+ // video, resume the Consumer to ask for an efficient key frame.
+ await consumer.resume();
+ } catch (error) {
+ logger.warn('_createConsumer() | [error:"%o"]', error);
+ }
+ }
+
+ /**
+ * Get the list of peers.
+ */
+ getPeers(excludePeer = undefined) {
+ return Object.values(this._peers)
+ .filter((peer) => peer !== excludePeer);
+ }
+
+ _timeoutCallback(callback) {
+ let called = false;
+
+ const interval = setTimeout(
+ () => {
+ if (called)
+ return;
+ called = true;
+ callback(new SocketTimeoutError('Request timed out'));
+ },
+ config.requestTimeout || 20000
+ );
+
+ return (...args) => {
+ if (called)
+ return;
+ called = true;
+ clearTimeout(interval);
+
+ callback(...args);
+ };
+ }
+
+ _sendRequest(socket, method, data = {}) {
+ return new Promise((resolve, reject) => {
+ socket.emit(
+ 'request',
+ { method, data },
+ this._timeoutCallback((err, response) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(response);
+ }
+ })
+ );
+ });
+ }
+
+ async _request(socket, method, data) {
+ logger.debug('_request() [method:"%s", data:"%o"]', method, data);
+
+ const {
+ requestRetries = 3
+ } = config;
+
+ for (let tries = 0; tries < requestRetries; tries++) {
+ try {
+ return await this._sendRequest(socket, method, data);
+ } catch (error) {
+ if (
+ error instanceof SocketTimeoutError &&
+ tries < requestRetries
+ )
+ logger.warn('_request() | timeout, retrying [attempt:"%s"]', tries);
+ else
+ throw error;
+ }
+ }
+ }
+
+ _notification(socket, method, data = {}, broadcast = false, includeSender = false) {
+ if (broadcast) {
+ socket.broadcast.to(this._roomId).emit(
+ 'notification', { method, data }
+ );
+
+ if (includeSender)
+ socket.emit('notification', { method, data });
+ } else {
+ socket.emit('notification', { method, data });
+ }
+ }
+
+ // Returns an array of router ids we need to pipe to:
+ // The combined set of routers of all peers, exluding the router of the peer itself.
+ _getRoutersToPipeTo(originRouterId) {
+ return Array.from(this._mediasoupRouters.keys())
+ .filter((routerId) => routerId !== originRouterId);
+ }
+}
+
+module.exports = Room;
diff --git a/meet/server/lib/errors.js b/meet/server/lib/errors.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/errors.js
@@ -0,0 +1,21 @@
+/**
+ * Error produced when a socket request has a timeout.
+ */
+class SocketTimeoutError extends Error {
+ constructor(message) {
+ super(message);
+
+ this.name = 'SocketTimeoutError';
+
+ // eslint-disable-next-line no-prototype-builtins
+ if (Error.hasOwnProperty('captureStackTrace')) // Just in V8.
+ Error.captureStackTrace(this, SocketTimeoutError);
+ else
+ this.stack = (new Error(message)).stack;
+ }
+}
+
+module.exports =
+{
+ SocketTimeoutError
+};
diff --git a/meet/server/lib/interactiveClient.js b/meet/server/lib/interactiveClient.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/interactiveClient.js
@@ -0,0 +1,25 @@
+const net = require('net');
+const os = require('os');
+const path = require('path');
+
+const SOCKET_PATH_UNIX = '/tmp/kolabmeet-server.sock';
+const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'kolabmeet-server');
+const SOCKET_PATH = os.platform() === 'win32'? SOCKET_PATH_WIN : SOCKET_PATH_UNIX;
+
+module.exports = async function() {
+ const socket = net.connect(SOCKET_PATH);
+
+ process.stdin.pipe(socket);
+ socket.pipe(process.stdout);
+
+ socket.on('connect', () => process.stdin.setRawMode(true));
+
+ socket.on('close', () => process.exit(0));
+ socket.on('exit', () => socket.end());
+
+ if (process.argv && process.argv[2] === '--stats') {
+ await socket.write('stats\n');
+
+ socket.end();
+ }
+};
diff --git a/meet/server/lib/interactiveServer.js b/meet/server/lib/interactiveServer.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/interactiveServer.js
@@ -0,0 +1,503 @@
+const os = require('os');
+const path = require('path');
+const repl = require('repl');
+const readline = require('readline');
+const net = require('net');
+const fs = require('fs');
+const mediasoup = require('mediasoup');
+const colors = require('colors/safe');
+const pidusage = require('pidusage');
+
+const SOCKET_PATH_UNIX = '/tmp/kolabmeet-server.sock';
+const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'kolabmeet-server');
+const SOCKET_PATH = os.platform() === 'win32' ? SOCKET_PATH_WIN : SOCKET_PATH_UNIX;
+
+// Maps to store all mediasoup objects.
+const workers = new Map();
+const routers = new Map();
+const transports = new Map();
+const producers = new Map();
+const consumers = new Map();
+const dataProducers = new Map();
+const dataConsumers = new Map();
+
+class Interactive {
+ constructor(socket) {
+ this._socket = socket;
+
+ this._isTerminalOpen = false;
+ }
+
+ async dump(name, params, entries) {
+ const list = params[0] ? [entries.get(params[0])] : entries.values();
+
+ for (const entry of list) {
+ if (!entry) {
+ this.error(`${name} not found`);
+ break;
+ }
+
+ try {
+ const dump = await entry.dump();
+ this.log(`${name}.dump():\n${JSON.stringify(dump, null, ' ')}`);
+ } catch (error) {
+ this.error(`${name}.dump() failed: ${error}`);
+ }
+ }
+ }
+
+ async dumpStats(name, params, entries) {
+ const list = params[0] ? [entries.get(params[0])] : entries.values();
+
+ for (const entry of list) {
+ if (!entry) {
+ this.error(`${name} not found`);
+ break;
+ }
+
+ try {
+ const stats = await entry.getStats();
+ this.log(`${name}.getStats():\n${JSON.stringify(stats, null, ' ')}`);
+ } catch (error) {
+ this.error(`${name}.getStats() failed: ${error}`);
+ }
+ }
+ }
+
+ async processCommand(command, params) {
+ switch (command) {
+ case '':
+ {
+ break;
+ }
+
+ case 'h':
+ case 'help':
+ {
+ this.log('');
+ this.log('available commands:');
+ this.log('- h, help : show this message');
+ this.log('- usage : show CPU and memory usage of the Node.js and mediasoup-worker processes');
+ this.log('- logLevel level : changes logLevel in all mediasoup Workers');
+ this.log('- logTags [tag] [tag] : changes logTags in all mediasoup Workers (values separated by space)');
+ this.log('- dumpRooms : dump all rooms');
+ this.log('- dumpPeers : dump all peers');
+ this.log('- dw, dumpWorkers : dump mediasoup Workers');
+ this.log('- dr, dumpRouter [id] : dump mediasoup Router with given id (or the latest created one)');
+ this.log('- dt, dumpTransport [id] : dump mediasoup Transport with given id (or the latest created one)');
+ this.log('- dp, dumpProducer [id] : dump mediasoup Producer with given id (or the latest created one)');
+ this.log('- dc, dumpConsumer [id] : dump mediasoup Consumer with given id (or the latest created one)');
+ this.log('- sr, statsRoom [id] : get stats for the room with id');
+ this.log('- st, statsTransport [id] : get stats for mediasoup Transport with given id (or all)');
+ this.log('- sp, statsProducer [id] : get stats for mediasoup Producer with given id (or all)');
+ this.log('- sc, statsConsumer [id] : get stats for mediasoup Consumer with given id (or all)');
+ this.log('- ddp, dumpDataProducer [id] : dump mediasoup DataProducer with given id (or the latest created one)');
+ this.log('- ddc, dumpDataConsumer [id] : dump mediasoup DataConsumer with given id (or the latest created one)');
+ this.log('- sdp, statsDataProducer [id] : get stats for mediasoup DataProducer with given id (or the latest created one)');
+ this.log('- sdc, statsDataConsumer [id] : get stats for mediasoup DataConsumer with given id (or the latest created one)');
+ this.log('- t, terminal : open Node REPL Terminal');
+ this.log('');
+
+ break;
+ }
+
+ case 'u':
+ case 'usage':
+ {
+ let usage = await pidusage(process.pid);
+
+ this.log(`Node.js process [pid:${process.pid}]:\n${JSON.stringify(usage, null, ' ')}`);
+
+ for (const worker of workers.values()) {
+ usage = await pidusage(worker.pid);
+
+ this.log(`mediasoup-worker process [pid:${worker.pid}]:\n${JSON.stringify(usage, null, ' ')}`);
+ }
+
+ break;
+ }
+
+ case 'logLevel':
+ {
+ const level = params[0];
+ const promises = [];
+
+ for (const worker of workers.values()) {
+ promises.push(worker.updateSettings({ logLevel: level }));
+ }
+
+ try {
+ await Promise.all(promises);
+
+ this.log('done');
+ } catch (error) {
+ this.error(String(error));
+ }
+
+ break;
+ }
+
+ case 'logTags':
+ {
+ const tags = params;
+ const promises = [];
+
+ for (const worker of workers.values()) {
+ promises.push(worker.updateSettings({ logTags: tags }));
+ }
+
+ try {
+ await Promise.all(promises);
+
+ this.log('done');
+ } catch (error) {
+ this.error(String(error));
+ }
+
+ break;
+ }
+
+ case 'stats':
+ {
+ this.log(`rooms:${global.rooms.size}\npeers:${global.peers.size}`);
+
+ break;
+ }
+
+ case 'sr':
+ case 'statsRoom': {
+ const room = global.rooms.get(params[0]);
+ this.log(`Room ${room._roomId}`);
+ this.log(`Stats \n${JSON.stringify(room.stats(), null, ' ')}`);
+ for (const peer of Object.values(room._peers)) {
+ this.log(`Peer ${peer._nickname}`);
+ for (const entry of peer._consumers.values()) {
+ const stats = await entry.getStats();
+ this.log(`Consumer:\n${JSON.stringify(stats, null, ' ')}`);
+ }
+ for (const entry of peer._producers.values()) {
+ const stats = await entry.getStats();
+ this.log(`Producer:\n${JSON.stringify(stats, null, ' ')}`);
+ }
+ for (const entry of peer._transports.values()) {
+ const stats = await entry.getStats();
+ this.log(`Transport:\n${JSON.stringify(stats, null, ' ')}`);
+ }
+ }
+ break;
+ }
+
+ case 'dumpRooms':
+ {
+ for (const room of global.rooms.values()) {
+ try {
+ const dump = await room.dump();
+
+ this.log(`room.dump():\n${JSON.stringify(dump, null, ' ')}`);
+ } catch (error) {
+ this.error(`room.dump() failed: ${error}`);
+ }
+ }
+
+ break;
+ }
+
+ case 'dumpPeers':
+ {
+ for (const peer of global.peers.values()) {
+ try {
+ const dump = await peer.peerInfo;
+
+ this.log(`peer.peerInfo():\n${JSON.stringify(dump, null, ' ')}`);
+ } catch (error) {
+ this.error(`peer.peerInfo() failed: ${error}`);
+ }
+ }
+
+ break;
+ }
+
+ case 'dw':
+ case 'dumpWorkers':
+ {
+ for (const worker of workers.values()) {
+ try {
+ const dump = await worker.dump();
+
+ this.log(`worker.dump():\n${JSON.stringify(dump, null, ' ')}`);
+ } catch (error) {
+ this.error(`worker.dump() failed: ${error}`);
+ }
+ }
+
+ break;
+ }
+
+ case 'dr':
+ case 'dumpRouter':
+ {
+ await this.dump('router', params, routers);
+ break;
+ }
+
+ case 'dt':
+ case 'dumpTransport':
+ {
+ await this.dump('transport', params, transports);
+ break;
+ }
+
+ case 'dp':
+ case 'dumpProducer':
+ {
+ await this.dump('producer', params, producers);
+ break;
+ }
+
+ case 'dc':
+ case 'dumpConsumer':
+ {
+ await this.dump('consumer', params, consumers);
+ break;
+ }
+
+ case 'ddp':
+ case 'dumpDataProducer':
+ {
+ await this.dump('dataProducer', params, dataProducers);
+ break;
+ }
+
+ case 'ddc':
+ case 'dumpDataConsumer':
+ {
+ await this.dump('dataConsumer', params, dataConsumers);
+ break;
+ }
+
+ case 'st':
+ case 'statsTransport':
+ {
+ await this.dumpStats('transport', params, transports);
+ break;
+ }
+
+ case 'sp':
+ case 'statsProducer':
+ {
+ await this.dumpStats('producer', params, producers);
+ break;
+ }
+
+ case 'sc':
+ case 'statsConsumer':
+ {
+ await this.dumpStats('consumer', params, consumers);
+ break;
+ }
+
+ case 'sdp':
+ case 'statsDataProducer':
+ {
+ await this.dumpStats('dataProducer', params, dataProducers);
+ break;
+ }
+
+ case 'sdc':
+ case 'statsDataConsumer':
+ {
+ await this.dumpStats('dataConsumer', params, dataConsumers);
+ break;
+ }
+
+ case 't':
+ case 'terminal':
+ {
+ return false;
+ }
+
+ default:
+ {
+ this.error(`unknown command '${command}'`);
+ this.log('press \'h\' or \'help\' to get the list of available commands');
+ }
+ }
+ return true;
+ }
+
+ openCommandConsole() {
+ const cmd = readline.createInterface(
+ {
+ input : this._socket,
+ output : this._socket,
+ terminal : true
+ });
+
+ cmd.on('close', () => {
+ if (this._isTerminalOpen)
+ return;
+
+ this.log('\nexiting...');
+
+ this._socket.end();
+ });
+
+ const readStdin = () => {
+ cmd.question('cmd> ', async (input) => {
+ const params = input.split(/[\s\t]+/);
+ const command = params.shift();
+ try {
+ const ret = await this.processCommand(command, params);
+ if (!ret) {
+ this._isTerminalOpen = true;
+ cmd.close();
+ this.openTerminal();
+ return;
+ }
+ } catch (error) {
+ this.error(`Processing of command ${command} ${params} failed: ${error}`);
+ }
+
+ readStdin();
+ });
+ };
+
+ readStdin();
+ }
+
+ openTerminal() {
+ this.log('\n[opening Node REPL Terminal...]');
+ this.log('here you have access to workers, routers, transports, producers, consumers, dataProducers and dataConsumers ES6 maps');
+
+ const terminal = repl.start(
+ {
+ input : this._socket,
+ output : this._socket,
+ terminal : true,
+ prompt : 'terminal> ',
+ useColors : true,
+ useGlobal : true,
+ ignoreUndefined : false
+ });
+
+ this._isTerminalOpen = true;
+
+ terminal.on('exit', () => {
+ this.log('\n[exiting Node REPL Terminal...]');
+
+ this._isTerminalOpen = false;
+
+ this.openCommandConsole();
+ });
+ }
+
+ log(msg) {
+ try {
+ this._socket.write(`${colors.green(msg)}\n`);
+ } catch (error) {
+ //Do nothing
+ }
+ }
+
+ error(msg) {
+ try {
+ this._socket.write(`${colors.red.bold('ERROR: ')}${colors.red(msg)}\n`);
+ } catch (error) {
+ //Do nothing
+ }
+ }
+}
+
+function runMediasoupObserver() {
+ mediasoup.observer.on('newworker', (worker) => {
+ // Store the latest worker in a global variable.
+ global.worker = worker;
+
+ workers.set(worker.pid, worker);
+ worker.observer.on('close', () => workers.delete(worker.pid));
+
+ worker.observer.on('newrouter', (router) => {
+ // Store the latest router in a global variable.
+ global.router = router;
+
+ routers.set(router.id, router);
+ router.observer.on('close', () => routers.delete(router.id));
+
+ router.observer.on('newtransport', (transport) => {
+ // Store the latest transport in a global variable.
+ global.transport = transport;
+
+ transports.set(transport.id, transport);
+ transport.observer.on('close', () => transports.delete(transport.id));
+
+ transport.observer.on('newproducer', (producer) => {
+ // Store the latest producer in a global variable.
+ global.producer = producer;
+
+ producers.set(producer.id, producer);
+ producer.observer.on('close', () => producers.delete(producer.id));
+ });
+
+ transport.observer.on('newconsumer', (consumer) => {
+ // Store the latest consumer in a global variable.
+ global.consumer = consumer;
+
+ consumers.set(consumer.id, consumer);
+ consumer.observer.on('close', () => consumers.delete(consumer.id));
+ });
+
+ transport.observer.on('newdataproducer', (dataProducer) => {
+ // Store the latest dataProducer in a global variable.
+ global.dataProducer = dataProducer;
+
+ dataProducers.set(dataProducer.id, dataProducer);
+ dataProducer.observer.on('close', () => dataProducers.delete(dataProducer.id));
+ });
+
+ transport.observer.on('newdataconsumer', (dataConsumer) => {
+ // Store the latest dataConsumer in a global variable.
+ global.dataConsumer = dataConsumer;
+
+ dataConsumers.set(dataConsumer.id, dataConsumer);
+ dataConsumer.observer.on('close', () => dataConsumers.delete(dataConsumer.id));
+ });
+ });
+ });
+ });
+}
+
+module.exports = async function(rooms, peers) {
+ try {
+ // Run the mediasoup observer API.
+ runMediasoupObserver();
+
+ // Make maps global so they can be used during the REPL terminal.
+ global.rooms = rooms;
+ global.peers = peers;
+ global.workers = workers;
+ global.routers = routers;
+ global.transports = transports;
+ global.producers = producers;
+ global.consumers = consumers;
+ global.dataProducers = dataProducers;
+ global.dataConsumers = dataConsumers;
+
+ const server = net.createServer((socket) => {
+ const interactive = new Interactive(socket);
+
+ interactive.openCommandConsole();
+ });
+
+ await new Promise((resolve) => {
+ try {
+ fs.unlinkSync(SOCKET_PATH);
+ } catch (error) {
+ //Do nothing
+ }
+
+ server.listen(SOCKET_PATH, resolve);
+ });
+ } catch (error) {
+ //Do nothing
+ }
+};
diff --git a/meet/server/lib/userRoles.js b/meet/server/lib/userRoles.js
new file mode 100644
--- /dev/null
+++ b/meet/server/lib/userRoles.js
@@ -0,0 +1,7 @@
+module.exports = {
+ SUBSCRIBER: 1 << 0,
+ PUBLISHER: 1 << 1,
+ MODERATOR: 1 << 2,
+ SCREEN: 1 << 3,
+ OWNER: 1 << 4
+};
diff --git a/meet/server/package-lock.json b/meet/server/package-lock.json
new file mode 100644
--- /dev/null
+++ b/meet/server/package-lock.json
@@ -0,0 +1,2530 @@
+{
+ "name": "kolabmeet-server",
+ "requires": true,
+ "lockfileVersion": 1,
+ "dependencies": {
+ "@eslint/eslintrc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.3.tgz",
+ "integrity": "sha512-DHI1wDPoKCBPoLZA3qDR91+3te/wDSc1YhKg3jR8NxKKRJq2hwHwcWv31cSwSYvIBrmbENoYMWcenW8uproQqg==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.0.0",
+ "globals": "^13.9.0",
+ "ignore": "^4.0.6",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^3.13.1",
+ "minimatch": "^3.0.4",
+ "strip-json-comments": "^3.1.1"
+ },
+ "dependencies": {
+ "js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "requires": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@humanwhocodes/config-array": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.6.0.tgz",
+ "integrity": "sha512-JQlEKbcgEUjBFhLIF4iqM7u/9lwgHRBcpHrmUNCALK0Q3amXN6lxdoXLnF0sm11E9VqTmBALR87IlUg1bZ8A9A==",
+ "dev": true,
+ "requires": {
+ "@humanwhocodes/object-schema": "^1.2.0",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.4"
+ }
+ },
+ "@humanwhocodes/object-schema": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz",
+ "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==",
+ "dev": true
+ },
+ "@types/component-emitter": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.11.tgz",
+ "integrity": "sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ=="
+ },
+ "@types/cookie": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
+ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
+ },
+ "@types/cors": {
+ "version": "2.8.12",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz",
+ "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw=="
+ },
+ "@types/debug": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
+ "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==",
+ "dev": true,
+ "requires": {
+ "@types/ms": "*"
+ }
+ },
+ "@types/events": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
+ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==",
+ "dev": true
+ },
+ "@types/ms": {
+ "version": "0.7.31",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
+ "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==",
+ "dev": true
+ },
+ "@types/node": {
+ "version": "16.11.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.1.tgz",
+ "integrity": "sha512-PYGcJHL9mwl1Ek3PLiYgyEKtwTMmkMw4vbiyz/ps3pfdRYLVv+SN7qHVAImrjdAXxgluDEw6Ph4lyv+m9UpRmA=="
+ },
+ "@ungap/promise-all-settled": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
+ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==",
+ "dev": true
+ },
+ "accepts": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
+ "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
+ "requires": {
+ "mime-types": "~2.1.24",
+ "negotiator": "0.6.2"
+ }
+ },
+ "acorn": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz",
+ "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==",
+ "dev": true
+ },
+ "acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true
+ },
+ "ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-colors": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+ "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+ "dev": true
+ },
+ "ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "requires": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+ "dev": true
+ },
+ "awaitqueue": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-1.0.1.tgz",
+ "integrity": "sha512-v5XFR8slds87u7WkjWUtuMXUBEaqfg1WJ8yxrUDbo8aodUQLEVag0MZYBptRP/TmMGOYn0YHS6ar8aqVCJVynA=="
+ },
+ "axios": {
+ "version": "0.21.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
+ "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
+ "requires": {
+ "follow-redirects": "^1.14.0"
+ }
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "base64-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA=="
+ },
+ "base64id": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
+ },
+ "binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true
+ },
+ "bintrees": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz",
+ "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ="
+ },
+ "body-parser": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
+ "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
+ "requires": {
+ "bytes": "3.1.0",
+ "content-type": "~1.0.4",
+ "debug": "2.6.9",
+ "depd": "~1.1.2",
+ "http-errors": "1.7.2",
+ "iconv-lite": "0.4.24",
+ "on-finished": "~2.3.0",
+ "qs": "6.7.0",
+ "raw-body": "2.4.0",
+ "type-is": "~1.6.17"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
+ "bowser": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.9.0.tgz",
+ "integrity": "sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA=="
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "browser-stdout": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+ "dev": true
+ },
+ "bytes": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+ "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
+ },
+ "call-bind": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+ "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.0.2"
+ }
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true
+ },
+ "camelcase": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz",
+ "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==",
+ "dev": true
+ },
+ "camelize": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz",
+ "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs="
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "child_process": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz",
+ "integrity": "sha1-sffn/HPSXn/R1FWtyU4UODAYK1o=",
+ "dev": true
+ },
+ "chokidar": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+ "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "fsevents": "~2.3.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ }
+ },
+ "cliui": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+ "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+ "dev": true,
+ "requires": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "dependencies": {
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ }
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "colors": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+ "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "component-emitter": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
+ },
+ "compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "requires": {
+ "mime-db": ">= 1.43.0 < 2"
+ }
+ },
+ "compression": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
+ "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+ "requires": {
+ "accepts": "~1.3.5",
+ "bytes": "3.0.0",
+ "compressible": "~2.0.16",
+ "debug": "2.6.9",
+ "on-headers": "~1.0.2",
+ "safe-buffer": "5.1.2",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg="
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+ "dev": true
+ },
+ "content-disposition": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
+ "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
+ "requires": {
+ "safe-buffer": "5.1.2"
+ }
+ },
+ "content-security-policy-builder": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.0.tgz",
+ "integrity": "sha512-/MtLWhJVvJNkA9dVLAp6fg9LxD2gfI6R2Fi1hPmfjYXSahJJzcfvoeDOxSyp4NvxMuwWv3WMssE9o31DoULHrQ=="
+ },
+ "content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
+ },
+ "cookie": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
+ "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+ },
+ "cookiejar": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz",
+ "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
+ "dev": true
+ },
+ "core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+ },
+ "cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "requires": {
+ "object-assign": "^4",
+ "vary": "^1"
+ }
+ },
+ "cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ }
+ },
+ "dasherize": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dasherize/-/dasherize-2.0.0.tgz",
+ "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg="
+ },
+ "debug": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+ "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+ "requires": {
+ "ms": "2.1.2"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ }
+ }
+ },
+ "decamelize": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+ "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+ "dev": true
+ },
+ "deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+ "dev": true
+ },
+ "depd": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+ "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
+ },
+ "destroy": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
+ "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
+ },
+ "detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="
+ },
+ "dgram": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz",
+ "integrity": "sha1-N/OyAPgDOl/3WTAwicgc42G2UcM=",
+ "dev": true
+ },
+ "diff": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+ "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
+ "dev": true
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "dont-sniff-mimetype": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz",
+ "integrity": "sha512-ZjI4zqTaxveH2/tTlzS1wFp+7ncxNZaIEWYg3lzZRHkKf5zPT/MnEG6WL0BhHMJUabkh8GeU5NL5j+rEUCb7Ug=="
+ },
+ "ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
+ },
+ "engine.io": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.0.0.tgz",
+ "integrity": "sha512-Ui7yl3JajEIaACg8MOUwWvuuwU7jepZqX3BKs1ho7NQRuP4LhN4XIykXhp8bEy+x/DhA0LBZZXYSCkZDqrwMMg==",
+ "requires": {
+ "@types/cookie": "^0.4.1",
+ "@types/cors": "^2.8.12",
+ "@types/node": ">=10.0.0",
+ "accepts": "~1.3.4",
+ "base64id": "2.0.0",
+ "cookie": "~0.4.1",
+ "cors": "~2.8.5",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.0.0",
+ "ws": "~8.2.3"
+ },
+ "dependencies": {
+ "cookie": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
+ "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
+ }
+ }
+ },
+ "engine.io-parser": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.1.tgz",
+ "integrity": "sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==",
+ "requires": {
+ "base64-arraybuffer": "~1.0.1"
+ }
+ },
+ "enquirer": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+ "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+ "dev": true,
+ "requires": {
+ "ansi-colors": "^4.1.1"
+ }
+ },
+ "escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "dev": true
+ },
+ "escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+ },
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true
+ },
+ "eslint": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.0.1.tgz",
+ "integrity": "sha512-LsgcwZgQ72vZ+SMp4K6pAnk2yFDWL7Ti4pJaRvsZ0Hsw2h8ZjUIW38a9AFn2cZXdBMlScMFYYgsSp4ttFI/0bA==",
+ "dev": true,
+ "requires": {
+ "@eslint/eslintrc": "^1.0.3",
+ "@humanwhocodes/config-array": "^0.6.0",
+ "ajv": "^6.10.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "enquirer": "^2.3.5",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^6.0.0",
+ "eslint-utils": "^3.0.0",
+ "eslint-visitor-keys": "^3.0.0",
+ "espree": "^9.0.0",
+ "esquery": "^1.4.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "functional-red-black-tree": "^1.0.1",
+ "glob-parent": "^6.0.1",
+ "globals": "^13.6.0",
+ "ignore": "^4.0.6",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.0.4",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.1",
+ "progress": "^2.0.0",
+ "regexpp": "^3.2.0",
+ "semver": "^7.2.1",
+ "strip-ansi": "^6.0.0",
+ "strip-json-comments": "^3.1.0",
+ "text-table": "^0.2.0",
+ "v8-compile-cache": "^2.0.3"
+ },
+ "dependencies": {
+ "glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.3"
+ }
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-6.0.0.tgz",
+ "integrity": "sha512-uRDL9MWmQCkaFus8RF5K9/L/2fn+80yoW3jkD53l4shjCh26fCtvJGasxjUqP5OT87SYTxCVA3BwTUzuELx9kA==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ }
+ },
+ "eslint-utils": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+ "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+ "dev": true,
+ "requires": {
+ "eslint-visitor-keys": "^2.0.0"
+ },
+ "dependencies": {
+ "eslint-visitor-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+ "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-visitor-keys": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.0.0.tgz",
+ "integrity": "sha512-mJOZa35trBTb3IyRmo8xmKBZlxf+N7OnUl4+ZhJHs/r+0770Wh/LEACE2pqMGMe27G/4y8P2bYGk4J70IC5k1Q==",
+ "dev": true
+ },
+ "espree": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.0.0.tgz",
+ "integrity": "sha512-r5EQJcYZ2oaGbeR0jR0fFVijGOcwai07/690YRXLINuhmVeRY4UKSAsQPe/0BNuDgwP7Ophoc1PRsr2E3tkbdQ==",
+ "dev": true,
+ "requires": {
+ "acorn": "^8.5.0",
+ "acorn-jsx": "^5.3.1",
+ "eslint-visitor-keys": "^3.0.0"
+ }
+ },
+ "esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true
+ },
+ "esquery": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+ "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.1.0"
+ }
+ },
+ "esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.2.0"
+ }
+ },
+ "estraverse": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+ "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+ "dev": true
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true
+ },
+ "etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
+ },
+ "event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "dev": true
+ },
+ "events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true
+ },
+ "express": {
+ "version": "4.17.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
+ "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
+ "requires": {
+ "accepts": "~1.3.7",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.19.0",
+ "content-disposition": "0.5.3",
+ "content-type": "~1.0.4",
+ "cookie": "0.4.0",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "~1.1.2",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.1.2",
+ "fresh": "0.5.2",
+ "merge-descriptors": "1.0.1",
+ "methods": "~1.1.2",
+ "on-finished": "~2.3.0",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "~2.0.5",
+ "qs": "6.7.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.1.2",
+ "send": "0.17.1",
+ "serve-static": "1.14.1",
+ "setprototypeof": "1.1.1",
+ "statuses": "~1.5.0",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
+ "fake-mediastreamtrack": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-1.1.6.tgz",
+ "integrity": "sha512-lcoO5oPsW57istAsnjvQxNjBEahi18OdUhWfmEewwfPfzNZnji5OXuodQM+VnUPi/1HnQRJ6gBUjbt1TNXrkjQ==",
+ "dev": true,
+ "requires": {
+ "event-target-shim": "^5.0.1",
+ "uuid": "^8.1.0"
+ },
+ "dependencies": {
+ "uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "dev": true
+ }
+ }
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+ "dev": true
+ },
+ "fast-safe-stringify": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+ "dev": true
+ },
+ "feature-policy": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/feature-policy/-/feature-policy-0.3.0.tgz",
+ "integrity": "sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ=="
+ },
+ "file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "requires": {
+ "flat-cache": "^3.0.4"
+ }
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "finalhandler": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+ "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
+ "requires": {
+ "debug": "2.6.9",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.3.0",
+ "parseurl": "~1.3.3",
+ "statuses": "~1.5.0",
+ "unpipe": "~1.0.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
+ "find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "flat": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+ "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+ "dev": true
+ },
+ "flat-cache": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+ "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+ "dev": true,
+ "requires": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ }
+ },
+ "flatted": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz",
+ "integrity": "sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==",
+ "dev": true
+ },
+ "follow-redirects": {
+ "version": "1.14.4",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
+ "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
+ },
+ "form-data": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+ "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+ "dev": true,
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "formidable": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz",
+ "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==",
+ "dev": true
+ },
+ "forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
+ },
+ "fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+ "dev": true
+ },
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "functional-red-black-tree": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+ "dev": true
+ },
+ "get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true
+ },
+ "get-intrinsic": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+ "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.1"
+ }
+ },
+ "glob": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+ "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "globals": {
+ "version": "13.11.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.11.0.tgz",
+ "integrity": "sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.20.2"
+ }
+ },
+ "growl": {
+ "version": "1.10.5",
+ "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
+ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==",
+ "dev": true
+ },
+ "h264-profile-level-id": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-1.0.1.tgz",
+ "integrity": "sha512-D3Rln/jKNjKDW5ZTJTK3niSoOGE+pFqPvRHHVgQN3G7umcn/zWGPUo8Q8VpDj16x3hKz++zVviRNRmXu5cpN+Q==",
+ "requires": {
+ "debug": "^4.1.1"
+ }
+ },
+ "handle-thing": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
+ "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg=="
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "has-flag": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz",
+ "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="
+ },
+ "has-symbols": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+ "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+ "dev": true
+ },
+ "he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true
+ },
+ "helmet": {
+ "version": "3.23.3",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-3.23.3.tgz",
+ "integrity": "sha512-U3MeYdzPJQhtvqAVBPntVgAvNSOJyagwZwyKsFdyRa8TV3pOKVFljalPOCxbw5Wwf2kncGhmP0qHjyazIdNdSA==",
+ "requires": {
+ "depd": "2.0.0",
+ "dont-sniff-mimetype": "1.1.0",
+ "feature-policy": "0.3.0",
+ "helmet-crossdomain": "0.4.0",
+ "helmet-csp": "2.10.0",
+ "hide-powered-by": "1.1.0",
+ "hpkp": "2.0.0",
+ "hsts": "2.2.0",
+ "nocache": "2.1.0",
+ "referrer-policy": "1.2.0",
+ "x-xss-protection": "1.3.0"
+ },
+ "dependencies": {
+ "depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
+ }
+ }
+ },
+ "helmet-crossdomain": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz",
+ "integrity": "sha512-AB4DTykRw3HCOxovD1nPR16hllrVImeFp5VBV9/twj66lJ2nU75DP8FPL0/Jp4jj79JhTfG+pFI2MD02kWJ+fA=="
+ },
+ "helmet-csp": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/helmet-csp/-/helmet-csp-2.10.0.tgz",
+ "integrity": "sha512-Rz953ZNEFk8sT2XvewXkYN0Ho4GEZdjAZy4stjiEQV3eN7GDxg1QKmYggH7otDyIA7uGA6XnUMVSgeJwbR5X+w==",
+ "requires": {
+ "bowser": "2.9.0",
+ "camelize": "1.0.0",
+ "content-security-policy-builder": "2.1.0",
+ "dasherize": "2.0.0"
+ }
+ },
+ "hide-powered-by": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.1.0.tgz",
+ "integrity": "sha512-Io1zA2yOA1YJslkr+AJlWSf2yWFkKjvkcL9Ni1XSUqnGLr/qRQe2UI3Cn/J9MsJht7yEVCe0SscY1HgVMujbgg=="
+ },
+ "hpack.js": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
+ "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=",
+ "requires": {
+ "inherits": "^2.0.1",
+ "obuf": "^1.0.0",
+ "readable-stream": "^2.0.1",
+ "wbuf": "^1.1.0"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ }
+ }
+ },
+ "hpkp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hpkp/-/hpkp-2.0.0.tgz",
+ "integrity": "sha1-EOFCJk52IVpdMMROxD3mTe5tFnI="
+ },
+ "hsts": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hsts/-/hsts-2.2.0.tgz",
+ "integrity": "sha512-ToaTnQ2TbJkochoVcdXYm4HOCliNozlviNsg+X2XQLQvZNI/kCHR9rZxVYpJB3UPcHz80PgxRyWQ7PdU1r+VBQ==",
+ "requires": {
+ "depd": "2.0.0"
+ },
+ "dependencies": {
+ "depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
+ }
+ }
+ },
+ "http-deceiver": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
+ "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc="
+ },
+ "http-errors": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
+ "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
+ "requires": {
+ "depd": "~1.1.2",
+ "inherits": "2.0.3",
+ "setprototypeof": "1.1.1",
+ "statuses": ">= 1.5.0 < 2",
+ "toidentifier": "1.0.0"
+ }
+ },
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ },
+ "ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true
+ },
+ "import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ }
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ },
+ "ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+ "dev": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "dev": true
+ },
+ "is-unicode-supported": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "requires": {
+ "argparse": "^2.0.1"
+ },
+ "dependencies": {
+ "argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ }
+ }
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+ "dev": true
+ },
+ "levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ }
+ },
+ "locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^5.0.0"
+ }
+ },
+ "lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "log-symbols": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+ "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+ "dev": true,
+ "requires": {
+ "chalk": "^4.1.0",
+ "is-unicode-supported": "^0.1.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+ },
+ "mediasoup": {
+ "version": "3.8.4",
+ "resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.8.4.tgz",
+ "integrity": "sha512-l6rQwjcWN5VJ85yg8darZyPjSQVlE1GqrR1mIjUVa0F8MX6jOxsscXNzFisxTly4ngB2DTjnI8HEabmD5a5Vog==",
+ "requires": {
+ "@types/node": "^16.9.1",
+ "awaitqueue": "^2.3.3",
+ "debug": "^4.3.2",
+ "h264-profile-level-id": "^1.0.1",
+ "netstring": "^0.3.0",
+ "random-number": "^0.0.9",
+ "supports-color": "^9.0.2",
+ "uuid": "^8.3.2"
+ },
+ "dependencies": {
+ "awaitqueue": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-2.3.3.tgz",
+ "integrity": "sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw=="
+ },
+ "uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+ }
+ }
+ },
+ "mediasoup-client": {
+ "version": "3.6.43",
+ "resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.6.43.tgz",
+ "integrity": "sha512-fieD9iLlLpyKMaW7EdE8RjOLonQYUoKHwDKHecwCZLXmP2RIwizTJN6NLXCOyappWX7rJNk95BgbLbJSxT28Xw==",
+ "dev": true,
+ "requires": {
+ "@types/debug": "^4.1.7",
+ "@types/events": "^3.0.0",
+ "awaitqueue": "^2.3.3",
+ "bowser": "^2.11.0",
+ "debug": "^4.3.2",
+ "events": "^3.3.0",
+ "fake-mediastreamtrack": "^1.1.6",
+ "h264-profile-level-id": "^1.0.1",
+ "sdp-transform": "^2.14.1",
+ "supports-color": "^8.1.1"
+ },
+ "dependencies": {
+ "awaitqueue": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-2.3.3.tgz",
+ "integrity": "sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw==",
+ "dev": true
+ },
+ "bowser": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
+ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+ },
+ "methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
+ },
+ "mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
+ },
+ "mime-db": {
+ "version": "1.50.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz",
+ "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A=="
+ },
+ "mime-types": {
+ "version": "2.1.33",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.33.tgz",
+ "integrity": "sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g==",
+ "requires": {
+ "mime-db": "1.50.0"
+ }
+ },
+ "minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "mocha": {
+ "version": "9.1.3",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.1.3.tgz",
+ "integrity": "sha512-Xcpl9FqXOAYqI3j79pEtHBBnQgVXIhpULjGQa7DVb0Po+VzmSIK9kanAiWLHoRR/dbZ2qpdPshuXr8l1VaHCzw==",
+ "dev": true,
+ "requires": {
+ "@ungap/promise-all-settled": "1.1.2",
+ "ansi-colors": "4.1.1",
+ "browser-stdout": "1.3.1",
+ "chokidar": "3.5.2",
+ "debug": "4.3.2",
+ "diff": "5.0.0",
+ "escape-string-regexp": "4.0.0",
+ "find-up": "5.0.0",
+ "glob": "7.1.7",
+ "growl": "1.10.5",
+ "he": "1.2.0",
+ "js-yaml": "4.1.0",
+ "log-symbols": "4.1.0",
+ "minimatch": "3.0.4",
+ "ms": "2.1.3",
+ "nanoid": "3.1.25",
+ "serialize-javascript": "6.0.0",
+ "strip-json-comments": "3.1.1",
+ "supports-color": "8.1.1",
+ "which": "2.0.2",
+ "workerpool": "6.1.5",
+ "yargs": "16.2.0",
+ "yargs-parser": "20.2.4",
+ "yargs-unparser": "2.0.0"
+ },
+ "dependencies": {
+ "argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true
+ },
+ "glob": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+ "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "requires": {
+ "argparse": "^2.0.1"
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ },
+ "nanoid": {
+ "version": "3.1.25",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz",
+ "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==",
+ "dev": true
+ },
+ "natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+ "dev": true
+ },
+ "negotiator": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+ "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
+ },
+ "netstring": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/netstring/-/netstring-0.3.0.tgz",
+ "integrity": "sha1-ho3FsgxY0/cwVTHUk2jqqr0ZtxI="
+ },
+ "nocache": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz",
+ "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q=="
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
+ "object-inspect": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz",
+ "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==",
+ "dev": true
+ },
+ "obuf": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
+ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
+ },
+ "on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+ "requires": {
+ "ee-first": "1.1.1"
+ }
+ },
+ "on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "optionator": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+ "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+ "dev": true,
+ "requires": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.3"
+ }
+ },
+ "p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "requires": {
+ "yocto-queue": "^0.1.0"
+ }
+ },
+ "p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^3.0.2"
+ }
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
+ },
+ "path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+ "dev": true
+ },
+ "path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true
+ },
+ "path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+ },
+ "picomatch": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
+ "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==",
+ "dev": true
+ },
+ "pidusage": {
+ "version": "2.0.21",
+ "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz",
+ "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==",
+ "requires": {
+ "safe-buffer": "^5.2.1"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
+ }
+ }
+ },
+ "prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true
+ },
+ "prom-client": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.0.0.tgz",
+ "integrity": "sha512-etPa4SMO4j6qTn2uaSZy7+uahGK0kXUZwO7WhoDpTf3yZ837I3jqUDYmG6N0caxuU6cyqrg0xmOxh+yneczvyA==",
+ "requires": {
+ "tdigest": "^0.1.1"
+ }
+ },
+ "proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "requires": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ }
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.7.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+ "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
+ },
+ "random-number": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/random-number/-/random-number-0.0.9.tgz",
+ "integrity": "sha512-ipG3kRCREi/YQpi2A5QGcvDz1KemohovWmH6qGfboVyyGdR2t/7zQz0vFxrfxpbHQgPPdtVlUDaks3aikD1Ljw=="
+ },
+ "randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
+ },
+ "raw-body": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
+ "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
+ "requires": {
+ "bytes": "3.1.0",
+ "http-errors": "1.7.2",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ }
+ },
+ "readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ },
+ "readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "referrer-policy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz",
+ "integrity": "sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA=="
+ },
+ "regexpp": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+ "dev": true
+ },
+ "require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+ "dev": true
+ },
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "sdp-transform": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz",
+ "integrity": "sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==",
+ "dev": true
+ },
+ "select-hose": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
+ "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo="
+ },
+ "semver": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+ "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ },
+ "send": {
+ "version": "0.17.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
+ "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
+ "requires": {
+ "debug": "2.6.9",
+ "depd": "~1.1.2",
+ "destroy": "~1.0.4",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "~1.7.2",
+ "mime": "1.6.0",
+ "ms": "2.1.1",
+ "on-finished": "~2.3.0",
+ "range-parser": "~1.2.1",
+ "statuses": "~1.5.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ }
+ }
+ },
+ "ms": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
+ }
+ }
+ },
+ "serialize-javascript": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+ "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "serve-static": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
+ "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
+ "requires": {
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.17.1"
+ }
+ },
+ "setprototypeof": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
+ "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
+ },
+ "shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^3.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true
+ },
+ "side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ }
+ },
+ "socket.io": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.3.1.tgz",
+ "integrity": "sha512-HC5w5Olv2XZ0XJ4gOLGzzHEuOCfj3G0SmoW3jLHYYh34EVsIr3EkW9h6kgfW+K3TFEcmYy8JcPWe//KUkBp5jA==",
+ "requires": {
+ "accepts": "~1.3.4",
+ "base64id": "~2.0.0",
+ "debug": "~4.3.2",
+ "engine.io": "~6.0.0",
+ "socket.io-adapter": "~2.3.2",
+ "socket.io-parser": "~4.0.4"
+ }
+ },
+ "socket.io-adapter": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.3.2.tgz",
+ "integrity": "sha512-PBZpxUPYjmoogY0aoaTmo1643JelsaS1CiAwNjRVdrI0X9Seuc19Y2Wife8k88avW6haG8cznvwbubAZwH4Mtg=="
+ },
+ "socket.io-parser": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz",
+ "integrity": "sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==",
+ "requires": {
+ "@types/component-emitter": "^1.2.10",
+ "component-emitter": "~1.3.0",
+ "debug": "~4.3.1"
+ }
+ },
+ "spdy": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz",
+ "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==",
+ "requires": {
+ "debug": "^4.1.0",
+ "handle-thing": "^2.0.0",
+ "http-deceiver": "^1.2.7",
+ "select-hose": "^2.0.0",
+ "spdy-transport": "^3.0.0"
+ }
+ },
+ "spdy-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz",
+ "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==",
+ "requires": {
+ "debug": "^4.1.0",
+ "detect-node": "^2.0.4",
+ "hpack.js": "^2.1.6",
+ "obuf": "^1.1.2",
+ "readable-stream": "^3.0.6",
+ "wbuf": "^1.7.3"
+ }
+ },
+ "sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+ "dev": true
+ },
+ "statuses": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+ "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
+ },
+ "string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "dependencies": {
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ }
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true
+ },
+ "superagent": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz",
+ "integrity": "sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg==",
+ "dev": true,
+ "requires": {
+ "component-emitter": "^1.3.0",
+ "cookiejar": "^2.1.2",
+ "debug": "^4.1.1",
+ "fast-safe-stringify": "^2.0.7",
+ "form-data": "^3.0.0",
+ "formidable": "^1.2.2",
+ "methods": "^1.1.2",
+ "mime": "^2.4.6",
+ "qs": "^6.9.4",
+ "readable-stream": "^3.6.0",
+ "semver": "^7.3.2"
+ },
+ "dependencies": {
+ "mime": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
+ "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==",
+ "dev": true
+ },
+ "qs": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
+ "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ },
+ "semver": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+ "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ }
+ }
+ },
+ "supertest": {
+ "version": "6.1.6",
+ "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.1.6.tgz",
+ "integrity": "sha512-0hACYGNJ8OHRg8CRITeZOdbjur7NLuNs0mBjVhdpxi7hP6t3QIbOzLON5RTUmZcy2I9riuII3+Pr2C7yztrIIg==",
+ "dev": true,
+ "requires": {
+ "methods": "^1.1.2",
+ "superagent": "^6.1.0"
+ }
+ },
+ "supports-color": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.0.2.tgz",
+ "integrity": "sha512-ii6tc8ImGFrgMPYq7RVAMKkhPo9vk8uA+D3oKbJq/3Pk2YSMv1+9dUAesa9UxMbxBTvxwKTQffBahNVNxEvM8Q==",
+ "requires": {
+ "has-flag": "^5.0.0"
+ }
+ },
+ "tdigest": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz",
+ "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=",
+ "requires": {
+ "bintrees": "1.0.1"
+ }
+ },
+ "text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ },
+ "toidentifier": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+ "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
+ },
+ "type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1"
+ }
+ },
+ "type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true
+ },
+ "type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "requires": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ }
+ },
+ "unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
+ },
+ "uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
+ },
+ "uuid": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
+ "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="
+ },
+ "v8-compile-cache": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+ "dev": true
+ },
+ "vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
+ },
+ "wbuf": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz",
+ "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==",
+ "requires": {
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "word-wrap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "dev": true
+ },
+ "workerpool": {
+ "version": "6.1.5",
+ "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.5.tgz",
+ "integrity": "sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw==",
+ "dev": true
+ },
+ "wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ }
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+ "dev": true
+ },
+ "ws": {
+ "version": "8.2.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
+ "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA=="
+ },
+ "x-xss-protection": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/x-xss-protection/-/x-xss-protection-1.3.0.tgz",
+ "integrity": "sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg=="
+ },
+ "y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "yargs": {
+ "version": "16.2.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+ "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+ "dev": true,
+ "requires": {
+ "cliui": "^7.0.2",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^20.2.2"
+ }
+ },
+ "yargs-parser": {
+ "version": "20.2.4",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+ "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+ "dev": true
+ },
+ "yargs-unparser": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+ "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+ "dev": true,
+ "requires": {
+ "camelcase": "^6.0.0",
+ "decamelize": "^4.0.0",
+ "flat": "^5.0.2",
+ "is-plain-obj": "^2.1.0"
+ }
+ },
+ "yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true
+ }
+ }
+}
diff --git a/meet/server/package.json b/meet/server/package.json
new file mode 100644
--- /dev/null
+++ b/meet/server/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "kolabmeet-server",
+ "private": true,
+ "license": "MIT",
+ "scripts": {
+ "start": "node server.js",
+ "connect": "node connect.js",
+ "lint": "eslint -c .eslintrc.json --ext .js *.js lib/",
+ "lint-fix": "eslint --fix -c .eslintrc.json --ext .js *.js lib/",
+ "test": "mocha --bail --inline-diffs --async-stack-traces --full-trace --exit test/test.js",
+ "performancetestbench": "mocha -b -t 0 test/performancetestbench.js",
+ "videoproducer": "node test/videoproducer.js"
+ },
+ "dependencies": {
+ "awaitqueue": "^1.0.0",
+ "axios": "^0.21.1",
+ "body-parser": "^1.19.0",
+ "colors": "^1.4.0",
+ "compression": "^1.7.4",
+ "debug": "^4.1.1",
+ "express": "^4.17.1",
+ "helmet": "^3.21.2",
+ "mediasoup": "~3.9.2",
+ "pidusage": "^2.0.17",
+ "prom-client": ">=12.0.0",
+ "socket.io": "~4.3.1",
+ "spdy": "^4.0.1",
+ "uuid": "^7.0.2"
+ },
+ "devDependencies": {
+ "child_process": "^1.0.2",
+ "eslint": "^8.0.1",
+ "mediasoup-client": "^3.6.37",
+ "mocha": "^9.1.1",
+ "supertest": "^6.1.6",
+ "socket.io-client": "^4.3.2",
+ "superagent": "^6.1.0"
+ }
+}
diff --git a/meet/server/server.js b/meet/server/server.js
new file mode 100755
--- /dev/null
+++ b/meet/server/server.js
@@ -0,0 +1,421 @@
+#!/usr/bin/env node
+
+process.title = 'kolabmeet-server';
+
+const config = require('./config/config');
+const fs = require('fs');
+const http = require('http');
+const spdy = require('spdy');
+const express = require('express');
+const bodyParser = require('body-parser');
+const compression = require('compression');
+const mediasoup = require('mediasoup');
+const Logger = require('./lib/Logger');
+const Room = require('./lib/Room');
+const Peer = require('./lib/Peer');
+const helmet = require('helmet');
+const axios = require('axios');
+const interactiveServer = require('./lib/interactiveServer');
+const { v4: uuidv4 } = require('uuid');
+
+/* eslint-disable no-console */
+console.log('- process.env.DEBUG:', process.env.DEBUG);
+console.log('- config.mediasoup.worker.logLevel:', config.mediasoup.worker.logLevel);
+console.log('- config.mediasoup.worker.logTags:', config.mediasoup.worker.logTags);
+/* eslint-enable no-console */
+
+
+if (!config.mediasoup.webRtcTransport.listenIps[0].ip) {
+ console.error('A webrtc listen ip is reuquired');
+ process.exit(3)
+}
+
+const logger = new Logger();
+
+// mediasoup Workers.
+// @type {Array<mediasoup.Worker>}
+const mediasoupWorkers = [];
+
+// Map of Room instances indexed by roomId.
+const rooms = new Map();
+
+// Map of Peer instances indexed by peerId.
+const peers = new Map();
+
+// HTTP client instance for webhook "pushes"
+let webhook = null;
+if (config.webhookURL) {
+ webhook = axios.create({
+ baseURL: config.webhookURL,
+ headers: { 'X-Auth-Token': config.webhookToken },
+ timeout: 5000
+ });
+}
+
+const app = express();
+
+app.use(helmet.hsts());
+
+app.use((req, res, next) => {
+ if (req.get('X-Auth-Token') !== config.authToken) {
+ logger.debug("X-Auth-Token mismatch")
+ res.status(403).send();
+ } else {
+ next();
+ }
+});
+
+app.use(bodyParser.json({ limit: '5mb' }));
+app.use(bodyParser.urlencoded({ limit: '5mb', extended: true }));
+
+let mainListener;
+let io;
+
+async function run() {
+ try {
+ await interactiveServer(rooms, peers);
+ await runMediasoupWorkers();
+ await runHttpsServer();
+ await runWebSocketServer();
+
+ // eslint-disable-next-line no-unused-vars
+ const errorHandler = (err, req, res, next) => {
+ const trackingId = uuidv4();
+
+ res.status(500).send(
+ `<h1>Internal Server Error</h1>
+ <p>If you report this error, please also report this
+ <i>tracking ID</i> which makes it possible to locate your session
+ in the logs which are available to the system administrator:
+ <b>${trackingId}</b></p>`
+ );
+ logger.error(
+ 'Express error handler dump with tracking ID: %s, error dump: %o',
+ trackingId, err);
+ };
+
+ app.use(errorHandler);
+ } catch (error) {
+ logger.error('run() [error:"%o"]', error);
+ }
+
+ app.emit('ready');
+}
+
+async function runHttpsServer() {
+ app.use(compression());
+
+ app.get(`${config.pathPrefix}/api/stats`, async function (req, res) {
+ let stats = {};
+ for (const room of rooms) {
+ let roomStats;
+ for (const peer of Object.values(room._peers)) {
+ let peerStats = {
+ id: peer.id,
+ nickname: peer._nickname,
+ consumers: [],
+ producers: [],
+ transports: [],
+ };
+ for (const entry of peer._consumers.values()) {
+ peerStats.consumers.push(await entry.getStats())
+ }
+ for (const entry of peer._producers.values()) {
+ peerStats.producers.push(await entry.getStats())
+ }
+ for (const entry of peer._transports.values()) {
+ peerStats.transports.push(await entry.getStats())
+ }
+ roomStats[peer.id] = peerStats;
+ }
+ stats[room.id] = roomStats;
+ }
+ res.send(stats);
+ });
+
+ app.get(`${config.pathPrefix}/api/health`, function (req, res) {
+ res.send({ success: true, message: "Healthy" });
+ });
+
+ app.get(`${config.pathPrefix}/api/ping`, function (req, res) {
+ res.send('PONG');
+ })
+
+ app.get(`${config.pathPrefix}/api/sessions`, function (req, res) {
+ let list = [];
+ rooms.forEach(room => {
+ list.push({
+ roomId: room.id,
+ createdAt: room.createdAt
+ })
+ })
+
+ res.json(list)
+ })
+
+ // Check if the room exists
+ app.get(`${config.pathPrefix}/api/sessions/:session_id`, function (req, res) {
+ const room = rooms.get(req.params.session_id);
+
+ if (!room) {
+ res.status(404).send();
+ } else {
+ res.status(200).send();
+ }
+ })
+
+ // Create room and return id
+ app.post(`${config.pathPrefix}/api/sessions`, async function (req, res) {
+ console.log("Creating new room");
+
+ const room = await createRoom();
+
+ res.json({
+ id : room.id
+ })
+ })
+
+ // Send a websocket notification signals to the room participants
+ app.post(`${config.pathPrefix}/api/signal`, async function (req, res) {
+ const data = req.body;
+ const roomId = data.roomId;
+ const emit = (socket) => {
+ socket.emit('notification', {
+ method: `signal:${data.type}`,
+ data: data.data
+ })
+ };
+
+ if ('role' in data) {
+ peers.forEach(peer => {
+ if (peer.socket && peer.roomId == roomId && peer.hasRole(data.role)) {
+ emit(peer.socket);
+ }
+ })
+ } else {
+ emit(io.to(roomId));
+ }
+
+ res.json({});
+ });
+
+ // Create connection in room (just wait for websocket instead?
+ // $post = [
+ // 'json' => [
+ // 'role' => self::OV_ROLE_PUBLISHER,
+ // 'data' => json_encode(['role' => $role])
+ // ]
+ // ];
+ app.post(`${config.pathPrefix}/api/sessions/:session_id/connection`, function (req, res) {
+ logger.info('Creating peer connection [roomId:"%s"]', req.params.session_id);
+
+ const roomId = req.params.session_id;
+ const room = rooms.get(roomId);
+
+ if (!room) {
+ res.status(404).send();
+ return;
+ }
+
+ const peer = new Peer({ roomId });
+
+ peers.set(peer.id, peer);
+
+ peer.on('close', () => {
+ peers.delete(peer.id);
+ });
+
+ const data = req.body;
+
+ if ('role' in data)
+ peer.setRole(data.role);
+
+ const proto = config.publicDomain.includes('localhost') || config.publicDomain.includes('127.0.0.1') ? 'ws' : 'wss';
+
+ res.json({
+ id: peer.id,
+ // Note: socket.io client will end up using (hardcoded) /meetmedia/signaling path
+ token: `${proto}://${config.publicDomain}?peerId=${peer.id}&roomId=${roomId}&authToken=${peer.authToken}`
+ });
+ })
+
+ if (config.tls) {
+ // TLS server configuration.
+ const tls = {
+ cert: fs.readFileSync(config.tls.cert),
+ key: fs.readFileSync(config.tls.key),
+ secureOptions: 'tlsv12',
+ ciphers: [
+ 'ECDHE-ECDSA-AES128-GCM-SHA256',
+ 'ECDHE-RSA-AES128-GCM-SHA256',
+ 'ECDHE-ECDSA-AES256-GCM-SHA384',
+ 'ECDHE-RSA-AES256-GCM-SHA384',
+ 'ECDHE-ECDSA-CHACHA20-POLY1305',
+ 'ECDHE-RSA-CHACHA20-POLY1305',
+ 'DHE-RSA-AES128-GCM-SHA256',
+ 'DHE-RSA-AES256-GCM-SHA384'
+ ].join(':'),
+ honorCipherOrder: true
+ };
+
+ mainListener = spdy.createServer(tls, app);
+ } else {
+ mainListener = http.createServer(app);
+ }
+ console.info(`Listening on ${config.listeningPort} ${config.listeningHost}`)
+ mainListener.listen(config.listeningPort, config.listeningHost);
+}
+
+/**
+ * Create a WebSocketServer to allow WebSocket connections from browsers.
+ */
+async function runWebSocketServer() {
+ io = require('socket.io')(mainListener, {
+ path: `${config.pathPrefix}/signaling`,
+ cookie: false
+ });
+
+ // Handle connections from clients.
+ io.on('connection', async (socket) => {
+ const { roomId, peerId, authToken } = socket.handshake.query;
+
+ if (!roomId || !peerId || !authToken) {
+ logger.warn('connection request without roomId and/or peerId');
+ socket.disconnect(true);
+ return;
+ }
+
+ logger.info('connection request [roomId:"%s", peerId:"%s"]', roomId, peerId);
+
+ try {
+ const room = rooms.get(roomId);
+
+ if (!room) {
+ logger.warn("Room does not exist %s", roomId);
+ socket.disconnect(true);
+ return;
+ }
+
+ const peer = peers.get(peerId);
+
+ if (!peer || peer.roomId != roomId || peer.authToken != authToken) {
+ logger.warn("Peer does not exist %s", peerId);
+ socket.disconnect(true);
+ return;
+ }
+
+ await room.joinRoom({ peer, socket });
+ } catch (error) {
+ logger.error('room creation or room joining failed [error:"%o"]', error);
+
+ if (socket)
+ socket.disconnect(true);
+ }
+ });
+}
+
+/**
+ * Launch as many mediasoup Workers as given in the configuration file.
+ */
+async function runMediasoupWorkers() {
+ const { numWorkers } = config.mediasoup;
+
+ logger.info('running %d mediasoup Workers...', numWorkers);
+
+ for (let i = 0; i < numWorkers; ++i) {
+ const worker = await mediasoup.createWorker(
+ {
+ logLevel : config.mediasoup.worker.logLevel,
+ logTags : config.mediasoup.worker.logTags,
+ rtcMinPort : config.mediasoup.worker.rtcMinPort,
+ rtcMaxPort : config.mediasoup.worker.rtcMaxPort
+ });
+
+ worker.on('died', () => {
+ logger.error(
+ 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid);
+
+ setTimeout(() => process.exit(1), 2000);
+ });
+
+ mediasoupWorkers.push(worker);
+ }
+}
+
+async function getLeastLoadedWorker() {
+ let workerLoads = new Map();
+ for (const worker of mediasoupWorkers) {
+ workerLoads.set(worker.pid, 0);
+ }
+
+ for (const peer of peers.values()) {
+ if (peer.workerId) {
+ const workerId = peer.workerId;
+ workerLoads.set(workerId, workerLoads.get(workerId) + 1);
+ }
+ }
+
+ const sortedWorkerLoads = new Map([ ...workerLoads.entries() ].sort(
+ (a, b) => a[1] - b[1]));
+
+ const workerId = sortedWorkerLoads.keys().next().value;
+ return mediasoupWorkers.find((worker) => worker.pid == workerId)
+}
+
+/**
+ * Get a Room instance (or create one if it does not exist).
+ */
+async function createRoom() {
+ logger.info('creating a new Room');
+
+ // Create the room
+ const room = await Room.create({ getLeastLoadedWorker });
+
+ room.on('close', () => {
+ logger.info('closing a Room [roomId:"%s"]', room.id);
+
+ rooms.delete(room.id);
+
+ if (webhook) {
+ webhook.post('', { roomId: room.id, event: 'roomClosed' })
+ .then(function (/* response */) {
+ logger.info(`Room ${room.id} closed. Webhook succeeded.`);
+ })
+ .catch(function (error) {
+ logger.error(error);
+ });
+ }
+ });
+
+ room.on('joinRequestAccepted', (requestId) => {
+ if (webhook) {
+ webhook.post('', { requestId, roomId: room.id, event: 'joinRequestAccepted' })
+ .then(function (/* response */) {
+ logger.info(`Accepted join request ${requestId}. Webhook succeeded.`);
+ })
+ .catch(function (error) {
+ logger.error(error);
+ });
+ }
+ });
+
+ room.on('joinRequestDenied', (requestId) => {
+ if (webhook) {
+ webhook.post('', { requestId, roomId: room.id, event: 'joinRequestDenied' })
+ .then(function (/* response */) {
+ logger.info(`Denied join request ${requestId}. Webhook succeeded.`);
+ })
+ .catch(function (error) {
+ logger.error(error);
+ });
+ }
+ });
+
+ rooms.set(room.id, room);
+
+ return room;
+}
+
+run();
+
+module.exports = app; // export for testing
diff --git a/meet/server/test/fakeParameters.js b/meet/server/test/fakeParameters.js
new file mode 100644
--- /dev/null
+++ b/meet/server/test/fakeParameters.js
@@ -0,0 +1,609 @@
+const uuidv4 = require('uuid/v4');
+
+exports.generateRouterRtpCapabilities = function() {
+ return {
+ codecs :
+ [
+ {
+ mimeType : 'audio/opus',
+ kind : 'audio',
+ preferredPayloadType : 100,
+ clockRate : 48000,
+ channels : 2,
+ rtcpFeedback :
+ [
+ { type: 'transport-cc' }
+ ],
+ parameters :
+ {
+ useinbandfec : 1,
+ foo : 'bar'
+ }
+ },
+ {
+ mimeType : 'video/VP8',
+ kind : 'video',
+ preferredPayloadType : 101,
+ clockRate : 90000,
+ rtcpFeedback :
+ [
+ { type: 'nack' },
+ { type: 'nack', parameter: 'pli' },
+ { type: 'ccm', parameter: 'fir' },
+ { type: 'goog-remb' },
+ { type: 'transport-cc' }
+ ],
+ parameters :
+ {
+ 'x-google-start-bitrate' : 1500
+ }
+ },
+ {
+ mimeType : 'video/rtx',
+ kind : 'video',
+ preferredPayloadType : 102,
+ clockRate : 90000,
+ rtcpFeedback : [],
+ parameters :
+ {
+ apt : 101
+ }
+ },
+ {
+ mimeType : 'video/H264',
+ kind : 'video',
+ preferredPayloadType : 103,
+ clockRate : 90000,
+ rtcpFeedback :
+ [
+ { type: 'nack' },
+ { type: 'nack', parameter: 'pli' },
+ { type: 'ccm', parameter: 'fir' },
+ { type: 'goog-remb' },
+ { type: 'transport-cc' }
+ ],
+ parameters :
+ {
+ 'level-asymmetry-allowed' : 1,
+ 'packetization-mode' : 1,
+ 'profile-level-id' : '42e01f'
+ }
+ },
+ {
+ mimeType : 'video/rtx',
+ kind : 'video',
+ preferredPayloadType : 104,
+ clockRate : 90000,
+ rtcpFeedback : [],
+ parameters :
+ {
+ apt : 103
+ }
+ }
+ ],
+ headerExtensions :
+ [
+ {
+ kind : 'audio',
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ preferredId : 1,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ preferredId : 1,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id',
+ preferredId : 2,
+ preferredEncrypt : false,
+ direction : 'recvonly'
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id',
+ preferredId : 3,
+ preferredEncrypt : false,
+ direction : 'recvonly'
+ },
+ {
+ kind : 'audio',
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
+ preferredId : 4,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
+ preferredId : 4,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'audio',
+ uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
+ preferredId : 5,
+ preferredEncrypt : false,
+ direction : 'recvonly'
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
+ preferredId : 5,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07',
+ preferredId : 6,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:framemarking',
+ preferredId : 7,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'audio',
+ uri : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
+ preferredId : 10,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'urn:3gpp:video-orientation',
+ preferredId : 11,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:toffset',
+ preferredId : 12,
+ preferredEncrypt : false,
+ direction : 'sendrecv'
+ }
+ ],
+ fecMechanisms : []
+ };
+};
+
+exports.generateNativeRtpCapabilities = function() {
+ return {
+ codecs :
+ [
+ {
+ mimeType : 'audio/opus',
+ kind : 'audio',
+ preferredPayloadType : 111,
+ clockRate : 48000,
+ channels : 2,
+ rtcpFeedback :
+ [
+ { type: 'transport-cc' }
+ ],
+ parameters :
+ {
+ minptime : 10,
+ useinbandfec : 1
+ }
+ },
+ {
+ mimeType : 'audio/ISAC',
+ kind : 'audio',
+ preferredPayloadType : 103,
+ clockRate : 16000,
+ channels : 1,
+ rtcpFeedback :
+ [
+ { type: 'transport-cc' }
+ ],
+ parameters : {}
+ },
+ {
+ mimeType : 'audio/CN',
+ kind : 'audio',
+ preferredPayloadType : 106,
+ clockRate : 32000,
+ channels : 1,
+ rtcpFeedback :
+ [
+ { type: 'transport-cc' }
+ ],
+ parameters : {}
+ },
+ {
+ mimeType : 'video/VP8',
+ kind : 'video',
+ preferredPayloadType : 96,
+ clockRate : 90000,
+ rtcpFeedback :
+ [
+ { type: 'goog-remb' },
+ { type: 'transport-cc' },
+ { type: 'ccm', parameter: 'fir' },
+ { type: 'nack' },
+ { type: 'nack', parameter: 'pli' }
+ ],
+ parameters :
+ {
+ baz : '1234abcd'
+ }
+ },
+ {
+ mimeType : 'video/rtx',
+ kind : 'video',
+ preferredPayloadType : 97,
+ clockRate : 90000,
+ rtcpFeedback : [],
+ parameters :
+ {
+ apt : 96
+ }
+ }
+ ],
+ headerExtensions :
+ [
+ {
+ kind : 'audio',
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ preferredId : 1
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ preferredId : 1
+ },
+ {
+ kind : 'video',
+ uri : 'urn:ietf:params:rtp-hdrext:toffset',
+ preferredId : 2
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
+ preferredId : 3
+ },
+ {
+ kind : 'video',
+ uri : 'urn:3gpp:video-orientation',
+ preferredId : 4
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
+ preferredId : 5
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/playout-delay',
+ preferredId : 6
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/video-content-type',
+ preferredId : 7
+ },
+ {
+ kind : 'video',
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/video-timing',
+ preferredId : 8
+ },
+ {
+ kind : 'audio',
+ uri : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
+ preferredId : 10
+ }
+ ],
+ fecMechanisms : []
+ };
+};
+
+exports.generateNativeSctpCapabilities = function() {
+ return {
+ numStreams : { OS: 2048, MIS: 2048 }
+ };
+};
+
+exports.generateLocalDtlsParameters = function() {
+ return {
+ fingerprints :
+ [
+ {
+ algorithm : 'sha-256',
+ value : '82:5A:68:3D:36:C3:0A:DE:AF:E7:32:43:D2:88:83:57:AC:2D:65:E5:80:C4:B6:FB:AF:1A:A0:21:9F:6D:0C:AD'
+ }
+ ],
+ role : 'auto'
+ };
+};
+
+exports.generateTransportRemoteParameters = function() {
+ return {
+ id : uuidv4(),
+ iceParameters :
+ {
+ iceLite : true,
+ password : 'yku5ej8nvfaor28lvtrabcx0wkrpkztz',
+ usernameFragment : 'h3hk1iz6qqlnqlne'
+ },
+ iceCandidates :
+ [
+ {
+ family : 'ipv4',
+ foundation : 'udpcandidate',
+ ip : '9.9.9.9',
+ port : 40533,
+ priority : 1078862079,
+ protocol : 'udp',
+ type : 'host'
+ },
+ {
+ family : 'ipv6',
+ foundation : 'udpcandidate',
+ ip : '9:9:9:9:9:9',
+ port : 41333,
+ priority : 1078862089,
+ protocol : 'udp',
+ type : 'host'
+ }
+ ],
+ dtlsParameters :
+ {
+ fingerprints :
+ [
+ {
+ algorithm : 'sha-256',
+ value : 'A9:F4:E0:D2:74:D3:0F:D9:CA:A5:2F:9F:7F:47:FA:F0:C4:72:DD:73:49:D0:3B:14:90:20:51:30:1B:90:8E:71'
+ },
+ {
+ algorithm : 'sha-384',
+ value : '03:D9:0B:87:13:98:F6:6D:BC:FC:92:2E:39:D4:E1:97:32:61:30:56:84:70:81:6E:D1:82:97:EA:D9:C1:21:0F:6B:C5:E7:7F:E1:97:0C:17:97:6E:CF:B3:EF:2E:74:B0'
+ },
+ {
+ algorithm : 'sha-512',
+ value : '84:27:A4:28:A4:73:AF:43:02:2A:44:68:FF:2F:29:5C:3B:11:9A:60:F4:A8:F0:F5:AC:A0:E3:49:3E:B1:34:53:A9:85:CE:51:9B:ED:87:5E:B8:F4:8E:3D:FA:20:51:B8:96:EE:DA:56:DC:2F:5C:62:79:15:23:E0:21:82:2B:2C'
+ }
+ ],
+ role : 'auto'
+ },
+ sctpParameters :
+ {
+ port : 5000,
+ numStreams : 2048,
+ maxMessageSize : 2000000
+ }
+ };
+};
+
+exports.generateProducerRemoteParameters = function() {
+ return {
+ id : uuidv4()
+ };
+};
+
+exports.generateConsumerRemoteParameters = function({ id, codecMimeType } = {}) {
+ switch (codecMimeType) {
+ case 'audio/opus':
+ {
+ return {
+ id : id || uuidv4(),
+ producerId : uuidv4(),
+ kind : 'audio',
+ rtpParameters :
+ {
+ codecs :
+ [
+ {
+ mimeType : 'audio/opus',
+ payloadType : 100,
+ clockRate : 48000,
+ channels : 2,
+ rtcpFeedback :
+ [
+ { type: 'transport-cc' }
+ ],
+ parameters :
+ {
+ useinbandfec : 1,
+ foo : 'bar'
+ }
+ }
+ ],
+ encodings :
+ [
+ {
+ ssrc : 46687003
+ }
+ ],
+ headerExtensions :
+ [
+ {
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ id : 1
+ },
+ {
+ uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
+ id : 5
+ },
+ {
+ uri : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
+ id : 10
+ }
+ ],
+ rtcp :
+ {
+ cname : 'wB4Ql4lrsxYLjzuN',
+ reducedSize : true,
+ mux : true
+ }
+ }
+ };
+ }
+
+ case 'audio/ISAC':
+ {
+ return {
+ id : id || uuidv4(),
+ producerId : uuidv4(),
+ kind : 'audio',
+ rtpParameters :
+ {
+ codecs :
+ [
+ {
+ mimeType : 'audio/ISAC',
+ payloadType : 111,
+ clockRate : 16000,
+ channels : 1,
+ rtcpFeedback :
+ [
+ { type: 'transport-cc' }
+ ],
+ parameters : {}
+ }
+ ],
+ encodings :
+ [
+ {
+ ssrc : 46687004
+ }
+ ],
+ headerExtensions :
+ [
+ {
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ id : 1
+ },
+ {
+ uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
+ id : 5
+ }
+ ],
+ rtcp :
+ {
+ cname : 'wB4Ql4lrsxYLjzuN',
+ reducedSize : true,
+ mux : true
+ }
+ }
+ };
+ }
+
+ case 'video/VP8':
+ {
+ return {
+ id : id || uuidv4(),
+ producerId : uuidv4(),
+ kind : 'video',
+ rtpParameters :
+ {
+ codecs :
+ [
+ {
+ mimeType : 'video/VP8',
+ payloadType : 101,
+ clockRate : 90000,
+ rtcpFeedback :
+ [
+ { type: 'nack' },
+ { type: 'nack', parameter: 'pli' },
+ { type: 'ccm', parameter: 'fir' },
+ { type: 'goog-remb' },
+ { type: 'transport-cc' }
+ ],
+ parameters :
+ {
+ 'x-google-start-bitrate' : 1500
+ }
+ },
+ {
+ mimeType : 'video/rtx',
+ payloadType : 102,
+ clockRate : 90000,
+ rtcpFeedback : [],
+ parameters :
+ {
+ apt : 101
+ }
+ }
+ ],
+ encodings :
+ [
+ {
+ ssrc : 99991111,
+ rtx :
+ {
+ ssrc : 99991112
+ }
+ }
+ ],
+ headerExtensions :
+ [
+ {
+ uri : 'urn:ietf:params:rtp-hdrext:sdes:mid',
+ id : 1
+ },
+ {
+ uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
+ id : 4
+ },
+ {
+ uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
+ id : 5
+ },
+ {
+ uri : 'urn:3gpp:video-orientation',
+ id : 11
+ },
+ {
+ uri : 'urn:ietf:params:rtp-hdrext:toffset',
+ id : 12
+ }
+ ],
+ rtcp :
+ {
+ cname : 'wB4Ql4lrsxYLjzuN',
+ reducedSize : true,
+ mux : true
+ }
+ }
+ };
+ }
+
+ default:
+ {
+ throw new TypeError(`unknown codecMimeType "${codecMimeType}"`);
+ }
+ }
+};
+
+exports.generateDataProducerRemoteParameters = function() {
+ return {
+ id : uuidv4()
+ };
+};
+
+exports.generateDataConsumerRemoteParameters = function({ id } = {}) {
+ return {
+ id : id || uuidv4(),
+ dataProducerId : uuidv4(),
+ sctpStreamParameters :
+ {
+ streamId : 666,
+ maxPacketLifeTime : 5000,
+ maxRetransmits : undefined
+ }
+ };
+};
diff --git a/meet/server/test/performancetestbench.js b/meet/server/test/performancetestbench.js
new file mode 100644
--- /dev/null
+++ b/meet/server/test/performancetestbench.js
@@ -0,0 +1,248 @@
+process.env.DEBUG = ''
+
+const assert = require('assert');
+let request = require('supertest')
+const io = require("socket.io-client");
+const child_process = require("child_process");
+const udp = require('dgram');
+
+const Roles = require('../lib/userRoles');
+
+let recvUdpSocket
+let recvRtcpUdpSocket
+let app
+let processes = [];
+
+let rtpParameters = {
+ codecs: [
+ {
+ mimeType: "video/H264",
+ payloadType: 125,
+ clockRate: 90000,
+ parameters: {
+ "level-asymmetry-allowed": 1,
+ "packetization-mode": 1,
+ "profile-level-id": "42e01f",
+ },
+ },
+ ],
+}
+
+function startFFMPEGStream(peers, ssrc) {
+ const cmdProgram = "ffmpeg";
+
+ //Build a video stream per producer
+ const streams = peers.map((peer) => `[select=v:f=rtp:ssrc=${ssrc}:payload_type=125]rtp://127.0.0.1:${peer.senderTransportInfo.port}?rtcpport=${peer.senderTransportInfo.rtcpPort}`);
+
+ const cmdArgStr = [
+ "-i /dev/video0", //We are streaming from the webcam (a looping videofile would be an alternative)
+ `-c:v h264`, //The codec
+ "-map 0:v:0",
+ "-f tee", //This option allows us to read the source once, encode once, and then output multiple streams
+ streams.join('|').trim()
+ ].join(" ").trim();
+
+ console.log(`Run command: ${cmdProgram} ${cmdArgStr}`);
+
+ let recProcess = child_process.spawn(cmdProgram, cmdArgStr.split(/\s+/));
+
+ recProcess.on("error", (err) => {
+ console.error("Recording process error:", err);
+ });
+
+ recProcess.on("exit", (code, signal) => {
+ console.log("Recording process exit, code: %d, signal: %s", code, signal);
+
+ recProcess = null;
+ });
+
+ // FFmpeg writes its logs to stderr
+ recProcess.stderr.on("data", (chunk) => {
+ chunk
+ .toString()
+ .split(/\r?\n/g)
+ .filter(Boolean) // Filter out empty strings
+ .forEach((line) => {
+ // console.log(line);
+ });
+ });
+
+ return recProcess;
+}
+
+async function sendRequest(socket, method, data = null) {
+ return await new Promise((resolve, /*reject*/) => {
+ socket.emit(
+ 'request',
+ {method: method,
+ data: data},
+ (error, response) => {
+ assert(!error)
+ resolve(response)
+ }
+ )
+ })
+}
+
+
+async function createPeer(roomId, request, receiverPort, receiverRtcpPort) {
+ let signalingSocket
+ await request
+ .post(`/meetmedia/api/sessions/${roomId}/connection`)
+ .send({role: Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR})
+ .expect(200)
+ .then(async (res) => {
+ let data = res.body;
+ const signalingUrl = data['token'];
+ signalingSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false });
+ let roomReady = new Promise((resolve, /*reject*/) => {
+ signalingSocket.once('notification', (reason) => {
+ // console.warn("Received notification", reason)
+ if (reason['method'] == 'roomReady') {
+ resolve();
+ }
+ });
+ })
+
+ signalingSocket.connect();
+ await roomReady
+ })
+ .catch(err => {
+ console.warn(err); throw err
+ })
+
+
+ //Necessary later for the server to resume the consumer,
+ //once we join with another peer
+ signalingSocket.on('request', async (reason, cb) => {
+ // console.warn("Received request", reason)
+ if (reason['method'] == 'newConsumer') {
+ cb();
+ }
+ });
+
+ //Join
+ await sendRequest(signalingSocket, 'join', {
+ nickname: "nickname",
+ rtpCapabilities: rtpParameters
+ })
+
+ //Create sending transport
+ const senderTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', {
+ producing: true,
+ consuming: false,
+ })
+
+ //Create consuming transport
+ const consumerTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', {
+ producing: false,
+ consuming: true,
+ })
+
+ await sendRequest(signalingSocket, 'connectPlainTransport', {
+ transportId: consumerTransportInfo.id,
+ ip: '127.0.0.1',
+ port: receiverPort,
+ rtcpPort: receiverRtcpPort,
+ })
+
+ //Create sending producer
+ await sendRequest(signalingSocket, 'produce', {
+ transportId: senderTransportInfo.id,
+ kind: 'video',
+
+ rtpParameters: {
+ codecs: [
+ {
+ mimeType: "video/H264",
+ payloadType: 125,
+ clockRate: 90000,
+ parameters: {
+ "level-asymmetry-allowed": 1,
+ "packetization-mode": 1,
+ "profile-level-id": "42e01f",
+ },
+ },
+ ],
+ encodings: [{ ssrc: 2222 }]
+ },
+ appData: {
+ source: 'webcam'
+ }
+ })
+
+ return {senderTransportInfo, consumerTransportInfo, signalingSocket};
+}
+
+
+before(function (done) {
+ process.env.SSL_CERT = "none"
+ process.env.MEDIASOUP_NUM_WORKERS = 3
+ process.env.ROUTER_SCALE_SIZE = 3
+ process.env.WEBRTC_LISTEN_IP = "127.0.0.1"
+ app = require('../server.js')
+ request = request(app);
+
+
+ recvUdpSocket = udp.createSocket('udp4');
+ recvUdpSocket.on('message',function(msg,info){
+ // console.warn("Received message", msg, info)
+ });
+
+ recvRtcpUdpSocket = udp.createSocket('udp4');
+ recvRtcpUdpSocket.on('message',function(msg,info){
+ // console.warn("Received RTCP message", msg, info)
+ });
+
+ app.on("ready", function(){
+ done();
+ });
+});
+
+describe('Testbench', function() {
+ let roomId;
+ let peers = [];
+
+ it('prepare udp sockets', async () => {
+ await new Promise(resolve => recvUdpSocket.bind(22222, '127.0.0.1', resolve));
+ await new Promise(resolve => recvRtcpUdpSocket.bind(22223, '127.0.0.1', resolve));
+ });
+
+ it('create room', async () => {
+ return request
+ .post(`/meetmedia/api/sessions`)
+ .expect(200)
+ .then(async (res) => {
+ roomId = res.body['id'];
+ })
+ .catch(err => {
+ console.warn(err); throw err
+ })
+ });
+
+ it('create peers', async () => {
+ for (var i = 0; i < 20; i++) {
+ peers.push(await createPeer(roomId, request, recvUdpSocket.address().port, recvRtcpUdpSocket.address().port))
+ }
+ });
+
+ it('start ffmpg stream', async () => {
+ processes.push(startFFMPEGStream(peers, 2222))
+ });
+
+ it('wait forever', async () => {
+ setInterval(function(){
+ sendRequest(peers[0].signalingSocket, 'dumpStats', {})
+ }, 5000)
+ const promise = new Promise((res, _rej) => {});
+ return promise;
+ })
+});
+
+after(function () {
+ for (const process of processes) {
+ process.kill()
+ }
+ process.exit();
+})
+
diff --git a/meet/server/test/test.js b/meet/server/test/test.js
new file mode 100644
--- /dev/null
+++ b/meet/server/test/test.js
@@ -0,0 +1,280 @@
+const assert = require('assert');
+let request = require('supertest')
+const io = require("socket.io-client");
+
+const Roles = require('../lib/userRoles');
+
+const mediasoupClient = require('mediasoup-client');
+const { FakeHandler } = require('mediasoup-client/lib/handlers/FakeHandler');
+const fakeParameters = require('./fakeParameters');
+
+let app
+let authToken = "Welcome2KolabSystems"
+
+before(function (done) {
+ process.env.SSL_CERT = "none"
+ process.env.AUTH_TOKEN = authToken
+ process.env.LISTENING_PORT = 12999
+ process.env.PUBLIC_DOMAIN = "127.0.0.1:12999"
+ process.env.WEBRTC_LISTEN_IP = "127.0.0.1"
+ process.env.DEBUG = '*'
+
+ app = require('../server.js')
+ request = request(app);
+
+ app.on("ready", function(){
+ done();
+ });
+});
+
+describe('GET /ping', function() {
+ it('responds', function(done) {
+ request
+ .get('/meetmedia/api/ping')
+ .set('X-Auth-Token', authToken)
+ .expect(200, done);
+ });
+});
+
+describe('Join room', function() {
+ let roomId
+ let signalingUrl;
+
+ let signalingSocket
+ let signalingSocket2
+ let peerId
+
+ async function sendRequest(socket, method, data = null) {
+ return await new Promise((resolve, /*reject*/) => {
+ socket.emit(
+ 'request',
+ {method: method,
+ data: data},
+ (error, response) => {
+ assert(!error)
+ resolve(response)
+ }
+ )
+ })
+ }
+
+ it('create room', async () => {
+ return request
+ .post(`/meetmedia/api/sessions`)
+ .set('X-Auth-Token', authToken)
+ .expect(200)
+ .then(async (res) => {
+ roomId = res.body['id'];
+ })
+ .catch(err => {
+ console.warn(err); throw err
+ })
+ });
+
+ it('list rooms', async () => {
+ return request
+ .get(`/meetmedia/api/sessions`)
+ .set('X-Auth-Token', authToken)
+ .expect(200);
+ })
+
+ it('connect', async () => {
+ return request
+ .post(`/meetmedia/api/sessions/${roomId}/connection`)
+ .set('X-Auth-Token', authToken)
+ .send({role: Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR})
+ .expect(200)
+ .then(async (res) => {
+ let data = res.body;
+ peerId = data['id'];
+ signalingUrl = data['token'];
+ assert(signalingUrl.includes(peerId))
+ assert(signalingUrl.includes(roomId))
+ // console.info(signalingUrl);
+
+ signalingSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false });
+ let roomReady = new Promise((resolve, /*reject*/) => {
+ signalingSocket.on('notification', (reason) => {
+ if (reason['method'] == 'roomReady') {
+ resolve();
+ }
+ });
+ })
+
+ signalingSocket.connect();
+ await roomReady
+ })
+ .catch(err => {
+ console.warn(err); throw err
+ })
+ });
+
+ it('getRtpCapabilities', async () => {
+ const routerRtpCapabilities = await sendRequest(signalingSocket, 'getRouterRtpCapabilities')
+ assert(Object.keys(routerRtpCapabilities).length != 0)
+ });
+
+
+ it('join', async () => {
+ const { id, role, peers } = await sendRequest(signalingSocket, 'join', {
+ nickname: "nickname",
+ rtpCapabilities: fakeParameters.generateNativeRtpCapabilities()
+ })
+ assert.equal(id, peerId)
+ assert.equal(role, Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR)
+ assert.equal(peers.length, 0)
+ })
+
+ it('second peer joining', async () => {
+ return request
+ .post(`/meetmedia/api/sessions/${roomId}/connection`)
+ .set('X-Auth-Token', authToken)
+ .expect(200)
+ .then(async (res) => {
+ let data = res.body;
+ const newId = data['id'];
+ const signalingUrl = data['token'];
+
+ signalingSocket2 = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false });
+
+ let roomReady = new Promise((resolve, /*reject*/) => {
+ signalingSocket2.on('notification', async (reason) => {
+ if (reason['method'] == 'roomReady') {
+ resolve(reason);
+ }
+ });
+ })
+
+ let newPeer = new Promise((resolve, /*reject*/) => {
+ signalingSocket.on('notification', (reason) => {
+ if (reason.method == 'newPeer') {
+ resolve(reason);
+ }
+ });
+ })
+
+ signalingSocket2.connect();
+
+
+ let reason = await roomReady;
+ const { peers } = await sendRequest(signalingSocket2, 'join', {
+ nickname: "nickname",
+ rtpCapabilities: fakeParameters.generateNativeRtpCapabilities()
+ })
+ assert.equal(peers.length, 1)
+ assert.equal(peers[0].id, peerId)
+
+ reason = await newPeer;
+ assert(reason.data.id == newId);
+ })
+ .catch(err => {
+ console.warn(err); throw err
+ })
+ });
+
+ let transportInfo;
+
+ it('createWebRtcTransport', async () => {
+ transportInfo = await sendRequest(signalingSocket, 'createWebRtcTransport', {
+ forceTcp: false,
+ producing: true,
+ consuming: false
+ })
+
+ const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo
+ assert(transportInfo != null);
+ });
+
+ it('createDevice', async () => {
+ let device;
+ try{
+ device = new mediasoupClient.Device({ handlerFactory: FakeHandler.createFactory(fakeParameters) });
+
+ let caps = fakeParameters.generateRouterRtpCapabilities();
+ await device.load({routerRtpCapabilities: caps})
+ assert(device.canProduce('video'))
+
+ const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo
+ //FIXME it doesn't look like this device can actually connect
+ let sendTransport = device.createSendTransport({
+ id,
+ iceParameters,
+ iceCandidates,
+ dtlsParameters,
+ // iceServers: turnServers,
+ // iceTransportPolicy: iceTransportPolicy,
+ proprietaryConstraints: { optional: [{ googDscp: true }] }
+ })
+
+ sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
+ console.warn("on connect");
+ // done();
+ // socket.sendRequest('connectWebRtcTransport',
+ // { transportId: sendTransport.id, dtlsParameters })
+ // .then(callback)
+ // .catch(errback)
+ })
+
+ //TODO we should get it to connected
+ // assert.equal(sendTransport.connectionState, 'new');
+
+ } catch (error) {
+ console.warn(error)
+ }
+ });
+
+ it('reconnect', async () => {
+ //Connect a new socket first, simulating a dangling old socket.
+ let reconnectSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false , forceNew: true});
+
+ //Listen for peer closed events
+ let peerClosed = false;
+ signalingSocket2.on('notification', (reason) => {
+ if (reason['method'] == 'peerClosed') {
+ peerClosed = true;
+ }
+ });
+
+ let roomBack = new Promise((resolve, /*reject*/) => {
+ reconnectSocket.on('notification', (reason) => {
+ if (reason['method'] == 'roomBack') {
+ resolve();
+ }
+ });
+ })
+
+ await reconnectSocket.connect();
+ await roomBack
+
+ //Now disconnect the old socket, it shouldn't affect the new one and shouldn't trigger a peer closure.
+ await signalingSocket.disconnect();
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ //We shouldn't receive a peer closed event
+ assert(!peerClosed);
+ //For further tests
+ signalingSocket = reconnectSocket;
+ });
+
+
+ it('disconnect', async () => {
+ let peerClosed = new Promise((resolve, /*reject*/) => {
+ signalingSocket2.on('notification', (reason) => {
+ if (reason['method'] == 'peerClosed') {
+ resolve();
+ }
+ });
+ })
+
+ //Disconnect and wait for the peer closed signal
+ await signalingSocket.disconnect();
+ await peerClosed
+ });
+
+ after(function () {
+ signalingSocket.close();
+ signalingSocket2.close();
+ })
+
+});
diff --git a/meet/server/test/videoproducer.js b/meet/server/test/videoproducer.js
new file mode 100644
--- /dev/null
+++ b/meet/server/test/videoproducer.js
@@ -0,0 +1,398 @@
+#!/usr/bin/env node
+
+process.env.DEBUG = '*'
+
+process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0;
+
+let request = require('superagent');
+const io = require("socket.io-client");
+const child_process = require("child_process");
+
+const Roles = require('../lib/userRoles');
+
+let processes = [];
+
+//e.g. http://kolab1.mkpf.ch:12443
+let serverUrl = process.argv[2]
+// Welcome2KolabSystems
+let webhookToken = process.argv[3]
+//This is the mediasoup internal id, not what we have in kolab4
+let roomId = process.argv[4]
+let numStreams = 1
+let filename = "input.mp4";
+
+
+const codecIndex = 1
+
+let rtpParameters = {
+ codecs: [
+ {
+ mimeType: "video/H264",
+ payloadType: 125,
+ clockRate: 90000,
+ parameters: {
+ "level-asymmetry-allowed": 1,
+ "packetization-mode": 1,
+ "profile-level-id": "42e01f",
+ },
+ rtcpFeedback: []
+ },
+ {
+ kind : 'video',
+ mimeType : 'video/VP8',
+ payloadType: 96,
+ clockRate : 90000,
+ parameters :
+ {
+ 'profile-id' : 2,
+ 'x-google-start-bitrate' : 1000
+ },
+ rtcpFeedback: []
+ },
+ {
+ kind : 'audio',
+ channels: 2,
+ clockRate: 48000,
+ mimeType: "audio/opus",
+ parameters: {
+ "maxplaybackrate": 48000,
+ "ptime": "20",
+ "sprop-stereo": 0,
+ "stereo": 1,
+ "usedtx": 1,
+ "useinbandfec": 1
+ },
+ payloadType: 109,
+ rtcpFeedback: []
+ }
+ ],
+}
+
+
+function startGStream(peers, ssrc, audioSsrc) {
+ const cmdProgram = "gst-launch-1.0";
+
+ const payloadType = rtpParameters.codecs[codecIndex]['payloadType']
+ const audioPayloadType = 109
+
+ //FIXME currently only handles a single peer
+ const peer = peers[0];
+
+ const cmdArgStr = [
+ "-v",
+ "rtpbin name=rtpbin rtp-profile=avpf",
+
+ //The source named "dec"
+ `filesrc location="${filename}" ! decodebin name=dec`,
+ //The video stream
+ `dec. ! queue ! videoconvert ! videoscale ! videorate ! video/x-raw,width=1280,height=720,framerate=25/1 ! vp8enc deadline=1 cpu-used=-5 ! rtpvp8pay pt=${payloadType} ssrc=${ssrc} picture-id-mode=2 ! rtprtxqueue max-size-time=2000 max-size-packets=0 ! rtpbin.send_rtp_sink_0`,
+ //The audio stream from the same source
+ `dec. ! queue ! audioconvert ! audioresample ! opusenc ! rtpopuspay pt=${audioPayloadType} ssrc=${audioSsrc} ! rtprtxqueue ! rtpbin.send_rtp_sink_1`,
+
+ //Send video over udp
+ `rtpbin.send_rtp_src_0 ! udpsink host=${peer.senderTransportInfo.ip} port=${peer.senderTransportInfo.port} sync=true async=false`,
+ `rtpbin.send_rtcp_src_0 ! udpsink host=${peer.senderTransportInfo.ip} port=${peer.senderTransportInfo.rtcpPort} sync=false async=false`,
+ //Send audio over udp
+ `rtpbin.send_rtp_src_1 ! udpsink host=${peer.senderAudioTransportInfo.ip} port=${peer.senderAudioTransportInfo.port} sync=true async=false`,
+ `rtpbin.send_rtcp_src_1 ! udpsink host=${peer.senderAudioTransportInfo.ip} port=${peer.senderAudioTransportInfo.rtcpPort} sync=false async=false`
+ ].join(" ").trim();
+
+
+ console.log(`Run command: ${cmdProgram} ${cmdArgStr}`);
+
+ let recProcess = child_process.spawn(cmdProgram, cmdArgStr.split(/\s+/));
+
+ recProcess.on("error", (err) => {
+ console.error("gstreamer process error:", err);
+ });
+
+ recProcess.on("exit", (code, signal) => {
+ console.log("gstreamer process exit, code: %d, signal: %s", code, signal);
+
+ recProcess = null;
+ process.exit()
+ });
+
+ recProcess.stdout.on("data", (chunk) => {
+ chunk
+ .toString()
+ .split(/\r?\n/g)
+ .filter(Boolean) // Filter out empty strings
+ .forEach((line) => {
+ console.log(line);
+ });
+ });
+
+ recProcess.stderr.on("data", (chunk) => {
+ chunk
+ .toString()
+ .split(/\r?\n/g)
+ .filter(Boolean) // Filter out empty strings
+ .forEach((line) => {
+ console.log(line);
+ });
+ });
+
+ return recProcess;
+}
+
+//ffmpeg kind of sucks https://mediasoup.discourse.group/t/very-high-packet-loss-with-ffmpeg-broadcasting/322
+function startFFMPEGStream(peers, ssrc, audioSsrc) {
+ const cmdProgram = "ffmpeg";
+
+ const payloadType = rtpParameters.codecs[codecIndex]['payloadType']
+ const audioPayloadType = 109
+
+ //Build a video stream per producer
+ const streams = peers.map((peer) => `[select=a:f=rtp:ssrc=${audioSsrc}:payload_type=${audioPayloadType}]rtp://${peer.senderAudioTransportInfo.ip}:${peer.senderAudioTransportInfo.port}?rtcpport=${peer.senderAudioTransportInfo.rtcpPort}|[select=v:f=rtp:ssrc=${ssrc}:payload_type=${payloadType}]rtp://${peer.senderTransportInfo.ip}:${peer.senderTransportInfo.port}?rtcpport=${peer.senderTransportInfo.rtcpPort}`);
+
+ const cmdArgStr = [
+ //The source
+ `-stream_loop -1 -re -i ${filename}`, //Loop a videofile (-re for original speed)
+ // "-i /dev/video0", //Stream from the webcam
+
+ '-map 0:a:0',
+ '-c:a libopus -ab 128k -ac 2 -ar 48000 -application lowdelay -cutoff 12000',
+
+ '-vf scale=640:480',
+
+ //The vp8 codec
+ '-c:v libvpx -crf 10 -b:v 1000k',
+
+ //The vp9 codec
+ // '-strict experimental',
+ // '-c:v libvpx-vp9 -crf 30 -b:v 0',
+ //
+ //The h264 codec
+ // '-c:v h264 -b:v 500k',
+ // '',
+ // '-c:v libx264 -tune zerolatency -preset ultrafast -threads 0 -crf 23 -minrate 5M -maxrate 5M -bufsize 10M',
+
+ //Frame rate?
+ // '-r 25',
+
+ "-map 0:v:0",
+ "-f tee", //This option allows us to read the source once, encode once, and then output multiple streams
+ streams.join('|').trim()
+ ].join(" ").trim();
+
+ console.log(`Run command: ${cmdProgram} ${cmdArgStr}`);
+
+ let recProcess = child_process.spawn(cmdProgram, cmdArgStr.split(/\s+/));
+
+ recProcess.on("error", (err) => {
+ console.error("ffmpeg process error:", err);
+ });
+
+ recProcess.on("exit", (code, signal) => {
+ console.log("ffmpeg process exit, code: %d, signal: %s", code, signal);
+
+ recProcess = null;
+ });
+
+ // FFmpeg writes its logs to stderr
+ recProcess.stderr.on("data", (chunk) => {
+ chunk
+ .toString()
+ .split(/\r?\n/g)
+ .filter(Boolean) // Filter out empty strings
+ .forEach((line) => {
+ console.log(line);
+ });
+ });
+
+ return recProcess;
+}
+
+async function sendRequest(socket, method, data = null) {
+ return await new Promise((resolve, /*reject*/) => {
+ socket.emit(
+ 'request',
+ {method: method,
+ data: data},
+ (error, response) => {
+ resolve(response)
+ }
+ )
+ })
+}
+
+
+async function createPeer(index, roomId/*, request, receiverPort, receiverRtcpPort*/) {
+ console.warn("Creating peer")
+ let signalingSocket
+ await request
+ .post(`${serverUrl}/meetmedia/api/sessions/${roomId}/connection`)
+ .send({role: Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR})
+ .set('X-Auth-Token', webhookToken)
+ .then(async (res) => {
+ let data = res.body;
+ console.warn(data)
+ const signalingUrl = data['token'];
+
+ signalingSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false });
+ console.warn("Waiting for room ready")
+ let roomReady = new Promise((resolve, /*reject*/) => {
+ console.warn("waiting for notification")
+ //For some reason this notification is never emitted
+ signalingSocket.once('notification', (reason) => {
+ console.warn("Received notification", reason)
+ if (reason['method'] == 'roomReady') {
+ resolve();
+ }
+ });
+ })
+
+ signalingSocket.on("connect", () => { console.warn("connected"); });
+ signalingSocket.on("disconnect", () => { console.warn("disconnect"); });
+
+ signalingSocket.on('notification', (reason) => {
+ console.warn("1Received notification", reason)
+ });
+
+ signalingSocket.connect();
+ signalingSocket.on('notification', (reason) => {
+ console.warn("1Received notification", reason)
+ });
+
+ console.warn("Connecting")
+
+ //FIXME this does not currently seem to work as it should.
+
+ await roomReady
+ console.warn("Connected")
+ })
+ .catch(err => {
+ console.warn(err); throw err
+ })
+
+ console.warn("Created connection")
+
+ //Necessary later for the server to resume the consumer,
+ //once we join with another peer
+ // signalingSocket.on('request', async (reason, cb) => {
+ // // console.warn("Received request", reason)
+ // if (reason['method'] == 'newConsumer') {
+ // cb();
+ // }
+ // });
+
+ //Join
+ await sendRequest(signalingSocket, 'join', {
+ nickname: `videoproducer ${index}`,
+ rtpCapabilities: {
+ codecs: [rtpParameters.codecs[codecIndex]],
+ }
+ })
+ console.warn("Joined")
+
+ //Create sending transport
+ const senderTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', {
+ producing: true,
+ consuming: false,
+ })
+ console.warn("Created transport", senderTransportInfo)
+
+ const senderAudioTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', {
+ producing: true,
+ consuming: false,
+ })
+
+ //Create consuming transport
+ // const consumerTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', {
+ // producing: false,
+ // consuming: true,
+ // })
+
+ // await sendRequest(signalingSocket, 'connectPlainTransport', {
+ // transportId: consumerTransportInfo.id,
+ // ip: '127.0.0.1',
+ // port: receiverPort,
+ // rtcpPort: receiverRtcpPort,
+ // })
+
+ //Create sending producer
+ await sendRequest(signalingSocket, 'produce', {
+ transportId: senderTransportInfo.id,
+ kind: 'video',
+
+ rtpParameters: {
+ codecs: [rtpParameters.codecs[codecIndex]],
+ encodings: [{ ssrc: 2222 }]
+ },
+ appData: {
+ source: 'webcam'
+ }
+ })
+
+ await sendRequest(signalingSocket, 'produce', {
+ transportId: senderAudioTransportInfo.id,
+ kind: 'audio',
+
+ rtpParameters: {
+ codecs: [
+ {
+ "channels": 2,
+ "clockRate": 48000,
+ "mimeType": "audio/opus",
+ "parameters": {
+ "maxplaybackrate": 48000,
+ "ptime": "20",
+ "sprop-stereo": 0,
+ "stereo": 1,
+ "usedtx": 1,
+ "useinbandfec": 1
+ },
+ "payloadType": 109,
+ "rtcpFeedback": []
+ }
+ ],
+ encodings: [{ ssrc: 2223 }]
+ },
+ appData: {
+ source: 'mic'
+ }
+ })
+
+ console.warn("Produced")
+
+ // return {senderTransportInfo, consumerTransportInfo, signalingSocket};
+ return {senderTransportInfo, senderAudioTransportInfo, signalingSocket};
+}
+
+async function run() {
+ // let roomId;
+ let peers = [];
+
+ // await request
+ // .post(`${serverUrl}/meetmedia/api/sessions`)
+ // .set('X-Auth-Token', webhookToken)
+ // .then(async (res) => {
+ // roomId = res.body['id'];
+ // })
+ // .catch(err => {
+ // console.warn(err); throw err
+ // })
+
+ for (var i = 0; i < numStreams; i++) {
+ peers.push(await createPeer(i, roomId))
+ }
+
+ if (true) {
+ processes.push(startGStream(peers, 2222, 2223))
+ } else {
+ processes.push(startFFMPEGStream(peers, 2222, 2223))
+ }
+
+ const promise = new Promise((res, _rej) => {});
+ await promise;
+}
+
+run()
+ .then(() => {
+ for (const process of processes) {
+ process.kill()
+ }
+ process.exit();
+ });
+
diff --git a/meet/server/throttle.sh b/meet/server/throttle.sh
new file mode 100755
--- /dev/null
+++ b/meet/server/throttle.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+
+# Throttle local network connections
+
+set -x
+
+# Requires kernel-modules-extra (and perhaps kernel-debug-modules-extra) and a reboot
+sudo modprobe sch_netem || exit 1
+
+INTERFACE=$(sudo route | grep -m 1 '^default' | grep -o '[^ ]*$' | tr -d '\n')
+
+if [[ $1 == "stop" ]]
+then
+ echo "Stopping"
+ sudo tc qdisc del dev $INTERFACE root
+ sudo tc qdisc del dev $INTERFACE ingress
+ sudo tc qdisc del dev ifb0 root
+ exit 0
+fi
+
+DOWN=500
+UP=500
+HALFWAYRTT=10
+
+echo "Throttling $INTERFACE Up $UP Down $DOWN RTT/2 $HALFWAYRTT"
+# Setup:
+# This creates a virtual ifb interface to redirect ingress traffic over it,
+# so we can then apply egress rules to the mirrored ingress traffic.
+# This allows to use the more flexible egress rules for ingress traffic, instead of the more limited tc ingress filters.
+sudo modprobe ifb || exit 1
+sudo ip link set dev ifb0 up
+sudo tc qdisc add dev $INTERFACE ingress
+sudo tc filter add dev $INTERFACE parent ffff: protocol ip u32 match u32 0 0 flowid 1:1 action mirred egress redirect dev ifb0
+
+# Set bandwith
+sudo tc qdisc add dev ifb0 root handle 1:0 netem delay ${HALFWAYRTT}ms rate ${DOWN}kbit
+sudo tc qdisc add dev $INTERFACE root handle 1:0 netem delay ${HALFWAYRTT}ms rate ${UP}kbit
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -78,24 +78,17 @@
LDAP_HOSTED_BIND_PW="Welcome2KolabSystems"
LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com"
-OPENVIDU_API_PASSWORD=MY_SECRET
-OPENVIDU_API_URL=http://localhost:8080/api/
-OPENVIDU_API_USERNAME=OPENVIDUAPP
-OPENVIDU_API_VERIFY_TLS=true
-OPENVIDU_COTURN_IP=127.0.0.1
-OPENVIDU_COTURN_REDIS_DATABASE=2
-OPENVIDU_COTURN_REDIS_IP=127.0.0.1
-OPENVIDU_COTURN_REDIS_PASSWORD=turn
-# Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL
-OPENVIDU_PUBLIC_IP=127.0.0.1
-OPENVIDU_PUBLIC_PORT=3478
-OPENVIDU_SERVER_PORT=8080
-OPENVIDU_WEBHOOK=true
-OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu
-
-# "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/
-#OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged]
-#OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"]
+COTURN_PUBLIC_IP=127.0.0.1
+COTURN_STATIC_SECRET="Welcome2KolabSystems"
+
+MEET_WEBHOOK_TOKEN=Welcome2KolabSystems
+MEET_SERVER_TOKEN=Welcome2KolabSystems
+MEET_SERVER_URLS=https://localhost:12443/meetmedia/api/
+MEET_SERVER_VERIFY_TLS=true
+
+MEET_WEBRTC_LISTEN_IP=
+MEET_PUBLIC_DOMAIN=127.0.0.1:12443
+MEET_TURN_SERVER='turn:127.0.0.1:3478?transport=tcp'
PGP_ENABLED=
PGP_BINARY=
diff --git a/src/app/Console/Commands/OpenVidu/RoomCreate.php b/src/app/Console/Commands/Meet/RoomCreate.php
rename from src/app/Console/Commands/OpenVidu/RoomCreate.php
rename to src/app/Console/Commands/Meet/RoomCreate.php
--- a/src/app/Console/Commands/OpenVidu/RoomCreate.php
+++ b/src/app/Console/Commands/Meet/RoomCreate.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Console\Commands\OpenVidu;
+namespace App\Console\Commands\Meet;
use App\Console\Command;
@@ -11,7 +11,7 @@
*
* @var string
*/
- protected $signature = 'openvidu:room-create {user} {room}';
+ protected $signature = 'meet:room-create {user} {room}';
/**
* The console command description.
@@ -40,18 +40,16 @@
return 1;
}
- $room = \App\OpenVidu\Room::where('name', $roomName)->first();
+ $room = \App\Meet\Room::where('name', $roomName)->first();
if ($room) {
$this->error("Room already exists.");
return 1;
}
- \App\OpenVidu\Room::create(
- [
+ \App\Meet\Room::create([
'name' => $roomName,
'user_id' => $user->id
- ]
- );
+ ]);
}
}
diff --git a/src/app/Console/Commands/OpenVidu/Rooms.php b/src/app/Console/Commands/Meet/Rooms.php
rename from src/app/Console/Commands/OpenVidu/Rooms.php
rename to src/app/Console/Commands/Meet/Rooms.php
--- a/src/app/Console/Commands/OpenVidu/Rooms.php
+++ b/src/app/Console/Commands/Meet/Rooms.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Console\Commands\OpenVidu;
+namespace App\Console\Commands\Meet;
use Illuminate\Console\Command;
@@ -11,14 +11,14 @@
*
* @var string
*/
- protected $signature = 'openvidu:rooms';
+ protected $signature = 'meet:rooms';
/**
* The console command description.
*
* @var string
*/
- protected $description = 'List OpenVidu rooms';
+ protected $description = 'List Meet rooms';
/**
* Execute the console command.
@@ -27,7 +27,7 @@
*/
public function handle()
{
- $rooms = \App\OpenVidu\Room::all();
+ $rooms = \App\Meet\Room::all();
foreach ($rooms as $room) {
$this->info("{$room->name}");
diff --git a/src/app/Console/Commands/Meet/Sessions.php b/src/app/Console/Commands/Meet/Sessions.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Meet/Sessions.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Console\Commands\Meet;
+
+use Illuminate\Console\Command;
+
+class Sessions extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'meet:sessions';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'List Meet sessions';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $client = new \GuzzleHttp\Client([
+ 'http_errors' => false, // No exceptions from Guzzle
+ 'base_uri' => \config('meet.api_url'),
+ 'verify' => \config('meet.api_verify_tls'),
+ 'headers' => [
+ 'X-Auth-Token' => \config('meet.api_token'),
+ ],
+ 'connect_timeout' => 10,
+ 'timeout' => 10,
+ ]);
+
+ $response = $client->request('GET', 'sessions');
+
+ if ($response->getStatusCode() !== 200) {
+ return 1;
+ }
+
+ $sessions = json_decode($response->getBody(), true);
+
+ foreach ($sessions as $session) {
+ $room = \App\Meet\Room::where('session_id', $session['roomId'])->first();
+ if ($room) {
+ $owner = $room->owner->email;
+ $roomName = $room->name;
+ } else {
+ $owner = '(none)';
+ $roomName = '(none)';
+ }
+
+ $this->info(
+ sprintf(
+ "Session: %s for %s since %s (by %s)",
+ $session['roomId'],
+ $roomName,
+ \Carbon\Carbon::parse($session['createdAt'], 'UTC'),
+ $owner
+ )
+ );
+ }
+ }
+}
diff --git a/src/app/Console/Commands/OpenVidu/Sessions.php b/src/app/Console/Commands/OpenVidu/Sessions.php
deleted file mode 100644
--- a/src/app/Console/Commands/OpenVidu/Sessions.php
+++ /dev/null
@@ -1,72 +0,0 @@
-<?php
-
-namespace App\Console\Commands\OpenVidu;
-
-use Illuminate\Console\Command;
-
-class Sessions extends Command
-{
- /**
- * The name and signature of the console command.
- *
- * @var string
- */
- protected $signature = 'openvidu:sessions';
-
- /**
- * The console command description.
- *
- * @var string
- */
- protected $description = 'List OpenVidu sessions';
-
- /**
- * Execute the console command.
- *
- * @return mixed
- */
- public function handle()
- {
- // curl -X GET -k -u OPENVIDUAPP:MY_SECRET https://localhost:4443/api/sessions, json
-
- $client = new \GuzzleHttp\Client(
- [
- 'base_uri' => \config('openvidu.api_url'),
- 'verify' => \config('openvidu.api_verify_tls')
- ]
- );
-
- $response = $client->request(
- 'GET',
- 'sessions',
- ['auth' => [\config('openvidu.api_username'), \config('openvidu.api_password')]]
- );
-
- if ($response->getStatusCode() !== 200) {
- return 1;
- }
-
- $sessionResponse = json_decode($response->getBody(), true);
-
- foreach ($sessionResponse['content'] as $session) {
- $room = \App\OpenVidu\Room::where('session_id', $session['sessionId'])->first();
- if ($room) {
- $owner = $room->owner->email;
- $roomName = $room->name;
- } else {
- $owner = '(none)';
- $roomName = '(none)';
- }
-
- $this->info(
- sprintf(
- "Session: %s for %s since %s (by %s)",
- $session['sessionId'],
- $roomName,
- \Carbon\Carbon::parse((int)substr($session['createdAt'], 0, 10), 'UTC'),
- $owner
- )
- );
- }
- }
-}
diff --git a/src/app/Http/Controllers/API/V4/MeetController.php b/src/app/Http/Controllers/API/V4/MeetController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/MeetController.php
@@ -0,0 +1,293 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Meet\Room;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Validator;
+
+class MeetController extends Controller
+{
+ /**
+ * Listing of rooms that belong to the authenticated user.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = Auth::guard()->user();
+
+ $rooms = Room::where('user_id', $user->id)->orderBy('name')->get();
+
+ if (count($rooms) == 0) {
+ // Create a room for the user (with a random and unique name)
+ while (true) {
+ $name = strtolower(\App\Utils::randStr(3, 3, '-'));
+ if (!Room::where('name', $name)->count()) {
+ break;
+ }
+ }
+
+ $room = Room::create([
+ 'name' => $name,
+ 'user_id' => $user->id
+ ]);
+
+ $rooms = collect([$room]);
+ }
+
+ $result = [
+ 'list' => $rooms,
+ 'count' => count($rooms),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Join the room session. Each room has one owner, and the room isn't open until the owner
+ * joins (and effectively creates the session).
+ *
+ * @param string $id Room identifier (name)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function joinRoom($id)
+ {
+ $room = Room::where('name', $id)->first();
+
+ // Room does not exist, or the owner is deleted
+ if (!$room || !$room->owner || $room->owner->isDegraded(true)) {
+ return $this->errorResponse(404, \trans('meet.room-not-found'));
+ }
+
+ // Check if there's still a valid meet entitlement for the room owner
+ if (!$room->owner->hasSku('meet')) {
+ return $this->errorResponse(404, \trans('meet.room-not-found'));
+ }
+
+ $user = Auth::guard()->user();
+ $isOwner = $user && $user->id == $room->user_id;
+ $init = !empty(request()->input('init'));
+
+ // There's no existing session
+ if (!$room->hasSession()) {
+ // Participants can't join the room until the session is created by the owner
+ if (!$isOwner) {
+ return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]);
+ }
+
+ // The room owner can create the session on request
+ if (!$init) {
+ return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]);
+ }
+
+ $session = $room->createSession();
+
+ if (empty($session)) {
+ return $this->errorResponse(500, \trans('meet.session-create-error'));
+ }
+ }
+
+ $settings = $room->getSettings(['locked', 'nomedia', 'password']);
+ $password = (string) $settings['password'];
+
+ $config = [
+ 'locked' => $settings['locked'] === 'true',
+ 'nomedia' => $settings['nomedia'] === 'true',
+ 'password' => $isOwner ? $password : '',
+ 'requires_password' => !$isOwner && strlen($password),
+ ];
+
+ $response = ['config' => $config];
+
+ // Validate room password
+ if (!$isOwner && strlen($password)) {
+ $request_password = request()->input('password');
+ if ($request_password !== $password) {
+ return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]);
+ }
+ }
+
+ // Handle locked room
+ if (!$isOwner && $config['locked']) {
+ $nickname = request()->input('nickname');
+ $picture = request()->input('picture');
+ $requestId = request()->input('requestId');
+
+ $request = $requestId ? $room->requestGet($requestId) : null;
+
+ $error = \trans('meet.session-room-locked-error');
+
+ // Request already has been processed (not accepted yet, but it could be denied)
+ if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) {
+ if (!$request) {
+ if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) {
+ return $this->errorResponse(422, $error, $response + ['code' => 326]);
+ }
+
+ if (empty($picture)) {
+ $svg = file_get_contents(resource_path('images/user.svg'));
+ $picture = 'data:image/svg+xml;base64,' . base64_encode($svg);
+ } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) {
+ return $this->errorResponse(422, $error, $response + ['code' => 326]);
+ }
+
+ // TODO: Resize when big/make safe the user picture?
+
+ $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture];
+
+ if (!$room->requestSave($requestId, $request)) {
+ // FIXME: should we use error code 500?
+ return $this->errorResponse(422, $error, $response + ['code' => 326]);
+ }
+
+ // Send the request (signal) to all moderators
+ $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR);
+ }
+
+ return $this->errorResponse(422, $error, $response + ['code' => 327]);
+ }
+ }
+
+ // Initialize connection tokens
+ if ($init) {
+ // Choose the connection role
+ $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner);
+ $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER;
+ if ($isOwner) {
+ $role |= Room::ROLE_MODERATOR;
+ $role |= Room::ROLE_OWNER;
+ }
+
+ // Create session token for the current user/connection
+ $response = $room->getSessionToken($role);
+
+ if (empty($response)) {
+ return $this->errorResponse(500, \trans('meet.session-join-error'));
+ }
+
+ $response_code = 200;
+ $response['role'] = $role;
+ $response['config'] = $config;
+ } else {
+ $response_code = 422;
+ $response['code'] = 322;
+ }
+
+ return response()->json($response, $response_code);
+ }
+
+ /**
+ * Set the domain configuration.
+ *
+ * @param string $id Room identifier (name)
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setRoomConfig($id)
+ {
+ $room = Room::where('name', $id)->first();
+
+ // Room does not exist, or the owner is deleted
+ if (!$room || !$room->owner || $room->owner->isDegraded(true)) {
+ return $this->errorResponse(404);
+ }
+
+ $user = Auth::guard()->user();
+
+ // Only room owner can configure the room
+ if ($user->id != $room->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ $input = request()->input();
+ $errors = [];
+
+ foreach ($input as $key => $value) {
+ switch ($key) {
+ case 'password':
+ if ($value === null || $value === '') {
+ $input[$key] = null;
+ } else {
+ // TODO: Do we have to validate the password in any way?
+ }
+ break;
+
+ case 'locked':
+ $input[$key] = $value ? 'true' : null;
+ break;
+
+ case 'nomedia':
+ $input[$key] = $value ? 'true' : null;
+ break;
+
+ default:
+ $errors[$key] = \trans('meet.room-unsupported-option-error');
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ if (!empty($input)) {
+ $room->setSettings($input);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('meet.room-setconfig-success'),
+ ]);
+ }
+
+ /**
+ * Webhook as triggered from the Meet server
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\Response The response
+ */
+ public function webhook(Request $request)
+ {
+ \Log::debug($request->getContent());
+
+ // Authenticate the request
+ if ($request->headers->get('X-Auth-Token') != \config('meet.webhook_token')) {
+ return response('Unauthorized', 403);
+ }
+
+ $sessionId = (string) $request->input('roomId');
+ $event = (string) $request->input('event');
+
+ switch ($event) {
+ case 'roomClosed':
+ // When all participants left the room the server will dispatch roomClosed
+ // event. We'll remove the session reference from the database.
+ $room = Room::where('session_id', $sessionId)->first();
+
+ if ($room) {
+ $room->session_id = null;
+ $room->save();
+ }
+
+ break;
+
+ case 'joinRequestAccepted':
+ case 'joinRequestDenied':
+ $room = Room::where('session_id', $sessionId)->first();
+
+ if ($room) {
+ $method = $event == 'joinRequestAccepted' ? 'requestAccept' : 'requestDeny';
+
+ $room->{$method}($request->input('requestId'));
+ }
+
+ break;
+ }
+
+ return response('Success', 200);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
deleted file mode 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ /dev/null
@@ -1,590 +0,0 @@
-<?php
-
-namespace App\Http\Controllers\API\V4;
-
-use App\Http\Controllers\Controller;
-use App\OpenVidu\Connection;
-use App\OpenVidu\Room;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Validator;
-
-class OpenViduController extends Controller
-{
- public const AUTH_HEADER = 'X-Meet-Auth-Token';
-
- /**
- * Accept the room join request.
- *
- * @param string $id Room identifier (name)
- * @param string $reqid Request identifier
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function acceptJoinRequest($id, $reqid)
- {
- $room = Room::where('name', $id)->first();
-
- // This isn't a room, bye bye
- if (!$room) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- // Only the moderator can do it
- if (!$this->isModerator($room)) {
- return $this->errorResponse(403);
- }
-
- if (!$room->requestAccept($reqid)) {
- return $this->errorResponse(500, \trans('meet.session-request-accept-error'));
- }
-
- return response()->json(['status' => 'success']);
- }
-
- /**
- * Deny the room join request.
- *
- * @param string $id Room identifier (name)
- * @param string $reqid Request identifier
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function denyJoinRequest($id, $reqid)
- {
- $room = Room::where('name', $id)->first();
-
- // This isn't a room, bye bye
- if (!$room) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- // Only the moderator can do it
- if (!$this->isModerator($room)) {
- return $this->errorResponse(403);
- }
-
- if (!$room->requestDeny($reqid)) {
- return $this->errorResponse(500, \trans('meet.session-request-deny-error'));
- }
-
- return response()->json(['status' => 'success']);
- }
-
- /**
- * Close the room session.
- *
- * @param string $id Room identifier (name)
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function closeRoom($id)
- {
- $room = Room::where('name', $id)->first();
-
- // This isn't a room, bye bye
- if (!$room) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- $user = Auth::guard()->user();
-
- // Only the room owner can do it
- if (!$user || $user->id != $room->user_id) {
- return $this->errorResponse(403);
- }
-
- if (!$room->deleteSession()) {
- return $this->errorResponse(500, \trans('meet.session-close-error'));
- }
-
- return response()->json([
- 'status' => 'success',
- 'message' => __('meet.session-close-success'),
- ]);
- }
-
- /**
- * Create a connection for screen sharing.
- *
- * @param string $id Room identifier (name)
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function createConnection($id)
- {
- $room = Room::where('name', $id)->first();
-
- // This isn't a room, bye bye
- if (!$room) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- $connection = $this->getConnectionFromRequest();
-
- if (
- !$connection
- || $connection->session_id != $room->session_id
- || ($connection->role & Room::ROLE_PUBLISHER) == 0
- ) {
- return $this->errorResponse(403);
- }
-
- $response = $room->getSessionToken(Room::ROLE_SCREEN);
-
- return response()->json(['status' => 'success', 'token' => $response['token']]);
- }
-
- /**
- * Dismiss the participant/connection from the session.
- *
- * @param string $id Room identifier (name)
- * @param string $conn Connection identifier
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function dismissConnection($id, $conn)
- {
- $connection = Connection::where('id', $conn)->first();
-
- // There's no such connection, bye bye
- if (!$connection || $connection->room->name != $id) {
- return $this->errorResponse(404, \trans('meet.connection-not-found'));
- }
-
- // Only the moderator can do it
- if (!$this->isModerator($connection->room)) {
- return $this->errorResponse(403);
- }
-
- if (!$connection->dismiss()) {
- return $this->errorResponse(500, \trans('meet.connection-dismiss-error'));
- }
-
- return response()->json(['status' => 'success']);
- }
-
- /**
- * Listing of rooms that belong to the authenticated user.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function index()
- {
- $user = Auth::guard()->user();
-
- $rooms = Room::where('user_id', $user->id)->orderBy('name')->get();
-
- if (count($rooms) == 0) {
- // Create a room for the user (with a random and unique name)
- while (true) {
- $name = strtolower(\App\Utils::randStr(3, 3, '-'));
- if (!Room::where('name', $name)->count()) {
- break;
- }
- }
-
- $room = Room::create([
- 'name' => $name,
- 'user_id' => $user->id
- ]);
-
- $rooms = collect([$room]);
- }
-
- $result = [
- 'list' => $rooms,
- 'count' => count($rooms),
- ];
-
- return response()->json($result);
- }
-
- /**
- * Join the room session. Each room has one owner, and the room isn't open until the owner
- * joins (and effectively creates the session).
- *
- * @param string $id Room identifier (name)
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function joinRoom($id)
- {
- $room = Room::where('name', $id)->first();
-
- // Room does not exist, or the owner is deleted
- if (!$room || !$room->owner || $room->owner->isDegraded(true)) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- // Check if there's still a valid meet entitlement for the room owner
- if (!$room->owner->hasSku('meet')) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- $user = Auth::guard()->user();
- $isOwner = $user && $user->id == $room->user_id;
- $init = !empty(request()->input('init'));
-
- // There's no existing session
- if (!$room->hasSession()) {
- // Participants can't join the room until the session is created by the owner
- if (!$isOwner) {
- return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]);
- }
-
- // The room owner can create the session on request
- if (!$init) {
- return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]);
- }
-
- $session = $room->createSession();
-
- if (empty($session)) {
- return $this->errorResponse(500, \trans('meet.session-create-error'));
- }
- }
-
- $settings = $room->getSettings(['locked', 'nomedia', 'password']);
- $password = (string) $settings['password'];
-
- $config = [
- 'locked' => $settings['locked'] === 'true',
- 'nomedia' => $settings['nomedia'] === 'true',
- 'password' => $isOwner ? $password : '',
- 'requires_password' => !$isOwner && strlen($password),
- ];
-
- $response = ['config' => $config];
-
- // Validate room password
- if (!$isOwner && strlen($password)) {
- $request_password = request()->input('password');
- if ($request_password !== $password) {
- return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]);
- }
- }
-
- // Handle locked room
- if (!$isOwner && $config['locked']) {
- $nickname = request()->input('nickname');
- $picture = request()->input('picture');
- $requestId = request()->input('requestId');
-
- $request = $requestId ? $room->requestGet($requestId) : null;
-
- $error = \trans('meet.session-room-locked-error');
-
- // Request already has been processed (not accepted yet, but it could be denied)
- if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) {
- if (!$request) {
- if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) {
- return $this->errorResponse(422, $error, $response + ['code' => 326]);
- }
-
- if (empty($picture)) {
- $svg = file_get_contents(resource_path('images/user.svg'));
- $picture = 'data:image/svg+xml;base64,' . base64_encode($svg);
- } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) {
- return $this->errorResponse(422, $error, $response + ['code' => 326]);
- }
-
- // TODO: Resize when big/make safe the user picture?
-
- $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture];
-
- if (!$room->requestSave($requestId, $request)) {
- // FIXME: should we use error code 500?
- return $this->errorResponse(422, $error, $response + ['code' => 326]);
- }
-
- // Send the request (signal) to the owner
- $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR);
- }
-
- return $this->errorResponse(422, $error, $response + ['code' => 327]);
- }
- }
-
- // Initialize connection tokens
- if ($init) {
- // Choose the connection role
- $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner);
- $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER;
- if ($isOwner) {
- $role |= Room::ROLE_MODERATOR;
- $role |= Room::ROLE_OWNER;
- }
-
- // Create session token for the current user/connection
- $response = $room->getSessionToken($role);
-
- if (empty($response)) {
- return $this->errorResponse(500, \trans('meet.session-join-error'));
- }
-
- // Get up-to-date connections metadata
- $response['connections'] = $room->getSessionConnections();
-
- $response_code = 200;
- $response['role'] = $role;
- $response['config'] = $config;
- } else {
- $response_code = 422;
- $response['code'] = 322;
- }
-
- return response()->json($response, $response_code);
- }
-
- /**
- * Set the domain configuration.
- *
- * @param string $id Room identifier (name)
- *
- * @return \Illuminate\Http\JsonResponse|void
- */
- public function setRoomConfig($id)
- {
- $room = Room::where('name', $id)->first();
-
- // Room does not exist, or the owner is deleted
- if (!$room || !$room->owner || $room->owner->isDegraded(true)) {
- return $this->errorResponse(404);
- }
-
- $user = Auth::guard()->user();
-
- // Only room owner can configure the room
- if ($user->id != $room->user_id) {
- return $this->errorResponse(403);
- }
-
- $input = request()->input();
- $errors = [];
-
- foreach ($input as $key => $value) {
- switch ($key) {
- case 'password':
- if ($value === null || $value === '') {
- $input[$key] = null;
- } else {
- // TODO: Do we have to validate the password in any way?
- }
- break;
-
- case 'locked':
- $input[$key] = $value ? 'true' : null;
- break;
-
- case 'nomedia':
- $input[$key] = $value ? 'true' : null;
- break;
-
- default:
- $errors[$key] = \trans('meet.room-unsupported-option-error');
- }
- }
-
- if (!empty($errors)) {
- return response()->json(['status' => 'error', 'errors' => $errors], 422);
- }
-
- if (!empty($input)) {
- $room->setSettings($input);
- }
-
- return response()->json([
- 'status' => 'success',
- 'message' => \trans('meet.room-setconfig-success'),
- ]);
- }
-
- /**
- * Update the participant/connection parameters (e.g. role).
- *
- * @param string $id Room identifier (name)
- * @param string $conn Connection identifier
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function updateConnection($id, $conn)
- {
- $connection = Connection::where('id', $conn)->first();
-
- // There's no such connection, bye bye
- if (!$connection || $connection->room->name != $id) {
- return $this->errorResponse(404, \trans('meet.connection-not-found'));
- }
-
- foreach (request()->input() as $key => $value) {
- switch ($key) {
- case 'hand':
- // Only possible on user's own connection(s)
- if (!$this->isSelfConnection($connection)) {
- return $this->errorResponse(403);
- }
-
- if ($value) {
- // Store current time, so we know the order in the queue
- $connection->metadata = ['hand' => time()] + $connection->metadata;
- } else {
- $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
- }
-
- break;
-
- case 'language':
- // Only the moderator can do it
- if (!$this->isModerator($connection->room)) {
- return $this->errorResponse(403);
- }
-
- if ($value) {
- if (preg_match('/^[a-z]{2}$/', $value)) {
- $connection->metadata = ['language' => $value] + $connection->metadata;
- }
- } else {
- $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
- }
-
- break;
-
- case 'role':
- // Only the moderator can do it
- if (!$this->isModerator($connection->room)) {
- return $this->errorResponse(403);
- }
-
- // The 'owner' role is not assignable
- if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) {
- return $this->errorResponse(403);
- } elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) {
- return $this->errorResponse(403);
- }
-
- // The room owner has always a 'moderator' role
- if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) {
- $value |= Room::ROLE_MODERATOR;
- }
-
- // Promotion to publisher? Put the user hand down
- if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) {
- $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
- }
-
- // Non-publisher cannot be a language interpreter
- if (!($value & Room::ROLE_PUBLISHER)) {
- $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
- }
-
- $connection->{$key} = $value;
- break;
- }
- }
-
- // The connection observer will send a signal to everyone when needed
- $connection->save();
-
- return response()->json(['status' => 'success']);
- }
-
- /**
- * Webhook as triggered from OpenVidu server
- *
- * @param \Illuminate\Http\Request $request The API request.
- *
- * @return \Illuminate\Http\Response The response
- */
- public function webhook(Request $request)
- {
- \Log::debug($request->getContent());
-
- switch ((string) $request->input('event')) {
- case 'sessionDestroyed':
- // When all participants left the room OpenVidu dispatches sessionDestroyed
- // event. We'll remove the session reference from the database.
- $sessionId = $request->input('sessionId');
- $room = Room::where('session_id', $sessionId)->first();
-
- if ($room) {
- $room->session_id = null;
- $room->save();
- }
-
- // Remove all connections
- // Note: We could remove connections one-by-one via the 'participantLeft' event
- // but that could create many INSERTs when the session (with many participants) ends
- // So, it is better to remove them all in a single INSERT.
- Connection::where('session_id', $sessionId)->delete();
-
- break;
- }
-
- return response('Success', 200);
- }
-
- /**
- * Check if current user is a moderator for the specified room.
- *
- * @param \App\OpenVidu\Room $room The room
- *
- * @return bool True if the current user is the room moderator
- */
- protected function isModerator(Room $room): bool
- {
- $user = Auth::guard()->user();
-
- // The room owner is a moderator
- if ($user && $user->id == $room->user_id) {
- return true;
- }
-
- // Moderator's authentication via the extra request header
- if (
- ($connection = $this->getConnectionFromRequest())
- && $connection->session_id === $room->session_id
- && $connection->role & Room::ROLE_MODERATOR
- ) {
- return true;
- }
-
- return false;
- }
-
- /**
- * Check if current user "owns" the specified connection.
- *
- * @param \App\OpenVidu\Connection $connection The connection
- *
- * @return bool
- */
- protected function isSelfConnection(Connection $connection): bool
- {
- return ($conn = $this->getConnectionFromRequest())
- && $conn->id === $connection->id;
- }
-
- /**
- * Get the connection object for the token in current request headers.
- * It will also validate the token.
- *
- * @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid)
- */
- protected function getConnectionFromRequest()
- {
- // Authenticate the user via the extra request header
- if ($token = request()->header(self::AUTH_HEADER)) {
- list($connId, ) = explode(':', base64_decode($token), 2);
-
- if (
- ($connection = Connection::find($connId))
- && $connection->metadata['authToken'] === $token
- ) {
- return $connection;
- }
- }
-
- return null;
- }
-}
diff --git a/src/app/OpenVidu/Room.php b/src/app/Meet/Room.php
rename from src/app/OpenVidu/Room.php
rename to src/app/Meet/Room.php
--- a/src/app/OpenVidu/Room.php
+++ b/src/app/Meet/Room.php
@@ -1,6 +1,6 @@
<?php
-namespace App\OpenVidu;
+namespace App\Meet;
use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +12,7 @@
* @property int $id Room identifier
* @property string $name Room name
* @property int $user_id Room owner
- * @property ?string $session_id OpenVidu session identifier
+ * @property ?string $session_id Meet session identifier
*/
class Room extends Model
{
@@ -41,24 +41,52 @@
/** @var \GuzzleHttp\Client|null HTTP client instance */
private static $client = null;
+ /**
+ * Select a Meet server for this room
+ *
+ * This needs to always result in the same server for the same room,
+ * so all participants end up on the same server.
+ *
+ * @return string The server url
+ */
+ private function selectMeetServer()
+ {
+ $urls = \config('meet.api_urls');
+
+ $count = count($urls);
+
+ if ($count == 0) {
+ \Log::error("No meet server configured.");
+ return "";
+ }
+
+ //Select a random backend.
+ //If the names are evenly distributed this should theoretically result in an even distribution.
+ $index = abs(intval(hash("crc32b", $this->name), 16) % $count);
+
+ return $urls[$index];
+ }
/**
- * Creates HTTP client for connections to OpenVidu server
+ * Creates HTTP client for connections to Meet server
*
* @return \GuzzleHttp\Client HTTP client instance
*/
private function client()
{
if (!self::$client) {
+ $url = $this->selectMeetServer();
+
self::$client = new \GuzzleHttp\Client(
[
'http_errors' => false, // No exceptions from Guzzle
- 'base_uri' => \config('openvidu.api_url'),
- 'verify' => \config('openvidu.api_verify_tls'),
- 'auth' => [
- \config('openvidu.api_username'),
- \config('openvidu.api_password')
+ 'base_uri' => $url,
+ 'verify' => \config('meet.api_verify_tls'),
+ 'headers' => [
+ 'X-Auth-Token' => \config('meet.api_token'),
],
+ 'connect_timeout' => 10,
+ 'timeout' => 10,
'on_stats' => function (\GuzzleHttp\TransferStats $stats) {
$threshold = \config('logging.slow_log');
if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) {
@@ -75,72 +103,20 @@
}
/**
- * Destroy a OpenVidu connection
- *
- * @param string $conn Connection identifier
- *
- * @return bool True on success, False otherwise
- * @throws \Exception if session does not exist
- */
- public function closeOVConnection($conn): bool
- {
- if (!$this->session_id) {
- throw new \Exception("The room session does not exist");
- }
-
- $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn);
-
- $response = $this->client()->request('DELETE', $url);
-
- return $response->getStatusCode() == 204;
- }
-
- /**
- * Fetch a OpenVidu connection information.
- *
- * @param string $conn Connection identifier
- *
- * @return ?array Connection data on success, Null otherwise
- * @throws \Exception if session does not exist
- */
- public function getOVConnection($conn): ?array
- {
- // Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists
- // TODO: Maybe use some other name? getParticipant?
- if (!$this->session_id) {
- throw new \Exception("The room session does not exist");
- }
-
- $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn);
-
- $response = $this->client()->request('GET', $url);
-
- if ($response->getStatusCode() == 200) {
- return json_decode($response->getBody(), true);
- }
-
- return null;
- }
-
- /**
- * Create a OpenVidu session
+ * Create a Meet session
*
* @return array|null Session data on success, NULL otherwise
*/
public function createSession(): ?array
{
- $response = $this->client()->request(
- 'POST',
- "sessions",
- [
- 'json' => [
- 'mediaMode' => 'ROUTED',
- 'recordingMode' => 'MANUAL'
- ]
- ]
- );
+ $params = [
+ 'json' => [ /* request params here */ ]
+ ];
+
+ $response = $this->client()->request('POST', "sessions", $params);
if ($response->getStatusCode() !== 200) {
+ $this->logError("Failed to create the meet session", $response);
$this->session_id = null;
$this->save();
return null;
@@ -155,65 +131,7 @@
}
/**
- * Delete a OpenVidu session
- *
- * @return bool
- */
- public function deleteSession(): bool
- {
- if (!$this->session_id) {
- return true;
- }
-
- $response = $this->client()->request(
- 'DELETE',
- "sessions/" . $this->session_id,
- );
-
- if ($response->getStatusCode() == 204) {
- $this->session_id = null;
- $this->save();
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Returns metadata for every connection in a session.
- *
- * @return array Connections metadata, indexed by connection identifier
- * @throws \Exception if session does not exist
- */
- public function getSessionConnections(): array
- {
- if (!$this->session_id) {
- throw new \Exception("The room session does not exist");
- }
-
- return Connection::where('session_id', $this->session_id)
- // Ignore screen sharing connection for now
- ->whereRaw("(role & " . self::ROLE_SCREEN . ") = 0")
- ->get()
- ->keyBy('id')
- ->map(function ($item) {
- // Warning: Make sure to not return all metadata here as it might contain sensitive data.
- return [
- 'role' => $item->role,
- 'hand' => $item->metadata['hand'] ?? 0,
- 'language' => $item->metadata['language'] ?? null,
- ];
- })
- // Sort by order in the queue, so UI can re-build the existing queue in order
- ->sort(function ($a, $b) {
- return $a['hand'] <=> $b['hand'];
- })
- ->all();
- }
-
- /**
- * Create a OpenVidu session (connection) token
+ * Create a Meet session (connection) token
*
* @param int $role User role (see self::ROLE_* constants)
*
@@ -226,17 +144,10 @@
throw new \Exception("The room session does not exist");
}
- // FIXME: Looks like passing the role in 'data' param is the only way
- // to make it visible for everyone in a room. So, for example we can
- // handle/style subscribers/publishers/moderators differently on the
- // client-side. Is this a security issue?
- $data = ['role' => $role];
-
$url = 'sessions/' . $this->session_id . '/connection';
$post = [
'json' => [
- 'role' => self::OV_ROLE_PUBLISHER,
- 'data' => json_encode($data)
+ 'role' => $role,
]
];
@@ -245,32 +156,14 @@
if ($response->getStatusCode() == 200) {
$json = json_decode($response->getBody(), true);
- $authToken = base64_encode($json['id'] . ':' . \random_bytes(16));
-
- // Extract the 'token' part of the token, it will be used to authenticate the connection.
- // It will be needed in next iterations e.g. to authenticate moderators that aren't
- // Kolab4 users (or are just not logged in to Kolab4).
- // FIXME: we could as well generate our own token for auth purposes
- parse_str(parse_url($json['token'], PHP_URL_QUERY), $url);
-
- // Create the connection reference in our database
- $conn = new Connection();
- $conn->id = $json['id'];
- $conn->session_id = $this->session_id;
- $conn->room_id = $this->id;
- $conn->role = $role;
- $conn->metadata = ['token' => $url['token'], 'authToken' => $authToken];
- $conn->save();
-
return [
- 'session' => $this->session_id,
'token' => $json['token'],
- 'authToken' => $authToken,
- 'connectionId' => $json['id'],
'role' => $role,
];
}
+ $this->logError("Failed to create the meet peer connection", $response);
+
return null;
}
@@ -287,6 +180,8 @@
$response = $this->client()->request('GET', "sessions/{$this->session_id}");
+ $this->logError("Failed to check that a meet session exists", $response);
+
return $response->getStatusCode() == 200;
}
@@ -376,16 +271,15 @@
*/
public function settings()
{
- return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id');
+ return $this->hasMany('App\Meet\RoomSetting', 'room_id');
}
/**
- * Send a OpenVidu signal to the session participants (connections)
+ * Send a signal to the Meet session participants (peers)
*
- * @param string $name Signal name (type)
- * @param array $data Signal data array
- * @param null|int|string[] $target List of target connections, Null for all connections.
- * It can be also a participant role.
+ * @param string $name Signal name (type)
+ * @param array $data Signal data array
+ * @param int $target Limit targets by their participant role
*
* @return bool True on success, False on failure
* @throws \Exception if session does not exist
@@ -397,31 +291,30 @@
}
$post = [
- 'session' => $this->session_id,
- 'type' => $name,
- 'data' => $data ? json_encode($data) : '',
+ 'roomId' => $this->session_id,
+ 'type' => $name,
+ 'role' => $target,
+ 'data' => $data,
];
- // Get connection IDs by participant role
- if (is_int($target)) {
- $connections = Connection::where('session_id', $this->session_id)
- ->whereRaw("(role & $target)")
- ->pluck('id')
- ->all();
+ $response = $this->client()->request('POST', 'signal', ['json' => $post]);
- if (empty($connections)) {
- return false;
- }
+ $this->logError("Failed to send a signal to the meet session", $response);
- $target = $connections;
- }
+ return $response->getStatusCode() == 200;
+ }
- if (!empty($target)) {
- $post['to'] = $target;
+ /**
+ * Log an error for a failed request to the meet server
+ *
+ * @param string $str The error string
+ * @param object $response Guzzle client response
+ */
+ private function logError(string $str, $response)
+ {
+ $code = $response->getStatusCode();
+ if ($code != 200) {
+ \Log::error("$str [$code]");
}
-
- $response = $this->client()->request('POST', 'signal', ['json' => $post]);
-
- return $response->getStatusCode() == 200;
}
}
diff --git a/src/app/OpenVidu/RoomSetting.php b/src/app/Meet/RoomSetting.php
rename from src/app/OpenVidu/RoomSetting.php
rename to src/app/Meet/RoomSetting.php
--- a/src/app/OpenVidu/RoomSetting.php
+++ b/src/app/Meet/RoomSetting.php
@@ -1,6 +1,6 @@
<?php
-namespace App\OpenVidu;
+namespace App\Meet;
use Illuminate\Database\Eloquent\Model;
@@ -27,6 +27,6 @@
*/
public function room()
{
- return $this->belongsTo('\App\OpenVidu\Room', 'room_id', 'id');
+ return $this->belongsTo('\App\Meet\Room', 'room_id', 'id');
}
}
diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php
deleted file mode 100644
--- a/src/app/Observers/OpenVidu/ConnectionObserver.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-
-namespace App\Observers\OpenVidu;
-
-use App\OpenVidu\Connection;
-
-class ConnectionObserver
-{
- /**
- * Handle the OpenVidu connection "updated" event.
- *
- * @param \App\OpenVidu\Connection $connection The connection.
- *
- * @return void
- */
- public function updated(Connection $connection)
- {
- $params = [];
-
- // Role change
- if ($connection->role != $connection->getOriginal('role')) {
- $params['role'] = $connection->role;
-
- // TODO: When demoting publisher to subscriber maybe we should
- // destroy all streams using REST API. For now we trust the
- // participant browser to do this.
- }
-
- // Detect metadata changes for specified properties
- $keys = [
- 'hand' => 'bool',
- 'language' => '',
- ];
-
- foreach ($keys as $key => $type) {
- $newState = $connection->metadata[$key] ?? null;
- $oldState = $this->getOriginal($connection, 'metadata')[$key] ?? null;
-
- if ($newState !== $oldState) {
- $params[$key] = $type == 'bool' ? !empty($newState) : $newState;
- }
- }
-
- // Send the signal to all participants
- if (!empty($params)) {
- $params['connectionId'] = $connection->id;
- $connection->room->signal('connectionUpdate', $params);
- }
- }
-
- /**
- * A wrapper to getOriginal() on an object
- *
- * @param \App\OpenVidu\Connection $connection The connection.
- * @param string $property The property name
- *
- * @return mixed
- */
- private function getOriginal($connection, $property)
- {
- $original = $connection->getOriginal($property);
-
- // The original value for a property is in a format stored in database
- // I.e. for 'metadata' it is a JSON string instead of an array
- if ($property == 'metadata') {
- $original = json_decode($original, true);
- }
-
- return $original;
- }
-}
diff --git a/src/app/OpenVidu/Connection.php b/src/app/OpenVidu/Connection.php
deleted file mode 100644
--- a/src/app/OpenVidu/Connection.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-
-namespace App\OpenVidu;
-
-use Illuminate\Database\Eloquent\Model;
-
-/**
- * The eloquent definition of a Connection.
- *
- * @property string $id OpenVidu connection identifier
- * @property array $metadata Connection metadata
- * @property int $role Connection role
- * @property int $room_id Room identifier
- * @property string $session_id OpenVidu session identifier
- */
-class Connection extends Model
-{
- protected $table = 'openvidu_connections';
-
- public $incrementing = false;
- protected $keyType = 'string';
-
- /**
- * The attributes that should be cast.
- *
- * @var array
- */
- protected $casts = [
- 'metadata' => 'array',
- ];
-
- /**
- * Dismiss (close) the connection.
- *
- * @return bool True on success, False on failure
- */
- public function dismiss()
- {
- if ($this->room->closeOVConnection($this->id)) {
- $this->delete();
-
- return true;
- }
-
- return false;
- }
-
- /**
- * The room to which this connection belongs.
- *
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
- */
- public function room()
- {
- return $this->belongsTo(Room::class, 'room_id', 'id');
- }
-
- /**
- * Connection role mutator
- *
- * @throws \Exception
- */
- public function setRoleAttribute($role)
- {
- $new_role = 0;
-
- $allowed_values = [
- Room::ROLE_SUBSCRIBER,
- Room::ROLE_PUBLISHER,
- Room::ROLE_MODERATOR,
- Room::ROLE_SCREEN,
- Room::ROLE_OWNER,
- ];
-
- foreach ($allowed_values as $value) {
- if ($role & $value) {
- $new_role |= $value;
- $role ^= $value;
- }
- }
-
- if ($role > 0) {
- throw new \Exception("Invalid connection role: {$role}");
- }
-
- // It is either screen sharing connection or publisher/subscriber connection
- if ($new_role & Room::ROLE_SCREEN) {
- if ($new_role & Room::ROLE_PUBLISHER) {
- $new_role ^= Room::ROLE_PUBLISHER;
- }
- if ($new_role & Room::ROLE_SUBSCRIBER) {
- $new_role ^= Room::ROLE_SUBSCRIBER;
- }
- }
-
- $this->attributes['role'] = $new_role;
- }
-}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -48,7 +48,6 @@
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\Group::observe(\App\Observers\GroupObserver::class);
\App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class);
- \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class);
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\Resource::observe(\App\Observers\ResourceObserver::class);
diff --git a/src/config/meet.php b/src/config/meet.php
new file mode 100644
--- /dev/null
+++ b/src/config/meet.php
@@ -0,0 +1,7 @@
+<?php
+ return [
+ 'api_token' => env('MEET_SERVER_TOKEN', 'MY_SECRET'),
+ 'api_urls' => explode(',', env('MEET_SERVER_URLS', "https://localhost:12443/meetmedia/api/,https://localhost:12444/meetmedia/api/")),
+ 'api_verify_tls' => (bool) env('MEET_SERVER_VERIFY_TLS', true),
+ 'webhook_token' => env('MEET_WEBHOOK_TOKEN', 'MY_SECRET'),
+ ];
diff --git a/src/config/openvidu.php b/src/config/openvidu.php
deleted file mode 100644
--- a/src/config/openvidu.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<?php
- return [
- 'api_password' => env('OPENVIDU_API_PASSWORD', 'MY_SECRET'),
- 'api_url' => env('OPENVIDU_API_URL', 'https://localhost:8443/api/'),
- 'api_username' => env('OPENVIDU_API_USERNAME', 'OPENVIDUAPP'),
- 'api_verify_tls' => (bool) env('OPENVIDU_API_VERIFY_TLS', true)
- ];
diff --git a/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php
@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class ExtendOpenviduRoomsSessionId extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'openvidu_rooms',
+ function (Blueprint $table) {
+ $table->string('session_id', 36)->change();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'openvidu_rooms',
+ function (Blueprint $table) {
+ $table->string('session_id', 16)->change();
+ }
+ );
+ }
+}
diff --git a/src/database/seeds/local/OpenViduRoomSeeder.php b/src/database/seeds/local/MeetRoomSeeder.php
rename from src/database/seeds/local/OpenViduRoomSeeder.php
rename to src/database/seeds/local/MeetRoomSeeder.php
--- a/src/database/seeds/local/OpenViduRoomSeeder.php
+++ b/src/database/seeds/local/MeetRoomSeeder.php
@@ -2,10 +2,10 @@
namespace Database\Seeds\Local;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Illuminate\Database\Seeder;
-class OpenViduRoomSeeder extends Seeder
+class MeetRoomSeeder extends Seeder
{
/**
* Run the database seeds.
@@ -17,14 +17,14 @@
$john = \App\User::where('email', 'john@kolab.org')->first();
$jack = \App\User::where('email', 'jack@kolab.org')->first();
- \App\OpenVidu\Room::create(
+ \App\Meet\Room::create(
[
'user_id' => $john->id,
'name' => 'john'
]
);
- \App\OpenVidu\Room::create(
+ \App\Meet\Room::create(
[
'user_id' => $jack->id,
'name' => strtolower(\App\Utils::randStr(3, 3, '-'))
diff --git a/src/package-lock.json b/src/package-lock.json
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -1270,6 +1270,12 @@
"integrity": "sha512-xDu17cEfh7Kid/d95kB6tZsLOmSWKCZKtprnhVepjsSaCij+lM3mItSJDuuHDMbCWTh8Ejmebwb+KONcCJ0eXQ==",
"dev": true
},
+ "@socket.io/component-emitter": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.0.0.tgz",
+ "integrity": "sha512-2pTGuibAXJswAPJjaKisthqS/NOK5ypG4LYT6tEAV0S/mxW0zOIvYvGK0V8w8+SHxAm6vRMSjqSalFXeBAqs+Q==",
+ "dev": true
+ },
"@stylelint/postcss-css-in-js": {
"version": "0.37.2",
"resolved": "https://registry.npmjs.org/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.37.2.tgz",
@@ -1354,6 +1360,15 @@
}
}
},
+ "@types/debug": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
+ "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==",
+ "dev": true,
+ "requires": {
+ "@types/ms": "*"
+ }
+ },
"@types/eslint": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.0.tgz",
@@ -1380,6 +1395,12 @@
"integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==",
"dev": true
},
+ "@types/events": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
+ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==",
+ "dev": true
+ },
"@types/glob": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.4.tgz",
@@ -1472,6 +1493,12 @@
"integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==",
"dev": true
},
+ "@types/ms": {
+ "version": "0.7.31",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
+ "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==",
+ "dev": true
+ },
"@types/node": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz",
@@ -1897,9 +1924,9 @@
"dev": true
},
"ansi-regex": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
- "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {
@@ -2024,13 +2051,19 @@
"postcss-value-parser": "^4.1.0"
}
},
+ "awaitqueue": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-2.3.3.tgz",
+ "integrity": "sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw==",
+ "dev": true
+ },
"axios": {
- "version": "0.21.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
- "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+ "version": "0.21.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
+ "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dev": true,
"requires": {
- "follow-redirects": "^1.10.0"
+ "follow-redirects": "^1.14.0"
}
},
"babel-loader": {
@@ -2084,6 +2117,12 @@
"@babel/helper-define-polyfill-provider": "^0.2.2"
}
},
+ "backo2": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
+ "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=",
+ "dev": true
+ },
"bail": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
@@ -2096,6 +2135,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "base64-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==",
+ "dev": true
+ },
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2199,6 +2244,12 @@
"integrity": "sha512-bs74WNI9BgBo3cEovmdMHikSKoXnDgA6VQjJ7TyTotU6L7d41ZyCEEelPwkYEzsG/Zjv3ie9IE3EMAje0W9Xew==",
"dev": true
},
+ "bowser": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
+ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
+ "dev": true
+ },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -3423,6 +3474,40 @@
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
"dev": true
},
+ "engine.io-client": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.0.2.tgz",
+ "integrity": "sha512-cAep9lhZV6Q8jMXx3TNSU5cydMzMed8/O7Tz5uzyqZvpNPtQ3WQXrLYGADxlsuaFmOLN7wZLmT7ImiFhUOku8g==",
+ "dev": true,
+ "requires": {
+ "@socket.io/component-emitter": "~3.0.0",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.0.0",
+ "has-cors": "1.1.0",
+ "parseqs": "0.0.6",
+ "parseuri": "0.0.6",
+ "ws": "~8.2.3",
+ "xmlhttprequest-ssl": "~2.0.0",
+ "yeast": "0.1.2"
+ },
+ "dependencies": {
+ "ws": {
+ "version": "8.2.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
+ "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
+ "dev": true
+ }
+ }
+ },
+ "engine.io-parser": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.1.tgz",
+ "integrity": "sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==",
+ "dev": true,
+ "requires": {
+ "base64-arraybuffer": "~1.0.1"
+ }
+ },
"enhanced-resolve": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz",
@@ -3668,6 +3753,12 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"dev": true
},
+ "event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "dev": true
+ },
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@@ -3783,6 +3874,24 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"dev": true
},
+ "fake-mediastreamtrack": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-1.1.6.tgz",
+ "integrity": "sha512-lcoO5oPsW57istAsnjvQxNjBEahi18OdUhWfmEewwfPfzNZnji5OXuodQM+VnUPi/1HnQRJ6gBUjbt1TNXrkjQ==",
+ "dev": true,
+ "requires": {
+ "event-target-shim": "^5.0.1",
+ "uuid": "^8.1.0"
+ },
+ "dependencies": {
+ "uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "dev": true
+ }
+ }
+ },
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4218,6 +4327,15 @@
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true
},
+ "h264-profile-level-id": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-1.0.1.tgz",
+ "integrity": "sha512-D3Rln/jKNjKDW5ZTJTK3niSoOGE+pFqPvRHHVgQN3G7umcn/zWGPUo8Q8VpDj16x3hKz++zVviRNRmXu5cpN+Q==",
+ "dev": true,
+ "requires": {
+ "debug": "^4.1.1"
+ }
+ },
"handle-thing": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
@@ -4248,6 +4366,12 @@
"function-bind": "^1.1.1"
}
},
+ "has-cors": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
+ "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=",
+ "dev": true
+ },
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@@ -5420,6 +5544,41 @@
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
"dev": true
},
+ "mediasoup-client": {
+ "version": "3.6.37",
+ "resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.6.37.tgz",
+ "integrity": "sha512-mGP2FNphHLwZApxUMSdWvLYlq6RICQssLFdvcUFIOVmO32++1ZW7eZNBv2oRtpnUY+4t4mPr7K3pOgTxxPyl/A==",
+ "dev": true,
+ "requires": {
+ "@types/debug": "^4.1.7",
+ "@types/events": "^3.0.0",
+ "awaitqueue": "^2.3.3",
+ "bowser": "^2.11.0",
+ "debug": "^4.3.2",
+ "events": "^3.3.0",
+ "fake-mediastreamtrack": "^1.1.6",
+ "h264-profile-level-id": "^1.0.1",
+ "sdp-transform": "^2.14.1",
+ "supports-color": "^8.1.1"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
"mem": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz",
@@ -5878,9 +6037,9 @@
}
},
"nth-check": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz",
- "integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz",
+ "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==",
"dev": true,
"requires": {
"boolbase": "^1.0.0"
@@ -6160,6 +6319,18 @@
"lines-and-columns": "^1.1.6"
}
},
+ "parseqs": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
+ "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==",
+ "dev": true
+ },
+ "parseuri": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
+ "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==",
+ "dev": true
+ },
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -7641,6 +7812,12 @@
"ajv-keywords": "^3.5.2"
}
},
+ "sdp-transform": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz",
+ "integrity": "sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==",
+ "dev": true
+ },
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -7886,6 +8063,30 @@
}
}
},
+ "socket.io-client": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.3.2.tgz",
+ "integrity": "sha512-2B9LqSunN60yV8F7S84CCEEcgbYNfrn7ejIInZtLZ7ppWtiX8rGZAjvdCvbnC8bqo/9RlCNOUsORLyskxSFP1g==",
+ "dev": true,
+ "requires": {
+ "@socket.io/component-emitter": "~3.0.0",
+ "backo2": "~1.0.2",
+ "debug": "~4.3.2",
+ "engine.io-client": "~6.0.1",
+ "parseuri": "0.0.6",
+ "socket.io-parser": "~4.1.1"
+ }
+ },
+ "socket.io-parser": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.1.1.tgz",
+ "integrity": "sha512-USQVLSkDWE5nbcY760ExdKaJxCE65kcsG/8k5FDGZVVxpD1pA7hABYXYkCUvxUuYYh/+uQw0N/fvBzfT8o07KA==",
+ "dev": true,
+ "requires": {
+ "@socket.io/component-emitter": "~3.0.0",
+ "debug": "~4.3.1"
+ }
+ },
"sockjs": {
"version": "0.3.21",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.21.tgz",
@@ -9323,6 +9524,12 @@
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==",
"dev": true
},
+ "xmlhttprequest-ssl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
+ "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
+ "dev": true
+ },
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -9368,6 +9575,12 @@
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true
},
+ "yeast": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
+ "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=",
+ "dev": true
+ },
"yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/src/package.json b/src/package.json
--- a/src/package.json
+++ b/src/package.json
@@ -19,7 +19,7 @@
"@fortawesome/vue-fontawesome": "^0.1.10",
"@popperjs/core": "^2.9.2",
"anchorme": "^2.1.2",
- "axios": "^0.21.1",
+ "axios": "^0.21.4",
"bootstrap": "^5.0.0",
"cash-dom": "^8.1.0",
"cross-env": "^7.0.3",
@@ -27,11 +27,13 @@
"eslint-plugin-vue": "^7.9.0",
"frappe-charts": "^1.5.8",
"laravel-mix": "^6.0.27",
+ "mediasoup-client": "^3.6.37",
"openvidu-browser": "^2.18.0",
"postcss": "^8.3.6",
"resolve-url-loader": "^4.0.0",
"sass": "^1.32.8",
"sass-loader": "^8.0.0",
+ "socket.io-client": "~4.3.2",
"stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0",
"vue": "^2.6.12",
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
deleted file mode 100644
--- a/src/resources/js/meet/app.js
+++ /dev/null
@@ -1,1726 +0,0 @@
-import anchorme from 'anchorme'
-import { Dropdown } from 'bootstrap'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import { OpenVidu } from 'openvidu-browser'
-
-class Roles {
- static get SUBSCRIBER() { return 1 << 0; }
- static get PUBLISHER() { return 1 << 1; }
- static get MODERATOR() { return 1 << 2; }
- static get SCREEN() { return 1 << 3; }
- static get OWNER() { return 1 << 4; }
-}
-
-// Disable jsnlog's error handlers added in OpenVidu 2.18
-// https://github.com/OpenVidu/openvidu/issues/631
-window.onerror = () => { return false }
-window.onunhandledrejection = () => { return false }
-
-function Meet(container)
-{
- let OV // OpenVidu object to initialize a session
- let session // Session object where the user will connect
- let publisher // Publisher object which the user will publish
- let audioActive = false // True if the audio track of the publisher is active
- let videoActive = false // True if the video track of the publisher is active
- let audioSource = '' // Currently selected microphone
- let videoSource = '' // Currently selected camera
- let sessionData // Room session metadata
-
- let screenOV // OpenVidu object to initialize a screen sharing session
- let screenSession // Session object where the user will connect for screen sharing
- let screenPublisher // Publisher object which the user will publish the screen sharing
-
- let publisherDefaults = {
- publishAudio: true, // Whether to start publishing with your audio unmuted or not
- publishVideo: true, // Whether to start publishing with your video enabled or not
- resolution: '640x480', // The resolution of your video
- frameRate: 30, // The frame rate of your video
- mirror: true // Whether to mirror your local video or not
- }
-
- let cameras = [] // List of user video devices
- let microphones = [] // List of user audio devices
- let connections = {} // Connected users in the session
-
- let chatCount = 0
- let volumeElement
- let publishersContainer
- let subscribersContainer
- let scrollStop
- let $t
-
- OV = ovInit()
-
- // Disconnect participant when browser's window close
- window.addEventListener('beforeunload', () => {
- leaveRoom()
- })
-
- window.addEventListener('resize', resize)
-
- // Public methods
- this.isScreenSharingSupported = isScreenSharingSupported
- this.joinRoom = joinRoom
- this.leaveRoom = leaveRoom
- this.setupStart = setupStart
- this.setupStop = setupStop
- this.setupSetAudioDevice = setupSetAudioDevice
- this.setupSetVideoDevice = setupSetVideoDevice
- this.switchAudio = switchAudio
- this.switchChannel = switchChannel
- this.switchScreen = switchScreen
- this.switchVideo = switchVideo
- this.updateSession = updateSession
-
- /**
- * Initialize OpenVidu instance
- */
- function ovInit()
- {
- let ov = new OpenVidu()
-
- // If there's anything to do, do it here.
- //ov.setAdvancedConfiguration(config)
-
- // Disable all logging except errors
- // ov.enableProdMode()
-
- return ov
- }
-
- /**
- * Join the room session
- *
- * @param data Session metadata and event handlers:
- * token - OpenVidu token for the main connection,
- * shareToken - OpenVidu token for screen-sharing connection,
- * nickname - Participant name,
- * role - connection (participant) role(s),
- * connections - Optional metadata for other users connections (current state),
- * channel - Selected interpreted language channel (two-letter language code)
- * languages - Supported languages (code-to-label map)
- * chatElement - DOM element for the chat widget,
- * counterElement - DOM element for the participants counter,
- * menuElement - DOM element of the room toolbar,
- * queueElement - DOM element for the Q&A queue (users with a raised hand)
- * onSuccess - Callback for session connection (join) success
- * onError - Callback for session connection (join) error
- * onDestroy - Callback for session disconnection event,
- * onDismiss - Callback for Dismiss action,
- * onJoinRequest - Callback for join request,
- * onConnectionChange - Callback for participant changes, e.g. role update,
- * onSessionDataUpdate - Callback for current user connection update,
- * onMediaSetup - Called when user clicks the Media setup button
- * translate - Translation function
- */
- function joinRoom(data) {
- // Create a container for subscribers and publishers
- publishersContainer = $('<div id="meet-publishers">').appendTo(container).get(0)
- subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
-
- resize();
- volumeMeterStop()
-
- data.params = {
- nickname: data.nickname, // user nickname
- // avatar: undefined // avatar image
- }
-
- $t = data.translate
-
- // Make sure all supported callbacks exist, so we don't have to check
- // their existence everywhere anymore
- let events = ['Success', 'Error', 'Destroy', 'Dismiss', 'JoinRequest', 'ConnectionChange',
- 'SessionDataUpdate', 'MediaSetup']
-
- events.map(event => 'on' + event).forEach(event => {
- if (!data[event]) {
- data[event] = () => {}
- }
- })
-
- sessionData = data
-
- // Init a session
- session = OV.initSession()
-
- // Handle connection creation events
- session.on('connectionCreated', event => {
- // Ignore the current user connection
- if (event.connection.role) {
- return
- }
-
- // This is the first event executed when a user joins in.
- // We'll create the video wrapper here, which can be re-used
- // in 'streamCreated' event handler.
-
- let metadata = connectionData(event.connection)
- const connId = metadata.connectionId
-
- // The connection metadata here is the initial metadata set on
- // connection initialization. There's no way to update it via OpenVidu API.
- // So, we merge the initial connection metadata with up-to-dated one that
- // we got from our database.
- if (sessionData.connections && connId in sessionData.connections) {
- Object.assign(metadata, sessionData.connections[connId])
- }
-
- metadata.element = participantCreate(metadata)
-
- connections[connId] = metadata
- })
-
- session.on('connectionDestroyed', event => {
- let connectionId = event.connection.connectionId
- let conn = connections[connectionId]
-
- if (conn) {
- // Remove elements related to the participant
- connectionHandDown(connectionId)
- $(conn.element).remove()
- delete connections[connectionId]
- }
-
- resize()
- })
-
- // On every new Stream received...
- session.on('streamCreated', event => {
- let connectionId = event.stream.connection.connectionId
- let metadata = connections[connectionId]
- let props = {
- // Prepend the video element so it is always before the watermark element
- insertMode: 'PREPEND'
- }
-
- // Subscribe to the Stream to receive it
- let subscriber = session.subscribe(event.stream, metadata.element, props);
-
- Object.assign(metadata, {
- audioActive: event.stream.audioActive,
- videoActive: event.stream.videoActive,
- videoDimensions: event.stream.videoDimensions
- })
-
- subscriber.on('videoElementCreated', event => {
- $(event.element).prop({
- tabindex: -1
- })
-
- resize()
- })
-
- // Update the wrapper controls/status
- participantUpdate(metadata.element, metadata)
-
- // Send the current user status to the connecting user
- // otherwise e.g. nickname might be not up to date
- signalUserUpdate(event.stream.connection)
- })
-
- // Stream properties changes e.g. audio/video muted/unmuted
- session.on('streamPropertyChanged', event => {
- let connectionId = event.stream.connection.connectionId
- let metadata = connections[connectionId]
-
- if (session.connection.connectionId == connectionId) {
- metadata = sessionData
- metadata.audioActive = audioActive
- metadata.videoActive = videoActive
- }
-
- if (metadata) {
- metadata[event.changedProperty] = event.newValue
-
- if (event.changedProperty == 'videoDimensions') {
- resize()
- } else {
- participantUpdate(metadata.element, metadata)
- }
- }
- })
-
- // Handle session disconnection events
- session.on('sessionDisconnected', event => {
- data.onDestroy(event)
- session = null
- resize()
- })
-
- // Handle signals from all participants
- session.on('signal', signalEventHandler)
-
- // Connect with the token
- session.connect(data.token, data.params)
- .then(() => {
- data.onSuccess()
-
- let params = {
- connectionId: session.connection.connectionId,
- role: data.role,
- audioActive,
- videoActive
- }
-
- params = Object.assign({}, data.params, params)
-
- publisher.on('videoElementCreated', event => {
- $(event.element).prop({
- muted: true, // Mute local video to avoid feedback
- disablePictureInPicture: true, // this does not work in Firefox
- tabindex: -1
- })
- resize()
- })
-
- let wrapper = participantCreate(params)
-
- if (data.role & Roles.PUBLISHER) {
- publisher.createVideoElement(wrapper, 'PREPEND')
- session.publish(publisher)
- }
-
- sessionData.element = wrapper
-
- // Create Q&A queue from the existing connections with rised hand.
- // Here we expect connections in a proper queue order
- Object.keys(data.connections || {}).forEach(key => {
- let conn = data.connections[key]
-
- if (conn.hand) {
- conn.connectionId = key
- connectionHandUp(conn)
- }
- })
-
- sessionData.channels = getChannels(data.connections)
-
- // Inform the vue component, so it can update some UI controls
- if (sessionData.channels.length) {
- sessionData.onSessionDataUpdate(sessionData)
- }
- })
- .catch(error => {
- console.error('There was an error connecting to the session: ', error.message);
- data.onError(error)
- })
-
- // Prepare the chat
- setupChat()
- }
-
- /**
- * Leave the room (disconnect)
- */
- function leaveRoom() {
- if (publisher) {
- volumeMeterStop()
-
- // Release any media
- let mediaStream = publisher.stream.getMediaStream()
- if (mediaStream) {
- mediaStream.getTracks().forEach(track => track.stop())
- }
-
- publisher = null
- }
-
- if (session) {
- session.disconnect();
- session = null
- }
-
- if (screenSession) {
- screenSession.disconnect();
- screenSession = null
- }
- }
-
- /**
- * Sets the audio and video devices for the session.
- * This will ask user for permission to access media devices.
- *
- * @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
- */
- function setupStart(props) {
- // Note: After changing media permissions in Chrome/Firefox a page refresh is required.
- // That means that in a scenario where you first blocked access to media devices
- // and then allowed it we can't ask for devices list again and expect a different
- // result than before.
- // That's why we do not bother, and return ealy when we open the media setup dialog.
- if (publisher) {
- volumeMeterStart()
- return
- }
-
- publisher = OV.initPublisher(undefined, publisherDefaults)
-
- publisher.once('accessDenied', error => {
- props.onError(error)
- })
-
- publisher.once('accessAllowed', async () => {
- let mediaStream = publisher.stream.getMediaStream()
- let videoStream = mediaStream.getVideoTracks()[0]
- let audioStream = mediaStream.getAudioTracks()[0]
-
- audioActive = !!audioStream
- videoActive = !!videoStream
- volumeElement = props.volumeElement
-
- publisher.addVideoElement(props.videoElement)
-
- volumeMeterStart()
-
- const devices = await OV.getDevices()
-
- devices.forEach(device => {
- // device's props: deviceId, kind, label
- if (device.kind == 'videoinput') {
- cameras.push(device)
- if (videoStream && videoStream.label == device.label) {
- videoSource = device.deviceId
- }
- } else if (device.kind == 'audioinput') {
- microphones.push(device)
- if (audioStream && audioStream.label == device.label) {
- audioSource = device.deviceId
- }
- }
- })
-
- props.onSuccess({
- microphones,
- cameras,
- audioSource,
- videoSource,
- audioActive,
- videoActive
- })
- })
- }
-
- /**
- * Stop the setup "process", cleanup after it.
- */
- function setupStop() {
- volumeMeterStop()
- }
-
- /**
- * Change the publisher audio device
- *
- * @param deviceId Device identifier string
- */
- async function setupSetAudioDevice(deviceId) {
- if (!deviceId) {
- publisher.publishAudio(false)
- volumeMeterStop()
- audioActive = false
- } else if (deviceId == audioSource) {
- publisher.publishAudio(true)
- volumeMeterStart()
- audioActive = true
- } else {
- const mediaStream = publisher.stream.mediaStream
- const properties = Object.assign({}, publisherDefaults, {
- publishAudio: true,
- publishVideo: videoActive,
- audioSource: deviceId,
- videoSource: videoSource
- })
-
- volumeMeterStop()
-
- // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
- mediaStream.getAudioTracks().forEach(track => {
- track.stop()
- mediaStream.removeTrack(track)
- })
-
- // TODO: Handle errors
-
- await OV.getUserMedia(properties)
- .then(async (newMediaStream) => {
- await replaceTrack(newMediaStream.getAudioTracks()[0])
- volumeMeterStart()
- audioActive = true
- audioSource = deviceId
- })
- }
-
- return audioActive
- }
-
- /**
- * Change the publisher video device
- *
- * @param deviceId Device identifier string
- */
- async function setupSetVideoDevice(deviceId) {
- if (!deviceId) {
- publisher.publishVideo(false)
- videoActive = false
- } else if (deviceId == videoSource) {
- publisher.publishVideo(true)
- videoActive = true
- } else {
- const mediaStream = publisher.stream.mediaStream
- const properties = Object.assign({}, publisherDefaults, {
- publishAudio: audioActive,
- publishVideo: true,
- audioSource: audioSource,
- videoSource: deviceId
- })
-
- volumeMeterStop()
-
- // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
- mediaStream.getVideoTracks().forEach(track => {
- track.stop()
- mediaStream.removeTrack(track)
- })
-
- // TODO: Handle errors
-
- await OV.getUserMedia(properties)
- .then(async (newMediaStream) => {
- await replaceTrack(newMediaStream.getVideoTracks()[0])
- volumeMeterStart()
- videoActive = true
- videoSource = deviceId
- })
- }
-
- return videoActive
- }
-
- /**
- * A way to switch tracks in a stream.
- * Note: This is close to what publisher.replaceTrack() does but it does not
- * require the session.
- * Note: The old track needs to be removed before OV.getUserMedia() call,
- * otherwise we get "Concurrent mic process limit" error.
- */
- function replaceTrack(track) {
- const stream = publisher.stream
-
- const replaceMediaStreamTrack = () => {
- stream.mediaStream.addTrack(track);
-
- if (session) {
- session.sendVideoData(publisher.stream.streamManager, 5, true, 5);
- }
- }
-
- // Fix a bug in Chrome where you would start hearing yourself after audio device change
- // https://github.com/OpenVidu/openvidu/issues/449
- publisher.videoReference.muted = true
-
- return new Promise((resolve, reject) => {
- if (stream.isLocalStreamPublished) {
- // Only if the Publisher has been published it is necessary to call the native
- // Web API RTCRtpSender.replaceTrack()
- const senders = stream.getRTCPeerConnection().getSenders()
- let sender
-
- if (track.kind === 'video') {
- sender = senders.find(s => !!s.track && s.track.kind === 'video')
- } else {
- sender = senders.find(s => !!s.track && s.track.kind === 'audio')
- }
-
- if (!sender) return
-
- sender.replaceTrack(track).then(() => {
- replaceMediaStreamTrack()
- resolve()
- }).catch(error => {
- reject(error)
- })
- } else {
- // Publisher not published. Simply modify local MediaStream tracks
- replaceMediaStreamTrack()
- resolve()
- }
- })
- }
-
- /**
- * Setup the chat UI
- */
- function setupChat() {
- // The UI elements are created in the vue template
- // Here we add a logic for how they work
-
- const chat = $(sessionData.chatElement).find('.chat').get(0)
- const textarea = $(sessionData.chatElement).find('textarea')
- const button = $(sessionData.menuElement).find('.link-chat')
-
- textarea.on('keydown', e => {
- if (e.keyCode == 13 && !e.shiftKey) {
- if (textarea.val().length) {
- signalChat(textarea.val())
- textarea.val('')
- }
-
- return false
- }
- })
-
- // Add an element for the count of unread messages on the chat button
- button.append('<span class="badge bg-dark blinker">')
- .on('click', () => {
- button.find('.badge').text('')
- chatCount = 0
- // When opening the chat scroll it to the bottom, or we shouldn't?
- scrollStop = false
- chat.scrollTop = chat.scrollHeight
- })
-
- $(chat).on('scroll', event => {
- // Detect manual scrollbar moves, disable auto-scrolling until
- // the scrollbar is positioned on the element bottom again
- scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight
- })
- }
-
- /**
- * Signal events handler
- */
- function signalEventHandler(signal) {
- let conn, data
- let connId = signal.from ? signal.from.connectionId : null
-
- switch (signal.type) {
- case 'signal:userChanged':
- // TODO: Use 'signal:connectionUpdate' for nickname updates?
- if (conn = connections[connId]) {
- data = JSON.parse(signal.data)
-
- conn.nickname = data.nickname
- participantUpdate(conn.element, conn)
- nicknameUpdate(data.nickname, connId)
- }
- break
-
- case 'signal:chat':
- data = JSON.parse(signal.data)
- data.id = connId
- pushChatMessage(data)
- break
-
- case 'signal:joinRequest':
- // accept requests from the server only
- if (!connId) {
- sessionData.onJoinRequest(JSON.parse(signal.data))
- }
- break
-
- case 'signal:connectionUpdate':
- // accept requests from the server only
- if (!connId) {
- data = JSON.parse(signal.data)
-
- connectionUpdate(data)
- }
- break
- }
- }
-
- /**
- * Send the chat message to other participants
- *
- * @param message Message string
- */
- function signalChat(message) {
- let data = {
- nickname: sessionData.params.nickname,
- message
- }
-
- session.signal({
- data: JSON.stringify(data),
- type: 'chat'
- })
- }
-
- /**
- * Add a message to the chat
- *
- * @param data Object with a message, nickname, id (of the connection, empty for self)
- */
- function pushChatMessage(data) {
- let message = $('<span>').text(data.message).text() // make the message secure
-
- // Format the message, convert emails and urls to links
- message = anchorme({
- input: message,
- options: {
- attributes: {
- target: "_blank"
- },
- // any link above 20 characters will be truncated
- // to 20 characters and ellipses at the end
- truncate: 20,
- // characters will be taken out of the middle
- middleTruncation: true
- }
- // TODO: anchorme is extensible, we could support
- // github/phabricator's markup e.g. backticks for code samples
- })
-
- message = message.replace(/\r?\n/, '<br>')
-
- // Display the message
- let isSelf = data.id == session.connectionId
- let chat = $(sessionData.chatElement).find('.chat')
- let box = chat.find('.message').last()
-
- message = $('<div>').html(message)
-
- message.find('a').attr('rel', 'noreferrer')
-
- if (box.length && box.data('id') == data.id) {
- // A message from the same user as the last message, no new box needed
- message.appendTo(box)
- } else {
- box = $('<div class="message">').data('id', data.id)
- .append($('<div class="nickname">').text(data.nickname || ''))
- .append(message)
- .appendTo(chat)
-
- if (isSelf) {
- box.addClass('self')
- }
- }
-
- // Count unread messages
- if (!$(sessionData.chatElement).is('.open')) {
- if (!isSelf) {
- chatCount++
- }
- } else {
- chatCount = 0
- }
-
- $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
-
- // Scroll the chat element to the end
- if (!scrollStop) {
- chat.get(0).scrollTop = chat.get(0).scrollHeight
- }
- }
-
- /**
- * Send the user properties update signal to other participants
- *
- * @param connection Optional connection to which the signal will be sent
- * If not specified the signal is sent to all participants
- */
- function signalUserUpdate(connection) {
- let data = {
- nickname: sessionData.params.nickname
- }
-
- session.signal({
- data: JSON.stringify(data),
- type: 'userChanged',
- to: connection ? [connection] : undefined
- })
-
- // The same nickname for screen sharing session
- if (screenSession) {
- screenSession.signal({
- data: JSON.stringify(data),
- type: 'userChanged',
- to: connection ? [connection] : undefined
- })
- }
- }
-
- /**
- * Switch interpreted language channel
- *
- * @param channel Two-letter language code
- */
- function switchChannel(channel) {
- sessionData.channel = channel
-
- // Mute/unmute all connections depending on the selected channel
- participantUpdateAll()
- }
-
- /**
- * Mute/Unmute audio for current session publisher
- */
- function switchAudio() {
- // TODO: If user has no devices or denied access to them in the setup,
- // the button will just not work. Find a way to make it working
- // after user unlocks his devices. For now he has to refresh
- // the page and join the room again.
- if (microphones.length) {
- try {
- publisher.publishAudio(!audioActive)
- audioActive = !audioActive
- } catch (e) {
- console.error(e)
- }
- }
-
- return audioActive
- }
-
- /**
- * Mute/Unmute video for current session publisher
- */
- function switchVideo() {
- // TODO: If user has no devices or denied access to them in the setup,
- // the button will just not work. Find a way to make it working
- // after user unlocks his devices. For now he has to refresh
- // the page and join the room again.
- if (cameras.length) {
- try {
- publisher.publishVideo(!videoActive)
- videoActive = !videoActive
- } catch (e) {
- console.error(e)
- }
- }
-
- return videoActive
- }
-
- /**
- * Switch on/off screen sharing
- */
- function switchScreen(callback) {
- if (screenPublisher) {
- // Note: This is what the original openvidu-call app does.
- // It is probably better for performance reasons to close the connection,
- // than to use unpublish() and keep the connection open.
- screenSession.disconnect()
- screenSession = null
- screenPublisher = null
-
- if (callback) {
- // Note: Disconnecting invalidates the token, we have to inform the vue component
- // to update UI state (and be prepared to request a new token).
- callback(false)
- }
-
- return
- }
-
- screenConnect(callback)
- }
-
- /**
- * Detect if screen sharing is supported by the browser
- */
- function isScreenSharingSupported() {
- return !!OV.checkScreenSharingCapabilities();
- }
-
- /**
- * Update participant connection state
- */
- function connectionUpdate(data) {
- let conn = connections[data.connectionId]
- let refresh = false
- let handUpdate = conn => {
- if ('hand' in data && data.hand != conn.hand) {
- if (data.hand) {
- connectionHandUp(conn)
- } else {
- connectionHandDown(data.connectionId)
- }
- }
- }
-
- // It's me
- if (session.connection.connectionId == data.connectionId) {
- const rolePublisher = data.role && data.role & Roles.PUBLISHER
- const roleModerator = data.role && data.role & Roles.MODERATOR
- const isPublisher = sessionData.role & Roles.PUBLISHER
- const isModerator = sessionData.role & Roles.MODERATOR
-
- // demoted to a subscriber
- if ('role' in data && isPublisher && !rolePublisher) {
- session.unpublish(publisher)
- // FIXME: There's a reference in OpenVidu to a video element that should not
- // exist anymore. It causes issues when we try to do publish/unpublish
- // sequence multiple times in a row. So, we're clearing the reference here.
- let videos = publisher.stream.streamManager.videos
- publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null)
- }
-
- handUpdate(sessionData)
-
- // merge the changed data into internal session metadata object
- sessionData = Object.assign({}, sessionData, data, { audioActive, videoActive })
-
- // update the participant element
- sessionData.element = participantUpdate(sessionData.element, sessionData)
-
- // promoted/demoted to/from a moderator
- if ('role' in data) {
- // Update all participants, to enable/disable the popup menu
- refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator)
- }
-
- // promoted to a publisher
- if ('role' in data && !isPublisher && rolePublisher) {
- publisher.createVideoElement(sessionData.element, 'PREPEND')
- session.publish(publisher).then(() => {
- sessionData.audioActive = publisher.stream.audioActive
- sessionData.videoActive = publisher.stream.videoActive
-
- sessionData.onSessionDataUpdate(sessionData)
- })
-
- // Open the media setup dialog
- // Note: If user didn't give permission to media before joining the room
- // he will not be able to use them now. Changing permissions requires
- // a page refresh.
- // Note: In Firefox I'm always being asked again for media permissions.
- // It does not happen in Chrome. In Chrome the cam/mic will be just re-used.
- // I.e. streaming starts automatically.
- // It might make sense to not start streaming automatically in any cirmustances,
- // display the dialog and wait until user closes it, but this would be
- // a bigger refactoring.
- sessionData.onMediaSetup()
- }
- } else if (conn) {
- handUpdate(conn)
-
- // merge the changed data into internal session metadata object
- Object.keys(data).forEach(key => { conn[key] = data[key] })
-
- conn.element = participantUpdate(conn.element, conn)
- }
-
- // Update channels list
- sessionData.channels = getChannels(connections)
-
- // The channel user was using has been removed (or rather the participant stopped being an interpreter)
- if (sessionData.channel && !sessionData.channels.includes(sessionData.channel)) {
- sessionData.channel = null
- refresh = true
- }
-
- if (refresh) {
- participantUpdateAll()
- }
-
- // Inform the vue component, so it can update some UI controls
- sessionData.onSessionDataUpdate(sessionData)
- }
-
- /**
- * Handler for Hand-Up "signal"
- */
- function connectionHandUp(connection) {
- connection.isSelf = session.connection.connectionId == connection.connectionId
-
- let element = $(nicknameWidget(connection))
-
- participantUpdate(element, connection)
-
- element.attr('id', 'qa' + connection.connectionId)
- .appendTo($(sessionData.queueElement).show())
-
- setTimeout(() => element.addClass('widdle'), 50)
- }
-
- /**
- * Handler for Hand-Down "signal"
- */
- function connectionHandDown(connectionId) {
- let list = $(sessionData.queueElement)
-
- list.find('#qa' + connectionId).remove();
-
- if (!list.find('.meet-nickname').length) {
- list.hide();
- }
- }
-
- /**
- * Update participant nickname in the UI
- *
- * @param nickname Nickname
- * @param connectionId Connection identifier of the user
- */
- function nicknameUpdate(nickname, connectionId) {
- if (connectionId) {
- $(sessionData.chatElement).find('.chat').find('.message').each(function() {
- let elem = $(this)
- if (elem.data('id') == connectionId) {
- elem.find('.nickname').text(nickname || '')
- }
- })
-
- $(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '')
- }
- }
-
- /**
- * Create a participant element in the matrix. Depending on the connection role
- * parameter it will be a video element wrapper inside the matrix or a simple
- * tag-like element on the subscribers list.
- *
- * @param params Connection metadata/params
- * @param content Optional content to prepend to the element
- *
- * @return The element
- */
- function participantCreate(params, content) {
- let element
-
- params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
-
- if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
- // publishers and shared screens
- element = publisherCreate(params, content)
- } else {
- // subscribers and language interpreters
- element = subscriberCreate(params, content)
- }
-
- setTimeout(resize, 50);
-
- return element
- }
-
- /**
- * Create a <video> element wrapper with controls
- *
- * @param params Connection metadata/params
- * @param content Optional content to prepend to the element
- */
- function publisherCreate(params, content) {
- let isScreen = params.role & Roles.SCREEN
-
- // Create the element
- let wrapper = $(
- '<div class="meet-video">'
- + svgIcon('user', 'fas', 'watermark')
- + '<div class="controls">'
- + '<button type="button" class="btn btn-link link-setup hidden" title="' + $t('meet.media-setup') + '">' + svgIcon('cog') + '</button>'
- + '<div class="volume hidden"><input type="range" min="0" max="1" step="0.1" /></div>'
- + '<button type="button" class="btn btn-link link-audio hidden" title="' + $t('meet.menu-audio-mute') + '">' + svgIcon('volume-mute') + '</button>'
- + '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('expand') + '</button>'
- + '<button type="button" class="btn btn-link link-fullscreen open hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('compress') + '</button>'
- + '</div>'
- + '<div class="status">'
- + '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
- + '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
- + '</div>'
- + '</div>'
- )
-
- // Append the nickname widget
- wrapper.find('.controls').before(nicknameWidget(params))
-
- if (content) {
- wrapper.prepend(content)
- }
-
- if (isScreen) {
- wrapper.addClass('screen')
- }
-
- if (params.isSelf) {
- wrapper.find('.link-setup').removeClass('hidden').on('click', () => sessionData.onMediaSetup())
- } else {
- let volumeInput = wrapper.find('.volume input')
- let audioButton = wrapper.find('.link-audio')
- let inVolume = false
- let hideVolumeTimeout
- let hideVolume = () => {
- if (inVolume) {
- hideVolumeTimeout = setTimeout(hideVolume, 1000)
- } else {
- volumeInput.parent().addClass('hidden')
- }
- }
-
- // Enable and set up the audio mute button
- audioButton.removeClass('hidden')
- .on('click', e => {
- let video = wrapper.find('video')[0]
-
- video.muted = !video.muted
- video.volume = video.muted ? 0 : 1
-
- audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
- volumeInput.val(video.volume)
- })
- // Show the volume slider when mouse is over the audio mute/unmute button
- .on('mouseenter', () => {
- let video = wrapper.find('video')[0]
-
- clearTimeout(hideVolumeTimeout)
- volumeInput.parent().removeClass('hidden')
- volumeInput.val(video.volume)
- })
- .on('mouseleave', () => {
- hideVolumeTimeout = setTimeout(hideVolume, 1000)
- })
-
- // Set up the audio volume control
- volumeInput
- .on('mouseenter', () => { inVolume = true })
- .on('mouseleave', () => { inVolume = false })
- .on('change input', () => {
- let video = wrapper.find('video')[0]
- let volume = volumeInput.val()
-
- video.volume = volume
- video.muted = volume == 0
- audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
- })
- }
-
- participantUpdate(wrapper, params, true)
-
- // Fullscreen control
- if (document.fullscreenEnabled) {
- wrapper.find('.link-fullscreen.closed').removeClass('hidden')
- .on('click', () => {
- wrapper.get(0).requestFullscreen()
- })
-
- wrapper.find('.link-fullscreen.open')
- .on('click', () => {
- document.exitFullscreen()
- })
-
- wrapper.on('fullscreenchange', () => {
- // const enabled = document.fullscreenElement
- wrapper.find('.link-fullscreen').toggleClass('hidden')
- })
- }
-
- // Remove the subscriber element, if exists
- $('#subscriber-' + params.connectionId).remove()
-
- let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
-
- return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
- .attr('id', 'publisher-' + params.connectionId)
- .get(0)
- }
-
- /**
- * Update the publisher/subscriber element controls
- *
- * @param wrapper The wrapper element
- * @param params Connection metadata/params
- */
- function participantUpdate(wrapper, params, noupdate) {
- const element = $(wrapper)
- const isModerator = sessionData.role & Roles.MODERATOR
- const isSelf = session.connection.connectionId == params.connectionId
- const rolePublisher = params.role & Roles.PUBLISHER
- const roleModerator = params.role & Roles.MODERATOR
- const roleScreen = params.role & Roles.SCREEN
- const roleOwner = params.role & Roles.OWNER
- const roleInterpreter = rolePublisher && !!params.language
-
- if (!noupdate && !roleScreen) {
- const isPublisher = element.is('.meet-video')
-
- // Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
- // but keep the existing video element
- if (
- !isSelf
- && element.find('video').length
- && ((roleInterpreter && isPublisher) || (!roleInterpreter && !isPublisher && rolePublisher))
- ) {
- wrapper = participantCreate(params, element.find('video'))
- element.remove()
- return wrapper
- }
-
- // Handle publisher-to-subscriber and subscriber-to-publisher change
- if (
- !roleInterpreter
- && (rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)
- ) {
- element.remove()
- return participantCreate(params)
- }
- }
-
- let muted = false
- let video = element.find('video')[0]
-
- // When a channel is selected - mute everyone except the interpreter of the language.
- // When a channel is not selected - mute language interpreters only
- if (sessionData.channel) {
- muted = !(roleInterpreter && params.language == sessionData.channel)
- } else {
- muted = roleInterpreter
- }
-
- if (muted && !isSelf) {
- element.find('.status-audio').removeClass('hidden')
- element.find('.link-audio').addClass('hidden')
- } else {
- element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden')
-
- if (!isSelf) {
- element.find('.link-audio').removeClass('hidden')
- }
-
- muted = !params.audioActive || isSelf
- }
-
- element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
-
- if (video) {
- video.muted = muted
- }
-
- if ('nickname' in params) {
- element.find('.meet-nickname > .content').text(params.nickname)
- }
-
- if (isSelf) {
- element.addClass('self')
- }
-
- if (isModerator) {
- element.addClass('moderated')
- }
-
- const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf);
- const withMenu = isSelf || (isModerator && !roleOwner)
-
- // TODO: This probably could be better done with css
- let elements = {
- '.dropdown-menu': withMenu,
- '.permissions': withPerm,
- '.interpreting': withPerm && rolePublisher,
- 'svg.moderator': roleModerator,
- 'svg.user': !roleModerator && !roleInterpreter,
- 'svg.interpreter': !roleModerator && roleInterpreter
- }
-
- Object.keys(elements).forEach(key => {
- element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
- })
-
- element.find('.action-role-publisher input').prop('checked', rolePublisher)
- element.find('.action-role-moderator input').prop('checked', roleModerator)
- .prop('disabled', roleOwner)
-
- element.find('.interpreting select').val(roleInterpreter ? params.language : '')
-
- return wrapper
- }
-
- /**
- * Update/refresh state of all participants' elements
- */
- function participantUpdateAll() {
- Object.keys(connections).forEach(key => {
- const conn = connections[key]
- participantUpdate(conn.element, conn)
- })
- }
-
- /**
- * Create a tag-like element for a subscriber participant
- *
- * @param params Connection metadata/params
- * @param content Optional content to prepend to the element
- */
- function subscriberCreate(params, content) {
- // Create the element
- let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
-
- if (content) {
- wrapper.prepend(content)
- }
-
- participantUpdate(wrapper, params, true)
-
- return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
- .attr('id', 'subscriber-' + params.connectionId)
- .get(0)
- }
-
- /**
- * Create a tag-like nickname widget
- *
- * @param object params Connection metadata/params
- */
- function nicknameWidget(params) {
- let languages = []
-
- // Append languages selection options
- Object.keys(sessionData.languages).forEach(code => {
- languages.push(`<option value="${code}">${$t(sessionData.languages[code])}</option>`)
- })
-
- // Create the element
- let element = $(
- '<div class="dropdown">'
- + '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
- + '<span class="content"></span>'
- + '<span class="icon">'
- + svgIcon('user', null, 'user')
- + svgIcon('crown', null, 'moderator hidden')
- + svgIcon('headphones', null, 'interpreter hidden')
- + '</span>'
- + '</a>'
- + '<div class="dropdown-menu">'
- + '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
- + '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
- + '<div class="dropdown-divider permissions"></div>'
- + '<div class="permissions">'
- + '<h6 class="dropdown-header">' + $t('meet.perm') + '</h6>'
- + '<label class="dropdown-item action-role-publisher form-check form-switch">'
- + '<input type="checkbox" class="form-check-input">'
- + ' <span class="form-check-label">' + $t('meet.perm-av') + '</span>'
- + '</label>'
- + '<label class="dropdown-item action-role-moderator form-check form-switch">'
- + '<input type="checkbox" class="form-check-input">'
- + ' <span class="form-check-label">' + $t('meet.perm-mod') + '</span>'
- + '</label>'
- + '</div>'
- + '<div class="dropdown-divider interpreting"></div>'
- + '<div class="interpreting">'
- + '<h6 class="dropdown-header">' + $t('meet.lang-int') + '</h6>'
- + '<div class="ps-3 pe-3"><select class="form-select">'
- + '<option value="">- ' + $t('form.none') + ' -</option>'
- + languages.join('')
- + '</select></div>'
- + '</div>'
- + '</div>'
- + '</div>'
- )
-
- let nickname = element.find('.meet-nickname')
- .addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
- .attr({title: $t('meet.menu-options'), 'data-bs-toggle': 'dropdown'})
-
- const dropdown = new Dropdown(nickname[0], {boundary: container.parentNode})
-
- if (params.isSelf) {
- // Add events for nickname change
- let editable = element.find('.content')[0]
- let editableEnable = () => {
- editable.contentEditable = true
- editable.focus()
- }
- let editableUpdate = () => {
- editable.contentEditable = false
- sessionData.params.nickname = editable.innerText
- signalUserUpdate()
- nicknameUpdate(editable.innerText, session.connection.connectionId)
- }
-
- element.find('.action-nickname').on('click', editableEnable)
- element.find('.action-dismiss').remove()
-
- $(editable).on('blur', editableUpdate)
- .on('keydown', e => {
- // Enter or Esc
- if (e.keyCode == 13 || e.keyCode == 27) {
- editableUpdate()
- return false
- }
-
- // Do not propagate the event, so it does not interfere with our
- // keyboard shortcuts
- e.stopPropagation()
- })
- } else {
- element.find('.action-nickname').remove()
-
- element.find('.action-dismiss').on('click', e => {
- sessionData.onDismiss(params.connectionId)
- })
- }
-
- let connectionRole = () => {
- if (params.isSelf) {
- return sessionData.role
- }
- if (params.connectionId in connections) {
- return connections[params.connectionId].role
- }
- return 0
- }
-
- // Don't close the menu on permission change
- element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
-
- element.find('.action-role-publisher input').on('change', e => {
- const enabled = e.target.checked
- let role = connectionRole()
-
- if (enabled) {
- role |= Roles.PUBLISHER
- } else {
- role |= Roles.SUBSCRIBER
- if (role & Roles.PUBLISHER) {
- role ^= Roles.PUBLISHER
- }
- }
-
- sessionData.onConnectionChange(params.connectionId, { role })
- })
-
- element.find('.action-role-moderator input').on('change', e => {
- const enabled = e.target.checked
- let role = connectionRole()
-
- if (enabled) {
- role |= Roles.MODERATOR
- } else if (role & Roles.MODERATOR) {
- role ^= Roles.MODERATOR
- }
-
- sessionData.onConnectionChange(params.connectionId, { role })
- })
-
- element.find('.interpreting select')
- .on('change', e => {
- const language = $(e.target).val()
- sessionData.onConnectionChange(params.connectionId, { language })
- dropdown.hide()
- })
- .on('click', e => {
- // Prevents from closing the dropdown menu on click
- e.stopPropagation()
- })
-
- return element.get(0)
- }
-
- /**
- * Window onresize event handler (updates room layout)
- */
- function resize() {
- if (publishersContainer) {
- updateLayout()
- }
-
- $(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
- }
-
- /**
- * Update the room "matrix" layout
- */
- function updateLayout() {
- let publishers = $(publishersContainer).find('.meet-video')
- let numOfVideos = publishers.length
-
- if (sessionData && sessionData.counterElement) {
- sessionData.counterElement.innerHTML = Object.keys(connections).length + 1
- }
-
- if (!numOfVideos) {
- subscribersContainer.style.minHeight = 'auto'
- return
- }
-
- // Note: offsetHeight/offsetWidth return rounded values, but for proper matrix
- // calculations we need more precision, therefore we use getBoundingClientRect()
-
- let allHeight = container.offsetHeight
- let scrollHeight = subscribersContainer.scrollHeight
- let bcr = publishersContainer.getBoundingClientRect()
- let containerWidth = bcr.width
- let containerHeight = bcr.height
- let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
-
- // Fix subscribers list height
- if (subscribersContainer.offsetHeight <= scrollHeight) {
- limit = Math.min(scrollHeight, limit)
- subscribersContainer.style.minHeight = limit + 'px'
- containerHeight = allHeight - limit
- } else {
- subscribersContainer.style.minHeight = 'auto'
- }
-
- let css, rows, cols, height, padding = 0
-
- // Make the first screen sharing tile big
- let screenVideo = publishers.filter('.screen').find('video').get(0)
-
- if (screenVideo) {
- let element = screenVideo.parentNode
- let connId = element.id.replace(/^publisher-/, '')
- let connection = connections[connId]
-
- // We know the shared screen video dimensions, we can calculate
- // width/height of the tile in the matrix
- if (connection && connection.videoDimensions) {
- let screenWidth = connection.videoDimensions.width
- let screenHeight = containerHeight
-
- // TODO: When the shared window is minimized the width/height is set to 1 (or 2)
- // - at least on my system. We might need to handle this case nicer. Right now
- // it create a 1-2px line on the left of the matrix - not a big issue.
- // TODO: Make the 0.666 factor bigger for wide screen and small number of participants?
- let maxWidth = Math.ceil(containerWidth * 0.666)
-
- if (screenWidth > maxWidth) {
- screenWidth = maxWidth
- }
-
- // Set the tile position and size
- $(element).css({
- width: screenWidth + 'px',
- height: screenHeight + 'px',
- position: 'absolute',
- top: 0,
- left: 0
- })
-
- padding = screenWidth + 'px'
-
- // Now the estate for the rest of participants is what's left on the right side
- containerWidth -= screenWidth
- publishers = publishers.not(element)
- numOfVideos -= 1
- }
- }
-
- // Compensate the shared screen estate with a padding
- $(publishersContainer).css('padding-left', padding)
-
- const factor = containerWidth / containerHeight
-
- if (factor >= 16/9) {
- if (numOfVideos <= 3) {
- rows = 1
- } else if (numOfVideos <= 8) {
- rows = 2
- } else if (numOfVideos <= 15) {
- rows = 3
- } else if (numOfVideos <= 20) {
- rows = 4
- } else {
- rows = 5
- }
-
- cols = Math.ceil(numOfVideos / rows)
- } else {
- if (numOfVideos == 1) {
- cols = 1
- } else if (numOfVideos <= 4) {
- cols = 2
- } else if (numOfVideos <= 9) {
- cols = 3
- } else if (numOfVideos <= 16) {
- cols = 4
- } else if (numOfVideos <= 25) {
- cols = 5
- } else {
- cols = 6
- }
-
- rows = Math.ceil(numOfVideos / cols)
-
- if (rows < cols && containerWidth < containerHeight) {
- cols = rows
- rows = Math.ceil(numOfVideos / cols)
- }
- }
-
- // console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows);
-
- // Update all tiles (except the main shared screen) in the matrix
- publishers.css({
- width: (containerWidth / cols) + 'px',
- // Height must be in pixels to make object-fit:cover working
- height: (containerHeight / rows) + 'px'
- })
- }
-
- /**
- * Initialize screen sharing session/publisher
- */
- function screenConnect(callback) {
- if (!sessionData.shareToken) {
- return false
- }
-
- let gotSession = !!screenSession
-
- if (!screenOV) {
- screenOV = ovInit()
- }
-
- // Init screen sharing session
- if (!gotSession) {
- screenSession = screenOV.initSession();
- }
-
- let successFunc = function() {
- screenSession.publish(screenPublisher)
-
- screenSession.on('sessionDisconnected', event => {
- callback(false)
- screenSession = null
- screenPublisher = null
- })
-
- if (callback) {
- callback(true)
- }
- }
-
- let errorFunc = function() {
- screenPublisher = null
- if (callback) {
- callback(false, true)
- }
- }
-
- // Init the publisher
- let params = {
- videoSource: 'screen',
- publishAudio: false
- }
-
- screenPublisher = screenOV.initPublisher(null, params)
-
- screenPublisher.once('accessAllowed', (event) => {
- if (gotSession) {
- successFunc()
- } else {
- screenSession.connect(sessionData.shareToken, sessionData.params)
- .then(() => {
- successFunc()
- })
- .catch(error => {
- console.error('There was an error connecting to the session:', error.code, error.message);
- errorFunc()
- })
- }
- })
-
- screenPublisher.once('accessDenied', () => {
- console.info('ScreenShare: Access Denied')
- errorFunc()
- })
- }
-
- /**
- * Create an svg element (string) for a FontAwesome icon
- *
- * @todo Find if there's a "official" way to do this
- */
- function svgIcon(name, type, className) {
- // Note: the library will contain definitions for all icons registered elswhere
- const icon = library.definitions[type || 'fas'][name]
-
- let attrs = {
- 'class': 'svg-inline--fa',
- 'aria-hidden': true,
- focusable: false,
- role: 'img',
- xmlns: 'http://www.w3.org/2000/svg',
- viewBox: `0 0 ${icon[0]} ${icon[1]}`
- }
-
- if (className) {
- attrs['class'] += ' ' + className
- }
-
- return $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
- .attr(attrs)
- .get(0).outerHTML
- }
-
- /**
- * A way to update some session data, after you joined the room
- *
- * @param data Same input as for joinRoom()
- */
- function updateSession(data) {
- sessionData.shareToken = data.shareToken
- }
-
- /**
- * A handler for volume level change events
- */
- function volumeChangeHandler(event) {
- let value = 100 + Math.min(0, Math.max(-100, event.value.newValue))
- let color = 'lime'
- const bar = volumeElement.firstChild
-
- if (value >= 70) {
- color = '#ff3300'
- } else if (value >= 50) {
- color = '#ff9933'
- }
-
- bar.style.height = value + '%'
- bar.style.background = color
- }
-
- /**
- * Start the volume meter
- */
- function volumeMeterStart() {
- if (publisher && volumeElement) {
- publisher.on('streamAudioVolumeChange', volumeChangeHandler)
- }
- }
-
- /**
- * Stop the volume meter
- */
- function volumeMeterStop() {
- if (publisher && volumeElement) {
- publisher.off('streamAudioVolumeChange')
- volumeElement.firstChild.style.height = 0
- }
- }
-
- function connectionData(connection) {
- // Note: we're sending a json from two sources (server-side when
- // creating a token/connection, and client-side when joining the session)
- // OpenVidu is unable to merge these two objects into one, for it it is only
- // two strings, so it puts a "%/%" separator in between, we'll replace it with comma
- // to get one parseable json object
- let data = JSON.parse(connection.data.replace('}%/%{', ','))
-
- data.connectionId = connection.connectionId
-
- return data
- }
-
- /**
- * Get all existing language interpretation channels
- */
- function getChannels(connections) {
- let channels = []
-
- Object.keys(connections || {}).forEach(key => {
- let conn = connections[key]
-
- if (
- conn.language
- && !channels.includes(conn.language)
- ) {
- channels.push(conn.language)
- }
- })
-
- return channels
- }
-}
-
-export { Meet, Roles }
diff --git a/src/resources/js/meet/client.js b/src/resources/js/meet/client.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/meet/client.js
@@ -0,0 +1,1377 @@
+'use strict'
+
+import { Device, parseScalabilityMode } from 'mediasoup-client'
+import Config from './config.js'
+import { Media } from './media.js'
+import { Roles } from './constants.js'
+import { Socket } from './socket.js'
+
+
+function Client()
+{
+ let eventHandlers = {}
+ let camProducer
+ let micProducer
+ let screenProducer
+ let consumers = {}
+ let consumerActiveLayers = {}
+ let consumerScore = {}
+ let socket
+ let sendTransportInfo
+ let sendTransport
+ let recvTransport
+ let iceServers = []
+ let nickname = ''
+ let channel = null
+ let channels = []
+ let peers = {}
+ let joinProps = {}
+ let videoSource
+ let audioSource
+ let roomId
+
+ const VIDEO_CONSTRAINTS = {
+ 'low': {
+ width: { ideal: 320 }
+ },
+ 'medium': {
+ width: { ideal: 640 }
+ },
+ 'high': {
+ width: { ideal: 1280 }
+ },
+ 'veryhigh': {
+ width: { ideal: 1920 }
+ },
+ 'ultra': {
+ width: { ideal: 3840 }
+ }
+ }
+
+ // Create a device (use browser auto-detection)
+ let device = new Device()
+
+ // A helper for basic browser media operations
+ const media = new Media()
+
+ this.media = media
+
+ navigator.mediaDevices.addEventListener('devicechange', () => {
+ trigger('deviceChange')
+ })
+
+ /**
+ * Sample statistics
+ */
+ this.getStats = async () => {
+ let consumerStats = {};
+ for (const consumer of Object.values(consumers)) {
+ const stats = await consumer.getStats()
+ const peerId = consumer.peerId
+ stats.forEach((stat) => {
+ if (stat["type"] == "inbound-rtp") {
+ if (stat["kind"] == "video") {
+ const peer = peers.self && peers.self.id === peerId ? peers.self : peers[peerId]
+ const name = peer ? peer['nickname'] : ""
+ const entryName = name + ":" + consumer.id
+ consumerStats[entryName] = stat
+ consumerStats[entryName]['activeLayers'] = consumerActiveLayers[consumer.id]
+ consumerStats[entryName]['score'] = consumerScore[consumer.id]
+ }
+ }
+ })
+ }
+
+ let sendTransportStats = {};
+ if (sendTransport) {
+ (await sendTransport.getStats()).forEach((stat) => {
+ if (stat["type"] == "outbound-rtp") {
+ sendTransportStats[stat["kind"]] = stat
+ }
+ })
+ }
+
+ let receiveTransportStats = {};
+ if (recvTransport) {
+ (await recvTransport.getStats()).forEach((stat) => {
+ if (stat["type"] == "inbound-rtp") {
+ receiveTransportStats[stat["kind"]] = stat
+ }
+ })
+ }
+
+ let camProducerStats = {};
+ if (camProducer) {
+ (await camProducer.getStats()).forEach((stat) => {
+ if (stat["type"] == "outbound-rtp") {
+ camProducerStats = stat
+ }
+ })
+ }
+
+ let micProducerStats = {};
+ if (micProducer) {
+ (await micProducer.getStats()).forEach((stat) => {
+ if (stat["type"] == "outbound-rtp") {
+ micProducerStats = stat
+ }
+ })
+ }
+
+ let screenProducerStats = {};
+ if (screenProducer) {
+ (await screenProducer.getStats()).forEach((stat) => {
+ if (stat["type"] == "outbound-rtp") {
+ screenProducerStats = stat
+ }
+ })
+ }
+
+ return {
+ 'roomId': roomId,
+ 'sendTransportState': sendTransport ? sendTransport.connectionState : "undefined",
+ 'sendTransportStats': sendTransportStats,
+ 'receiveTransportState': recvTransport ? recvTransport.connectionState : "undefined",
+ 'receiveTransportStats': receiveTransportStats,
+ 'camProducerStats': camProducerStats,
+ 'micProducerStats': micProducerStats,
+ 'screenProducerStats': screenProducerStats,
+ 'consumerStats': consumerStats,
+ };
+ }
+
+ /**
+ * Start a session (join a room)
+ */
+ this.joinSession = (token, props) => {
+ // Store the join properties for later
+ joinProps = props
+ // Initialize the socket, 'roomReady' request handler will do the rest of the job
+ socket = initSocket(token)
+ }
+
+ /**
+ * Close the session (disconnect)
+ */
+ this.closeSession = async (reason) => {
+ // If room owner, send the request to close the room
+ if (reason === true && peers.self && peers.self.role & Roles.OWNER) {
+ await socket.sendRequest('moderator:closeRoom')
+ }
+
+ trigger('closeSession', { reason: reason || 'disconnected' })
+
+ if (socket) {
+ socket.close()
+ }
+
+ media.setupStop()
+
+ // Close mediasoup transports
+ if (sendTransport) {
+ sendTransport.close()
+ sendTransport = null
+ }
+
+ if (recvTransport) {
+ recvTransport.close()
+ recvTransport = null
+ }
+
+ // Remove peers' video elements
+ Object.values(peers).forEach(peer => {
+ if (peer.videoElement) {
+ $(peer.videoElement).remove()
+ }
+ if (peer.screenVideoElement) {
+ $(peer.screenVideoElement).remove()
+ }
+ })
+
+ // Reset state
+ eventHandlers = {}
+ camProducer = null
+ micProducer = null
+ screenProducer = null
+ consumers = {}
+ peers = {}
+ channels = []
+ }
+
+ /**
+ * Returns True if user already joined the room session
+ */
+ this.isJoined = () => {
+ return 'self' in peers
+ }
+
+ /**
+ * Accept the join request
+ */
+ this.joinRequestAccept = (requestId) => {
+ socket.sendRequest('moderator:joinRequestAccept', { requestId })
+ }
+
+ /**
+ * Deny the join request
+ */
+ this.joinRequestDeny = (requestId) => {
+ socket.sendRequest('moderator:joinRequestDeny', { requestId })
+ }
+
+ /**
+ * Disable the current user camera
+ */
+ this.camMute = async () => {
+ if (camProducer) {
+ camProducer.pause()
+ await socket.sendRequest('pauseProducer', { producerId: camProducer.id })
+ trigger('updatePeer', updatePeerState(peers.self))
+ }
+
+ return this.camStatus()
+ }
+
+ /**
+ * Enable the current user camera
+ */
+ this.camUnmute = async () => {
+ if (camProducer) {
+ camProducer.resume()
+ await socket.sendRequest('resumeProducer', { producerId: camProducer.id })
+ trigger('updatePeer', updatePeerState(peers.self))
+ }
+
+ return this.camStatus()
+ }
+
+ /**
+ * Get the current user camera status
+ */
+ this.camStatus = () => {
+ return !!(camProducer && !camProducer.paused && !camProducer.closed)
+ }
+
+ /**
+ * Mute the current user microphone
+ */
+ this.micMute = async () => {
+ if (micProducer) {
+ micProducer.pause()
+ await socket.sendRequest('pauseProducer', { producerId: micProducer.id })
+ trigger('updatePeer', updatePeerState(peers.self))
+ }
+
+ return this.micStatus()
+ }
+
+ /**
+ * Unmute the current user microphone
+ */
+ this.micUnmute = async () => {
+ if (micProducer) {
+ micProducer.resume()
+ await socket.sendRequest('resumeProducer', { producerId: micProducer.id })
+ trigger('updatePeer', updatePeerState(peers.self))
+ }
+
+ return this.micStatus()
+ }
+
+ /**
+ * Get the current user microphone status
+ */
+ this.micStatus = () => {
+ return !!(micProducer && !micProducer.paused && !micProducer.closed)
+ }
+
+ /**
+ * Kick a user out of the room
+ */
+ this.kickPeer = (peerId) => {
+ socket.sendRequest('moderator:kickPeer', { peerId })
+ }
+
+ /**
+ * Send a chat message to the server
+ */
+ this.chatMessage = (message) => {
+ socket.sendRequest('chatMessage', { message })
+ }
+
+ /**
+ * Mute microphone of another user
+ */
+ this.peerMicMute = (peerId) => {
+ Object.values(consumers).forEach(consumer => {
+ if (consumer.peerId == peerId && consumer.kind == 'audio') {
+ consumer.consumerPaused = true
+ if (!consumer.paused) {
+ setConsumerState(consumer, false)
+ }
+ }
+ })
+ }
+
+ /**
+ * Unmute microphone of another user
+ */
+ this.peerMicUnmute = (peerId) => {
+ Object.values(consumers).forEach(consumer => {
+ if (consumer.peerId == peerId && consumer.kind == 'audio') {
+ consumer.consumerPaused = false
+ if (consumer.paused && !consumer.producerPaused && !consumer.channelPaused) {
+ setConsumerState(consumer, true)
+ }
+ }
+ })
+ }
+
+ /**
+ * Set 'raisedHand' state of the current user
+ */
+ this.raiseHand = async (status) => {
+ if (peers.self.raisedHand != status) {
+ peers.self.raisedHand = status
+ await socket.sendRequest('raisedHand', { raisedHand: status })
+ }
+
+ return status
+ }
+
+ /**
+ * Set nickname of the current user
+ */
+ this.setNickname = (nickname) => {
+ if (peers.self.nickname != nickname) {
+ peers.self.nickname = nickname
+ socket.sendRequest('changeNickname', { nickname })
+ }
+ }
+
+ /**
+ * Set language channel for the current user
+ */
+ this.setLanguageChannel = (language) => {
+ channel = language
+ updateChannels(true)
+ }
+
+ /**
+ * Set language for the current user (make him an interpreter)
+ */
+ this.setLanguage = (peerId, language) => {
+ socket.sendRequest('moderator:changeLanguage', { peerId, language })
+ }
+
+ /**
+ * Add a role to a user
+ */
+ this.addRole = (peerId, role) => {
+ socket.sendRequest('moderator:addRole', { peerId, role })
+ }
+
+ /**
+ * Remove a role from a user
+ */
+ this.removeRole = (peerId, role) => {
+ socket.sendRequest('moderator:removeRole', { peerId, role })
+ }
+
+ /**
+ * Register event handlers
+ */
+ this.on = (eventName, callback) => {
+ eventHandlers[eventName] = callback
+ }
+
+ /**
+ * Execute an event handler
+ */
+ const trigger = (...args) => {
+ const eventName = args.shift()
+
+ console.log(eventName, args)
+
+ if (eventName in eventHandlers) {
+ eventHandlers[eventName].apply(null, args)
+ }
+ }
+
+ /**
+ * Initialize websocket connection, register event handlers
+ */
+ const initSocket = (token) => {
+ // Connect to websocket
+ socket = new Socket(token)
+
+ socket.on('disconnect', reason => {
+ console.warn("websocket disconnect")
+ // The socket will not attempt to reconnect on these
+ if (reason === "io server disconnect" || reason === "io client disconnect") {
+ this.closeSession()
+ }
+ })
+
+ socket.on('reconnectFailed', () => {
+ console.warn("websocket reconnect failed")
+ this.closeSession()
+ })
+
+ socket.on('request', async (request, cb) => {
+ switch (request.method) {
+ case 'newConsumer':
+ const {
+ peerId,
+ producerId,
+ id,
+ kind,
+ rtpParameters,
+ type,
+ appData,
+ producerPaused
+ } = request.data
+
+ const consumer = await recvTransport.consume({
+ id,
+ producerId,
+ kind,
+ rtpParameters
+ })
+
+ consumer.peerId = peerId
+ consumer.source = appData.source
+
+ consumer.on('transportclose', () => {
+ // TODO: What actually else needs to be done here?
+ delete consumers[consumer.id]
+ })
+
+ consumers[consumer.id] = consumer
+
+
+ // We are ready. Answer the request so the server will
+ // resume this Consumer (which was paused for now).
+ cb(null)
+
+ if (producerPaused) {
+ consumer.producerPaused = true
+ setConsumerState(consumer, false, true)
+ }
+
+ let peer = peers[peerId]
+
+ if (!peer) {
+ return
+ }
+
+ addPeerTrack(peer, consumer.track, consumer.source)
+
+ trigger('updatePeer', peer)
+ updateChannels()
+
+ break
+
+ default:
+ console.error('Unknow request method: ' + request.method)
+ }
+ })
+
+ socket.on('notification', (notification) => {
+ console.warn("notification", notification)
+ switch (notification.method) {
+ case 'roomReady':
+ iceServers = notification.data.iceServers
+ roomId = notification.data.roomId
+ joinRoom()
+ return
+
+ case 'roomBack':
+ reconnectToRoom()
+ return
+
+ case 'newPeer':
+ peers[notification.data.id] = notification.data
+ trigger('addPeer', notification.data)
+ updateChannels()
+ return
+
+ case 'peerClosed':
+ const { peerId } = notification.data
+ trigger('removePeer', peerId)
+ delete peers[peerId]
+ updateChannels()
+ return
+
+ case 'consumerClosed': {
+ const { consumerId } = notification.data
+ const consumer = consumers[consumerId]
+
+ if (!consumer) {
+ return
+ }
+
+ // Calling pause() before close() on a video consumer prevents from
+ // a "freezed" video frame left in the peer video element, even removing
+ // the track from the stream below does not fix that.
+ consumer.pause()
+ consumer.close()
+
+ delete consumers[consumerId]
+
+ let peer = peers[consumer.peerId]
+
+ if (peer) {
+ // Remove the track from the video element
+ // FIXME: This is not really needed if the consumer was closed
+ // if (peer.videoElement) {
+ // media.removeTracksFromStream(peer.videoElement.srcObject, consumer.kind)
+ // }
+
+ // If this is a shared screen, remove the video element
+ if (consumer.source == 'screen') {
+ peer.screenVideoElement = null
+ }
+
+ trigger('updatePeer', updatePeerState(peer))
+ }
+
+ return
+ }
+
+ case 'consumerPaused': {
+ const { consumerId } = notification.data
+ const consumer = consumers[consumerId]
+
+ if (!consumer) {
+ return
+ }
+
+ consumer.producerPaused = true
+
+ if (!consumer.paused) {
+ setConsumerState(consumer, false)
+ }
+
+ let peer = peers[consumer.peerId]
+
+ if (peer) {
+ trigger('updatePeer', updatePeerState(peer))
+ }
+
+ return
+ }
+
+ case 'consumerResumed': {
+ const { consumerId } = notification.data
+ const consumer = consumers[consumerId]
+
+ if (!consumer) {
+ return
+ }
+
+ consumer.producerPaused = false
+
+ if (consumer.paused && !consumer.consumerPaused && !consumer.channelPaused) {
+ setConsumerState(consumer, true)
+ }
+
+ let peer = peers[consumer.peerId]
+
+ if (peer) {
+ trigger('updatePeer', updatePeerState(peer))
+ }
+
+ return
+ }
+
+ case 'consumerScoreChanged': {
+ const { consumerId, score } = notification.data
+ consumerScore[consumerId] = score;
+ return
+ }
+
+ case 'consumerLayersChanged': {
+ const { consumerId, layers } = notification.data
+ consumerActiveLayers[consumerId] = layers;
+ return
+ }
+
+ case 'changeLanguage':
+ updatePeerProperty(notification.data, 'language')
+ return
+
+ case 'changeNickname':
+ updatePeerProperty(notification.data, 'nickname')
+ return
+
+ case 'changeRaisedHand':
+ updatePeerProperty(notification.data, 'raisedHand')
+ return
+
+ case 'changeRole': {
+ const { peerId, role } = notification.data
+ const peer = peers.self.id === peerId ? peers.self : peers[peerId]
+
+ if (!peer) {
+ return
+ }
+
+ let changes = []
+
+ const rolePublisher = role & Roles.PUBLISHER
+ const roleModerator = role & Roles.MODERATOR
+ const isPublisher = peer.role & Roles.PUBLISHER
+ const isModerator = peer.role & Roles.MODERATOR
+
+ if (isPublisher && !rolePublisher) {
+ // demoted to a subscriber
+ changes.push('publisherRole')
+
+ if (peer.isSelf) {
+ // stop publishing any streams
+ this.setMic('', true)
+ this.setCamera('', true)
+ this.screenUnshare()
+ } else {
+ // remove the video element
+ peer.videoElement = null
+ peer.screenVideoElement = null
+ // TODO: Do we need to remove/stop consumers?
+ }
+ } else if (!isPublisher && rolePublisher) {
+ // promoted to a publisher
+ changes.push('publisherRole')
+
+ // create a video element with no tracks
+ setPeerTracks(peer, [])
+ }
+
+ if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) {
+ changes.push('moderatorRole')
+ }
+
+ updatePeerProperty(notification.data, 'role', changes)
+
+ return
+ }
+
+ case 'chatMessage':
+ notification.data.isSelf = notification.data.peerId == peers.self.id
+ trigger('chatMessage', notification.data)
+ return
+
+ case 'moderator:closeRoom':
+ this.closeSession('session-closed')
+ return
+
+ case 'moderator:kickPeer':
+ this.closeSession('session-closed')
+ return
+
+ case 'raisedHand':
+ updatePeerProperty(notification.data, 'raisedHand')
+ return
+
+ case 'signal:joinRequest':
+ trigger('joinRequest', notification.data)
+ return
+
+ default:
+ console.error('Unknow notification method: ' + notification.method)
+ }
+ })
+
+ return socket
+ }
+
+ /**
+ * Make sure we're fully connected to the room again
+ */
+ const reconnectToRoom = async () => {
+ //TODO check for new peers
+ //Make sure transports are ok
+ if (sendTransport) {
+ console.info("Send transport state " + sendTransport.connectionState)
+ if (sendTransport.connectionState == 'disconnected') {
+ await restartIce(sendTransport)
+ }
+ }
+ if (recvTransport) {
+ console.info("Receive transport state " + recvTransport.connectionState)
+ if (recvTransport.connectionState == 'disconnected') {
+ await restartIce(recvTransport)
+ }
+ }
+ }
+
+ /**
+ * Join the session (room)
+ */
+ const joinRoom = async () => {
+ const routerRtpCapabilities = await socket.getRtpCapabilities()
+
+ routerRtpCapabilities.headerExtensions = routerRtpCapabilities.headerExtensions
+ .filter(ext => ext.uri !== 'urn:3gpp:video-orientation')
+
+ device = new Device()
+ await device.load({ routerRtpCapabilities })
+
+ // Setup the consuming transport (for handling streams of other participants)
+ await setRecvTransport()
+
+ // Send the "join" request, get room data, participants, etc.
+ const { peers: existing, role, nickname, id: peerId } = await socket.sendRequest('join', {
+ nickname: joinProps.nickname,
+ rtpCapabilities: device.rtpCapabilities
+ })
+
+ trigger('joinSuccess')
+
+ let peer = {
+ id: peerId,
+ role,
+ nickname,
+ audioActive: false,
+ videoActive: false,
+ screenActive: false,
+ isSelf: true
+ }
+
+ // Add self to the list
+ peers.self = peer
+
+ // Start publishing webcam and mic (and setup the producing transport)
+ await this.setCamera(joinProps.videoSource, true)
+ await this.setMic(joinProps.audioSource, true)
+
+ updatePeerState(peer)
+
+ trigger('addPeer', peer)
+
+ // Trigger addPeer event for all peers already in the room, maintain peers list
+ existing.forEach(peer => {
+ let tracks = []
+ let screenTracks = []
+
+ // We receive newConsumer requests before we add the peer to peers list,
+ // therefore we look here for any consumers that belong to this peer and update
+ // the peer. If we do not do this we have to wait about 20 seconds for repeated
+ // newConsumer requests
+ Object.keys(consumers).forEach(cid => {
+ if (consumers[cid].peerId === peer.id) {
+ (consumers.source == 'screen' ? screenTracks : tracks).push(consumers[cid].track)
+ }
+ })
+
+ if (tracks.length) {
+ setPeerTracks(peer, tracks)
+ }
+ if (screenTracks.length) {
+ setPeerTracks(peer, screenTracks, 'screen')
+ }
+
+ peers[peer.id] = peer
+
+ trigger('addPeer', peer)
+ })
+
+ updateChannels()
+ }
+
+ /**
+ * Set the camera device for the current user
+ */
+ this.setCamera = async (deviceId, noUpdate) => {
+ if (!(peers.self.role & Roles.PUBLISHER)) {
+ // We're checking the role here because thanks to "subscribers only" feature
+ // the peer might have been "downgraded" automatically to a subscriber
+ deviceId = ''
+ }
+
+ // Actually selected device, do nothing
+ if (deviceId == videoSource) {
+ return
+ }
+
+ // Remove current device, stop producer
+ if (camProducer && !camProducer.closed) {
+ camProducer.close()
+ await socket.sendRequest('closeProducer', { producerId: camProducer.id })
+ setPeerTracks(peers.self, [])
+ }
+
+ peers.self.videoSource = videoSource = deviceId
+
+ if (!deviceId) {
+ if (!noUpdate) {
+ trigger('updatePeer', updatePeerState(peers.self), ['videoSource'])
+ }
+ return
+ }
+
+ if (!device.canProduce('video')) {
+ throw new Error('cannot produce video')
+ }
+
+ const { aspectRatio, frameRate, resolution, simulcastEncodings } = Config.videoOptions
+
+ const track = await media.getTrack({
+ video: {
+ deviceId: { ideal: deviceId },
+ ...VIDEO_CONSTRAINTS[resolution],
+ frameRate
+ }
+ })
+
+ await setSendTransport()
+
+ camProducer = await sendTransport.produce({
+ track,
+ encodings: simulcastEncodings,
+ codecOptions: {
+ videoGoogleStartBitrate : 1000
+ },
+ appData: {
+ source : 'webcam'
+ }
+ })
+/*
+ camProducer.on('transportclose', () => {
+ camProducer = null
+ })
+
+ camProducer.on('trackended', () => {
+ // disableWebcam()
+ })
+*/
+ // Create/Update the video element
+ addPeerTrack(peers.self, track)
+ if (!noUpdate) {
+ trigger('updatePeer', peers.self, ['videoSource'])
+ }
+ }
+
+ /**
+ * Set the microphone device for the current user
+ */
+ this.setMic = async (deviceId, noUpdate) => {
+ if (!(peers.self.role & Roles.PUBLISHER)) {
+ // We're checking the role here because thanks to "subscribers only" feature
+ // the peer might have been "downgraded" automatically to a subscriber
+ deviceId = ''
+ }
+
+ // Actually selected device, do nothing
+ if (deviceId == audioSource) {
+ return
+ }
+
+ // Remove current device, stop producer
+ if (micProducer && !micProducer.closed) {
+ micProducer.close()
+ await socket.sendRequest('closeProducer', { producerId: micProducer.id })
+ }
+
+ peers.self.audioSource = audioSource = deviceId
+
+ if (!deviceId) {
+ if (!noUpdate) {
+ trigger('updatePeer', updatePeerState(peers.self), ['audioSource'])
+ }
+ return
+ }
+
+ if (!device.canProduce('audio')) {
+ throw new Error('cannot produce audio')
+ }
+
+ const {
+ autoGainControl,
+ echoCancellation,
+ noiseSuppression,
+ sampleRate,
+ channelCount,
+ volume,
+ sampleSize,
+ opusStereo,
+ opusDtx,
+ opusFec,
+ opusPtime,
+ opusMaxPlaybackRate
+ } = Config.audioOptions
+
+ const track = await media.getTrack({
+ audio: {
+ sampleRate,
+ channelCount,
+ volume,
+ autoGainControl,
+ echoCancellation,
+ noiseSuppression,
+ sampleSize,
+ deviceId: { ideal: deviceId }
+ }
+ })
+
+ await setSendTransport()
+
+ micProducer = await sendTransport.produce({
+ track,
+ codecOptions: {
+ opusStereo,
+ opusDtx,
+ opusFec,
+ opusPtime,
+ opusMaxPlaybackRate
+ },
+ appData: {
+ source : 'mic'
+ }
+ })
+/*
+ micProducer.on('transportclose', () => {
+ micProducer = null
+ })
+
+ micProducer.on('trackended', () => {
+ // disableMic()
+ })
+*/
+ // Note: We're not adding this track to the video element
+ if (!noUpdate) {
+ trigger('updatePeer', updatePeerState(peers.self), ['audioSource'])
+ }
+ }
+
+ /**
+ * Start the current user screen sharing
+ */
+ this.screenShare = async () => {
+ if (this.screenStatus()) {
+ return true
+ }
+
+ if (!(peers.self.role & Roles.PUBLISHER)) {
+ // We're checking the role here because thanks to "subscribers only" feature
+ // the peer might have been "downgraded" automatically to a subscriber
+ return false
+ }
+
+ const { frameRate, resolution, simulcastEncodings} = Config.screenOptions
+
+ const track = await media.getDisplayTrack({
+ video: {
+ ...VIDEO_CONSTRAINTS[resolution],
+ frameRate
+ },
+ audio: false
+ })
+
+ await setSendTransport()
+
+ screenProducer = await sendTransport.produce({
+ track,
+ encodings: simulcastEncodings,
+ codecOptions: {
+ videoGoogleStartBitrate : 1000
+ },
+ appData: {
+ source : 'screen'
+ }
+ })
+/*
+ screenProducer.on('transportclose', () => {
+ screenProducer = null
+ })
+
+ screenProducer.on('trackended', () => {
+ })
+*/
+ // Create the video element
+ createScreenElement(peers.self, [ track ])
+
+ trigger('updatePeer', peers.self)
+
+ return this.screenStatus()
+ }
+
+ /**
+ * Stop the current user screen sharing
+ */
+ this.screenUnshare = async () => {
+ if (screenProducer && !screenProducer.closed) {
+ screenProducer.close()
+ await socket.sendRequest('closeProducer', { producerId: screenProducer.id })
+
+ peers.self.screenVideoElement = null
+
+ trigger('updatePeer', peers.self)
+ }
+
+ screenProducer = null
+
+ return this.screenStatus()
+ }
+
+ /**
+ * Get the current user shared screen status
+ */
+ this.screenStatus = () => {
+ return !!screenProducer && !screenProducer.closed
+ }
+
+ /**
+ * Set the media stream tracks for a video element of a peer
+ */
+ const setPeerTracks = (peer, tracks, source) => {
+ if (source == 'screen' && !peer.screenVideoElement) {
+ createScreenElement(peer, tracks)
+ } else if (source == 'screen') {
+ const stream = new MediaStream()
+ tracks.forEach(track => stream.addTrack(track))
+ peer.screenVideoElement.srcObject = stream
+ } else if (!peer.videoElement) {
+ let props = peer.isSelf ? { mirror: true, muted: true } : {}
+ peer.videoElement = media.createVideoElement(tracks, props)
+ } else {
+ const stream = new MediaStream()
+ tracks.forEach(track => stream.addTrack(track))
+ peer.videoElement.srcObject = stream
+ }
+
+ updatePeerState(peer)
+ }
+
+ /**
+ * Add a media stream track to a video element(s) of a peer
+ */
+ const addPeerTrack = (peer, track, source) => {
+ let stream
+
+ if (source == 'screen') {
+ if (!peer.screenVideoElement) {
+ setPeerTracks(peer, [ track ], source)
+ return
+ }
+
+ stream = peer.screenVideoElement.srcObject
+ } else {
+ if (!peer.videoElement) {
+ setPeerTracks(peer, [ track ])
+ return
+ }
+
+ stream = peer.videoElement.srcObject
+ }
+
+ media.removeTracksFromStream(stream, track.kind)
+
+ stream.addTrack(track)
+
+ updatePeerState(peer)
+ }
+
+ /**
+ * Update peer state
+ */
+ const updatePeerState = (peer) => {
+ if (peer.isSelf) {
+ peer.videoActive = this.camStatus()
+ peer.audioActive = this.micStatus()
+ peer.screenActive = this.screenStatus()
+ } else {
+ peer.videoActive = false
+ peer.audioActive = false
+ peer.screenActive = false
+
+ Object.keys(consumers).forEach(cid => {
+ const consumer = consumers[cid]
+
+ if (consumer.peerId == peer.id) {
+ const key = (consumer.source == 'screen' ? 'screen' : consumer.kind) + 'Active'
+ peer[key] = !consumer.closed && !consumer.producerPaused && !consumer.channelPaused
+ }
+ })
+ }
+
+ return peer
+ }
+
+ /**
+ * Restart ICE on transport
+ */
+ const restartIce = async (transport) => {
+ try {
+ const iceParameters = await socket.sendRequest('restartIce', {
+ transportId: transport.id,
+ })
+ await transport.restartIce({iceParameters})
+ } catch (error) {
+ console.info("The ice restart failed: " + error)
+ }
+ }
+
+ /**
+ * Configure transport for producer (publisher) streams
+ */
+ const setSendTransport = async () => {
+ if (sendTransport && !sendTransport.closed) {
+ return
+ }
+
+ if (!sendTransportInfo) {
+ sendTransportInfo = await socket.sendRequest('createWebRtcTransport', {
+ forceTcp: false,
+ producing: true,
+ consuming: false
+ })
+ }
+
+ const { id, iceParameters, iceCandidates, dtlsParameters } = sendTransportInfo
+
+ const iceTransportPolicy = (device.handlerName.toLowerCase().includes('firefox') && iceServers) ? 'relay' : undefined
+
+ sendTransport = device.createSendTransport({
+ id,
+ iceParameters,
+ iceCandidates,
+ dtlsParameters,
+ iceServers,
+ iceTransportPolicy,
+ proprietaryConstraints: { optional: [{ googDscp: true }] }
+ })
+
+ sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
+ socket.sendRequest('connectWebRtcTransport', { transportId: sendTransport.id, dtlsParameters })
+ .then(callback)
+ .catch(errback)
+ })
+
+ sendTransport.on('produce', async ({ kind, rtpParameters, appData }, callback, errback) => {
+ try {
+ const { id } = await socket.sendRequest('produce', {
+ transportId: sendTransport.id,
+ kind,
+ rtpParameters,
+ appData
+ })
+ callback({ id })
+ } catch (error) {
+ errback(error)
+ }
+ })
+
+ sendTransport.on('connectionstatechange', async (connectionState) => {
+ console.info("sendTransport new connection state:", connectionState)
+ if (connectionState == 'connecting') {
+ // TODO check with a timer that we're reaching the connected state
+ console.info("The 'connected' state is expected next.")
+ }
+ if (connectionState == 'failed') {
+ await restartIce(sendTransport)
+ }
+ })
+ }
+
+ /**
+ * Configure transport for consumer streams
+ */
+ const setRecvTransport = async () => {
+ const transportInfo = await socket.sendRequest('createWebRtcTransport', {
+ forceTcp: false,
+ producing: false,
+ consuming: true
+ })
+
+ const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo
+
+ const iceTransportPolicy = (device.handlerName.toLowerCase().includes('firefox') && iceServers) ? 'relay' : undefined
+
+ recvTransport = device.createRecvTransport({
+ id,
+ iceParameters,
+ iceCandidates,
+ dtlsParameters,
+ iceServers,
+ iceTransportPolicy
+ })
+
+ recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
+ socket.sendRequest('connectWebRtcTransport', { transportId: recvTransport.id, dtlsParameters })
+ .then(callback)
+ .catch(errback)
+ })
+
+ recvTransport.on('connectionstatechange', async (connectionState) => {
+ console.info("recvTransport new connection state:", connectionState)
+ if (connectionState == 'connecting') {
+ // TODO check with a timer that we're reaching the connected state
+ console.info("The 'connected' state is expected next.")
+ }
+ if (connectionState == 'failed') {
+ await restartIce(recvTransport)
+ }
+ })
+ }
+
+ /**
+ * A helper for a peer property update (received via websocket)
+ */
+ const updatePeerProperty = (data, prop, changes) => {
+ const peerId = data.peerId
+ const peer = peers.self && peers.self.id === peerId ? peers.self : peers[peerId]
+
+ if (!peer) {
+ return
+ }
+
+ if (!changes) {
+ changes = []
+ }
+
+ changes.push(prop)
+
+ if (prop == 'language' && peer.language != data.language) {
+ changes.push('interpreterRole')
+ }
+
+ peer[prop] = data[prop]
+
+ trigger('updatePeer', peer, changes)
+
+ if (prop == 'language') {
+ updateChannels()
+ } else if (peer.isSelf) {
+ trigger('updateSession', sessionData())
+ }
+ }
+
+ /**
+ * Update list of existing language interpretation channels and update
+ * audio state of all participants according to the selected channel.
+ */
+ const updateChannels = (update) => {
+ let list = []
+
+ Object.values(peers).forEach(peer => {
+ if (!peer.isSelf && peer.language && !list.includes(peer.language)) {
+ list.push(peer.language)
+ }
+ })
+
+ update = update || channels.join() != list.join()
+ channels = list
+
+ // The channel user was using has been removed (or the participant stopped being an interpreter)
+ if (channel && !channels.includes(channel)) {
+ channel = null
+ update = true
+ }
+
+ // Mute/unmute all peers depending on the selected channel
+ Object.values(consumers).forEach(consumer => {
+ if (consumer.kind == 'audio' && !consumer.closed) {
+ let peer = peers[consumer.peerId]
+
+ // It can happen because consumers are being removed after the peer
+ if (!peer) {
+ return
+ }
+
+ // When a channel is selected we mute everyone except the interpreter of the language.
+ // When a channel is not selected we mute language interpreters only
+ consumer.channelPaused = (peer.language || '') != (channel || '')
+
+ if (consumer.channelPaused && !consumer.paused) {
+ setConsumerState(consumer, false)
+ } else if (!consumer.channelPaused && consumer.paused
+ && !consumer.consumerPaused && !consumer.producerPaused
+ ) {
+ setConsumerState(consumer, true)
+ }
+
+ const state = !consumer.producerPaused && !consumer.channelPaused
+
+ if (peer.audioActive != state) {
+ peer.audioActive = state
+ trigger('updatePeer', peer)
+ }
+ }
+ })
+
+ if (update) {
+ trigger('updateSession', sessionData())
+ }
+ }
+
+ /**
+ * Returns all relevant information about the current session/user state
+ */
+ const sessionData = () => {
+ const { audioActive, videoActive, audioSource, videoSource, screenActive, raisedHand, role } = peers.self
+
+ return {
+ channel,
+ channels,
+ audioActive,
+ videoActive,
+ audioSource,
+ videoSource,
+ screenActive,
+ raisedHand,
+ role
+ }
+ }
+
+ /**
+ * A helper to pause/resume a consumer and propagate the state
+ * to the video element as well as the server
+ */
+ const setConsumerState = (consumer, state, quiet) => {
+ const action = state ? 'resume' : 'pause'
+
+ // Pause/resume the consumer
+ consumer[action]()
+
+ // Mute/unmute the video element
+ // Note: We don't really have to do this, but this simplifies testing
+ if (consumer.kind == 'audio') {
+ const peer = peers[consumer.peerId]
+ if (peer && peer.videoElement && consumer.source == 'mic') {
+ peer.videoElement.muted = !state
+ } else if (peer && peer.screenVideoElement && consumer.source == 'screen') {
+ peer.screenVideoElement.muted = !state
+ }
+ }
+
+ if (!quiet) {
+ socket.sendRequest(action + 'Consumer', { consumerId: consumer.id })
+ }
+ }
+
+ /**
+ * Creates video element for screen sharing stream
+ */
+ const createScreenElement = (peer, tracks) => {
+ peer.screenVideoElement = media.createVideoElement(tracks, { muted: true })
+
+ // Track video dimensions (width) change
+ // Note: the videoWidth is intially 0, we have to wait a while for the real value
+ let interval = setInterval(() => {
+ if (!peer || !peer.screenVideoElement) {
+ clearInterval(interval)
+ return
+ }
+
+ const width = peer.screenVideoElement.videoWidth
+
+ if (width != peer.screenWidth) {
+ peer.screenWidth = width
+ trigger('updatePeer', peers.self, [ 'screenWidth' ])
+ }
+ }, 1000)
+ }
+}
+
+export { Client }
diff --git a/src/resources/js/meet/config.js b/src/resources/js/meet/config.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/meet/config.js
@@ -0,0 +1,47 @@
+export default {
+
+ // Default audio options
+ audioOptions: {
+ autoGainControl: false,
+ echoCancellation: true,
+ noiseSuppression: true,
+ voiceActivatedUnmute: false, // Automatically unmute speaking above noiseThreshold
+ noiseThreshold: -60, // default -60 / This is only for voiceActivatedUnmute and audio-indicator
+ sampleRate: 96000, // will not eat that much bandwith thanks to opus
+ channelCount: 1, // usually mics are mono so this saves bandwidth
+ volume: 1.0,
+ sampleSize: 16,
+ opusStereo: false, // usually mics are mono so this saves bandwidth
+ opusDtx: true, // will save bandwidth
+ opusFec: true, // forward error correction
+ opusPtime: '20', // minimum packet time (3, 5, 10, 20, 40, 60, 120)
+ opusMaxPlaybackRate: 96000
+ },
+
+ // Default video options
+ videoOptions: {
+ resolution: 'high',
+ aspectRatio: 1.777, // 16 : 9
+ frameRate: 30,
+ simulcastEncodings: [
+ { scaleResolutionDownBy: 4, maxBitrate: 300000 },
+ { scaleResolutionDownBy: 2, maxBitrate: 800000 },
+ { scaleResolutionDownBy: 1, maxBitrate: 2500000 }
+ ],
+ },
+
+ screenOptions: {
+ resolution: 'veryhigh',
+ frameRate: 5,
+ simulcastEncodings: [
+ { scaleResolutionDownBy: 4, maxBitrate: 300000 },
+ { scaleResolutionDownBy: 1, maxBitrate: 5000000 }
+ ],
+ },
+
+ // Socket.io request timeout
+ requestTimeout: 20000,
+ transportOptions: {
+ tcp : true
+ }
+}
diff --git a/src/resources/js/meet/constants.js b/src/resources/js/meet/constants.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/meet/constants.js
@@ -0,0 +1,9 @@
+class Roles {
+ static get SUBSCRIBER() { return 1 << 0; }
+ static get PUBLISHER() { return 1 << 1; }
+ static get MODERATOR() { return 1 << 2; }
+ static get SCREEN() { return 1 << 3; }
+ static get OWNER() { return 1 << 4; }
+}
+
+export { Roles }
diff --git a/src/resources/js/meet/media.js b/src/resources/js/meet/media.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/meet/media.js
@@ -0,0 +1,418 @@
+'use strict'
+
+function Media()
+{
+ let audioActive = null // True if the audio track is active
+ let videoActive = null // True if the video track is active
+ let audioSource = '' // Current audio device identifier
+ let videoSource = '' // Current video device identifier
+ let cameras = [] // List of user video devices
+ let microphones = [] // List of user audio devices
+ let setupVideoElement // <video> element for setup process
+ let setupVolumeElement // Volume indicator element for setup process
+
+
+ this.getAudioDevices = async () => {
+ let audioDevices = {}
+
+ try
+ {
+ const devices = await navigator.mediaDevices.enumerateDevices()
+
+ for (const device of devices) {
+ if (device.kind !== 'audioinput') {
+ continue
+ }
+
+ audioDevices[device.deviceId] = device
+ }
+ }
+ catch (error) {
+ console.error(error)
+ }
+
+ return audioDevices
+ }
+
+ this.getWebcams = async () => {
+ let webcamDevices = {}
+
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices()
+
+ for (const device of devices) {
+ if (device.kind !== 'videoinput') {
+ continue
+ }
+
+ // Firefox on my laptop reports the same device twice with the same deviceId, but different labels.
+ // We ignore this edgecase as both devices seem to work.
+ webcamDevices[device.deviceId] = device
+ }
+ }
+ catch (error) {
+ console.error(error)
+ }
+
+ return webcamDevices
+ }
+
+ this.getMediaStream = async (successCallback, errorCallback) => {
+ const constraints = { audio: true, video: true }
+
+ if (videoSource)
+ constraints.video = { deviceId: videoSource }
+ if (audioSource)
+ constraints.audio = { deviceId: audioSource }
+
+ navigator.mediaDevices.getUserMedia(constraints)
+ .then(mediaStream => {
+ successCallback(mediaStream)
+ })
+ .catch(error => {
+ errorCallback(error)
+ })
+ }
+
+ this.getTrack = async (constraints) => {
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
+
+ if (constraints.audio) {
+ return stream.getAudioTracks()[0]
+ }
+
+ return stream.getVideoTracks()[0]
+ }
+
+ this.getDisplayTrack = async (constraints) => {
+ const stream = await navigator.mediaDevices.getDisplayMedia(constraints)
+
+ if (constraints.audio) {
+ return stream.getAudioTracks()[0]
+ }
+
+ return stream.getVideoTracks()[0]
+ }
+
+ this.createVideoElement = (tracks, props) => {
+ const videoElement = document.createElement('video')
+
+ const stream = new MediaStream()
+
+ tracks.forEach(track => stream.addTrack(track))
+
+ videoElement.srcObject = stream
+
+ this.setVideoProps(videoElement, props)
+
+ return videoElement
+ }
+
+ /**
+ * Make a picture from a video element
+ */
+ this.makePicture = (videoElement) => {
+ // Skip if video is not "playing"
+ if (!videoElement.videoWidth) {
+ return
+ }
+
+ // we're going to crop a square from the video and resize it
+ const maxSize = 64
+
+ // Calculate sizing
+ let sh = Math.floor(videoElement.videoHeight / 1.5)
+ let sw = sh
+ let sx = (videoElement.videoWidth - sw) / 2
+ let sy = (videoElement.videoHeight - sh) / 2
+
+ let dh = Math.min(sh, maxSize)
+ let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
+
+ const canvas = $("<canvas>")[0]
+ canvas.width = dw
+ canvas.height = dh
+
+ // draw the image on the canvas (square cropped and resized)
+ canvas.getContext('2d').drawImage(videoElement, sx, sy, sw, sh, 0, 0, dw, dh)
+
+ // convert it to a usable data URL (png format)
+ return canvas.toDataURL()
+ }
+
+ /**
+ * Set video element properties
+ */
+ this.setVideoProps = (videoElement, props) => {
+ videoElement.autoplay = true
+ videoElement.controls = false
+ videoElement.muted = props.muted || false
+ videoElement.disablePictureInPicture = true // this does not work in Firefox
+ videoElement.tabIndex = -1
+ videoElement.setAttribute('playsinline', 'true')
+
+ if (props.mirror) {
+ videoElement.style.transform = 'rotateY(180deg)'
+ videoElement.style.webkitTransform = 'rotateY(180deg)'
+ }
+ }
+
+ /**
+ * Sets the audio and video devices for the session.
+ * This will ask user for permission to access media devices.
+ *
+ * @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
+ */
+ this.setupStart = (props) => {
+ setupVideoElement = props.videoElement
+ setupVolumeElement = props.volumeElement
+
+ const callback = async (mediaStream) => {
+ if (audioActive === false) {
+ this.removeTracksFromStream(mediaStream, 'Audio')
+ }
+ if (videoActive === false) {
+ this.removeTracksFromStream(mediaStream, 'Video')
+ }
+
+ let videoStream = mediaStream.getVideoTracks()[0]
+ let audioStream = mediaStream.getAudioTracks()[0]
+
+ audioActive = !!audioStream
+ videoActive = !!videoStream
+
+ this.setVideoProps(setupVideoElement, { mirror: true, muted: true })
+ setupVideoElement.srcObject = mediaStream
+
+ if (audioActive) {
+ audioSource = audioStream.getSettings().deviceId
+ volumeMeterStart()
+ }
+
+ if (videoActive) {
+ videoSource = videoStream.getSettings().deviceId
+ }
+
+ microphones = await this.getAudioDevices()
+ cameras = await this.getWebcams()
+
+ props.onSuccess({
+ microphones,
+ cameras,
+ audioSource,
+ videoSource,
+ audioActive,
+ videoActive
+ })
+ }
+
+ this.getMediaStream(callback, props.onError)
+ }
+
+ /**
+ * Stop the setup "process", cleanup after it.
+ */
+ this.setupStop = () => {
+ volumeMeterStop()
+
+ // Unset the video element tracks
+ if (setupVideoElement) {
+ const mediaStream = new MediaStream()
+ setupVideoElement.srcObject = mediaStream
+ }
+ }
+
+ /**
+ * Return current setup information
+ */
+ this.setupData = () => {
+ return {
+ microphones,
+ cameras,
+ audioSource,
+ videoSource,
+ audioActive,
+ videoActive
+ }
+ }
+
+ /**
+ * Change the publisher audio device
+ *
+ * @param deviceId Device identifier string
+ */
+ this.setupSetAudio = async (deviceId) => {
+ const mediaStream = setupVideoElement.srcObject
+
+ if (!deviceId) {
+ volumeMeterStop()
+ this.removeTracksFromStream(mediaStream, 'Audio')
+ audioActive = false
+ audioSource = ''
+ } else if (deviceId == audioSource) {
+ volumeMeterStart()
+ audioActive = true
+ } else {
+ const constraints = {
+ audio: {
+ deviceId: { ideal: deviceId }
+ }
+ }
+
+ volumeMeterStop()
+
+ // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
+ this.removeTracksFromStream(mediaStream, 'Audio')
+
+ // TODO: Error handling
+
+ const track = await this.getTrack(constraints)
+
+ mediaStream.addTrack(track)
+ volumeMeterStart()
+ audioActive = true
+ audioSource = deviceId
+ }
+
+ return audioActive
+ }
+
+ /**
+ * Change the publisher video device
+ *
+ * @param deviceId Device identifier string
+ */
+ this.setupSetVideo = async (deviceId) => {
+ const mediaStream = setupVideoElement.srcObject
+
+ if (!deviceId) {
+ this.removeTracksFromStream(mediaStream, 'Video')
+ // Without the next line the video element will freeze on the last video frame
+ // instead of turning black.
+ setupVideoElement.srcObject = mediaStream
+ videoActive = false
+ videoSource = ''
+ } else if (deviceId == audioSource) {
+ videoActive = true
+ } else {
+ const constraints = {
+ video: {
+ deviceId: { ideal: deviceId }
+ }
+ }
+
+ // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
+ this.removeTracksFromStream(mediaStream, 'Video')
+
+ // TODO: Error handling
+
+ const track = await this.getTrack(constraints)
+
+ mediaStream.addTrack(track)
+ videoActive = true
+ videoSource = deviceId
+ }
+
+ return videoActive
+ }
+
+ /**
+ * Removes tracks of specified kind (audio or video) from a stream
+ */
+ this.removeTracksFromStream = (stream, type) => {
+ if (stream) {
+ type = type.replace(/^a/, 'A').replace(/^v/, 'V')
+ stream[`get${type}Tracks`]().forEach(track => {
+ track.stop()
+ stream.removeTrack(track)
+ })
+ }
+ }
+
+ /**
+ * Starts volume changes tracking on the setup video element
+ */
+ const volumeMeterStart = () => {
+ if (!setupVolumeElement) {
+ return
+ }
+
+ const audioContext = new AudioContext()
+ const source = audioContext.createMediaStreamSource(setupVideoElement.srcObject)
+
+ // Create a new volume meter
+ const processor = audioContext.createScriptProcessor(512)
+
+ processor.volume = 0
+ processor.averaging = 0.95
+
+ processor.onaudioprocess = function(event) {
+ let buf = event.inputBuffer.getChannelData(0)
+ let bufLength = buf.length
+ let sum = 0
+
+ // Do a root-mean-square on the samples: sum up the squares...
+ for (let x, i=0; i<bufLength; i++) {
+ x = buf[i]
+ sum += x * x
+ }
+
+ // ... then take the square root of the sum.
+ const rms = Math.sqrt(sum / bufLength)
+
+ // Now smooth this out with the averaging factor applied
+ // to the previous sample - take the max here because we
+ // want "fast attack, slow release."
+ this.volume = Math.max(rms, this.volume * this.averaging)
+ }
+
+ processor.shutdown = function() {
+ this.disconnect()
+ this.onaudioprocess = null
+ }
+
+ // this will have no effect, since we don't copy the input to the output,
+ // but works around a current Chrome bug.
+ processor.connect(audioContext.destination)
+
+ // Connect the volume processor to the source
+ source.connect(processor)
+
+ const update = () => { volumeMeterUpdate(processor.volume * 100) }
+
+ this.audioContext = audioContext
+ this.volumeInterval = setInterval(update, 25)
+ }
+
+ /**
+ * Stops volume changes tracking on the setup video element
+ */
+ const volumeMeterStop = () => {
+ if (this.audioContext) {
+ clearInterval(this.volumeInterval)
+ this.audioContext.close()
+ this.audioContext = null
+ volumeMeterUpdate(0)
+ }
+ }
+
+ /**
+ * Updates volume meter widget on voluma level change
+ */
+ const volumeMeterUpdate = (volume) => {
+ const value = Math.min(100, Math.ceil(volume))
+ const bar = setupVolumeElement.firstChild
+
+ let color = 'lime'
+ if (value >= 70) {
+ color = '#ff3300'
+ } else if (value >= 50) {
+ color = '#ff9933'
+ }
+
+ bar.style.height = value + '%'
+ bar.style.background = color
+ }
+}
+
+export { Media }
diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/meet/room.js
@@ -0,0 +1,1102 @@
+'use strict'
+
+import anchorme from 'anchorme'
+import { Client } from './client.js'
+import { Roles } from './constants.js'
+import { Dropdown } from 'bootstrap'
+import { library } from '@fortawesome/fontawesome-svg-core'
+
+function Room(container)
+{
+ let sessionData // Room session metadata
+ let peers = {} // Participants in the session (including self)
+ let publishersContainer // Container element for publishers
+ let subscribersContainer // Container element for subscribers
+ let selfId // peer Id of the current user
+
+ let chatCount = 0
+ let scrollStop
+ let $t
+ let $toast
+
+ const client = new Client()
+
+ // Disconnect participant when browser's window close
+ window.addEventListener('beforeunload', () => {
+ leaveRoom()
+ })
+
+ window.addEventListener('resize', resize)
+
+ // Public methods
+ this.isScreenSharingSupported = isScreenSharingSupported
+ this.joinRoom = joinRoom
+ this.leaveRoom = leaveRoom
+ this.raiseHand = raiseHand
+ this.setupStart = setupStart
+ this.setupStop = setupStop
+ this.setupSetAudioDevice = setupSetAudioDevice
+ this.setupSetVideoDevice = setupSetVideoDevice
+ this.switchAudio = switchAudio
+ this.switchChannel = switchChannel
+ this.switchScreen = switchScreen
+ this.switchVideo = switchVideo
+ this.getStats = getStats
+
+ /**
+ * Join the room session
+ *
+ * @param data Session metadata and event handlers:
+ * token - A token for the main connection,
+ * nickname - Participant name,
+ * languages - Supported languages (code-to-label map)
+ * chatElement - DOM element for the chat widget,
+ * counterElement - DOM element for the participants counter,
+ * menuElement - DOM element of the room toolbar,
+ * queueElement - DOM element for the Q&A queue (users with a raised hand)
+ * onSuccess - Callback for session connection (join) success
+ * onError - Callback for session connection (join) error
+ * onDestroy - Callback for session disconnection event,
+ * onMediaSetup - Called when user clicks the Media setup button
+ * onUpdate - Callback for current user/session update,
+ * toast - Toast widget
+ * translate - Translation function
+ */
+ function joinRoom(data) {
+ // Create a container for subscribers and publishers
+ publishersContainer = $('<div id="meet-publishers">').appendTo(container).get(0)
+ subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
+
+ resize()
+
+ $t = data.translate
+ $toast = data.toast
+
+ // Make sure all supported callbacks exist, so we don't have to check
+ // their existence everywhere anymore
+ let events = ['Success', 'Error', 'Destroy', 'Update', 'MediaSetup']
+
+ events.map(event => 'on' + event).forEach(event => {
+ if (!data[event]) {
+ data[event] = () => {}
+ }
+ })
+
+ sessionData = data
+
+ // Handle new participants (including self)
+ client.on('addPeer', (event) => {
+ if (event.isSelf) {
+ selfId = event.id
+ }
+
+ peers[event.id] = event
+
+ event.element = participantCreate(event, event.videoElement)
+
+ if (event.raisedHand) {
+ peerHandUp(event)
+ }
+ })
+
+ // Handle removed participants
+ client.on('removePeer', (peerId) => {
+ let peer = peers[peerId]
+
+ if (peer) {
+ // Remove elements related to the participant
+ peerHandDown(peer)
+
+ $(peer.element).remove()
+ if (peer.screen) {
+ $(peer.screen).remove()
+ }
+
+ delete peers[peerId]
+ }
+
+ resize()
+ })
+
+ // Participant properties changed e.g. audio/video muted/unmuted
+ client.on('updatePeer', (event, changed) => {
+ let peer = peers[event.id]
+
+ if (!peer) {
+ return
+ }
+
+ event.element = peer.element
+ event.screen = peer.screen
+
+ // Video element added or removed
+ if (event.videoElement && event.videoElement.parentNode != event.element) {
+ $(event.element).prepend(event.videoElement)
+ } else if (!event.videoElement) {
+ $(event.element).find('video').remove()
+ }
+
+ // Video element of the shared screen added or removed
+ if (event.screenVideoElement && !event.screen) {
+ const screen = { id: event.id, role: event.role | Roles.SCREEN, nickname: event.nickname }
+ event.screen = participantCreate(screen, event.screenVideoElement)
+ } else if (!event.screenVideoElement && event.screen) {
+ $(event.screen).remove()
+ event.screen = null
+ resize()
+ }
+
+ peers[event.id] = event
+
+ if (changed && changed.length) {
+ if (changed && changed.includes('nickname')) {
+ nicknameUpdate(event.nickname, event.id)
+ }
+
+ if (changed.includes('raisedHand')) {
+ if (event.raisedHand) {
+ peerHandUp(event)
+ } else {
+ peerHandDown(event)
+ }
+ }
+
+ if (changed && changed.includes('screenWidth')) {
+ resize()
+ return
+ }
+ }
+
+ if (changed && changed.includes('interpreterRole')
+ && !event.isSelf && $(event.element).find('video').length
+ ) {
+ // Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
+ // but keep the existing video element
+ let wrapper = participantCreate(event, $(event.element).find('video'))
+ event.element.remove()
+ event.element = wrapper
+ } else if (changed && changed.includes('publisherRole') && !event.language) {
+ // Handle publisher-to-subscriber and subscriber-to-publisher change
+ event.element.remove()
+ event.element = participantCreate(event, event.videoElement)
+ } else {
+ participantUpdate(event.element, event)
+ }
+
+ // It's me, got publisher role
+ if (event.isSelf && (event.role & Roles.PUBLISHER) && changed && changed.includes('publisherRole')) {
+ // Open the media setup dialog
+ sessionData.onMediaSetup()
+ }
+
+ if (changed && changed.includes('moderatorRole')) {
+ participantUpdateAll()
+ }
+ })
+
+ // Handle successful connection to the room
+ client.on('joinSuccess', () => {
+ data.onSuccess()
+ client.media.setupStop()
+ })
+
+ // Handle join requests from other users (knocking to the room)
+ client.on('joinRequest', event => {
+ joinRequest(event)
+ })
+
+ // Handle session disconnection events
+ client.on('closeSession', event => {
+ // Notify the UI
+ data.onDestroy(event)
+
+ // Remove all participant elements
+ Object.keys(peers).forEach(peerId => {
+ $(peers[peerId].element).remove()
+ })
+ peers = {}
+
+ // refresh the matrix
+ resize()
+ })
+
+ // Handle session update events (e.g. channel, channels list changes)
+ client.on('updateSession', event => {
+ // Inform the vue component, so it can update some UI controls
+ sessionData.onUpdate(event)
+ })
+
+ const { audioSource, videoSource } = client.media.setupData()
+
+ // Start the session
+ client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname })
+
+ // Prepare the chat
+ initChat()
+ }
+
+ async function getStats() {
+ return await client.getStats()
+ }
+
+ /**
+ * Leave the room (disconnect)
+ */
+ function leaveRoom(forced) {
+ client.closeSession(forced)
+ peers = {}
+ }
+
+ /**
+ * Handler for an event received by the moderator when a participant
+ * is asking for a permission to join the room
+ */
+ function joinRequest(data) {
+ const id = data.requestId
+
+ // The toast for this user request already exists, ignore
+ // It's not really needed as we do this on server-side already
+ if ($('#i' + id).length) {
+ return
+ }
+
+ const body = $(
+ `<div>`
+ + `<div class="picture"><img src="${data.picture}"></div>`
+ + `<div class="content">`
+ + `<p class="mb-2"></p>`
+ + `<div class="text-end">`
+ + `<button type="button" class="btn btn-sm btn-success accept">${$t('btn.accept')}</button>`
+ + `<button type="button" class="btn btn-sm btn-danger deny ms-2">${$t('btn.deny')}</button>`
+ )
+
+ $toast.message({
+ className: 'join-request',
+ icon: 'user',
+ timeout: 0,
+ title: $t('meet.join-request'),
+ // titleClassName: '',
+ body: body.html(),
+ onShow: element => {
+ $(element).find('p').text($t('meet.join-requested', { user: data.nickname || '' }))
+
+ // add id attribute, so we can identify it
+ $(element).attr('id', 'i' + id)
+ // add action to the buttons
+ .find('button.accept,button.deny').on('click', e => {
+ const action = $(e.target).is('.accept') ? 'Accept' : 'Deny'
+ client['joinRequest' + action](id)
+ $('#i' + id).remove()
+ })
+ }
+ })
+ }
+
+ /**
+ * Raise or lower the hand
+ *
+ * @param status Hand raised or not
+ */
+ async function raiseHand(status) {
+ return await client.raiseHand(status)
+ }
+
+ /**
+ * Sets the audio and video devices for the session.
+ * This will ask user for permission to access media devices.
+ *
+ * @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
+ */
+ function setupStart(props) {
+ client.media.setupStart(props)
+
+ // When setting up devices while the session is ongoing we have to
+ // disable currently selected devices (temporarily) otherwise e.g.
+ // changing a mic or camera to another device will not be possible.
+ if (client.isJoined()) {
+ client.setMic('')
+ client.setCamera('')
+ }
+ }
+
+ /**
+ * Stop the setup "process", cleanup after it.
+ */
+ async function setupStop() {
+ client.media.setupStop()
+
+ // Apply device changes to the client
+ const { audioSource, videoSource } = client.media.setupData()
+ await client.setMic(audioSource)
+ await client.setCamera(videoSource)
+ }
+
+ /**
+ * Change the publisher audio device
+ *
+ * @param deviceId Device identifier string
+ */
+ async function setupSetAudioDevice(deviceId) {
+ return await client.media.setupSetAudio(deviceId)
+ }
+
+ /**
+ * Change the publisher video device
+ *
+ * @param deviceId Device identifier string
+ */
+ async function setupSetVideoDevice(deviceId) {
+ return await client.media.setupSetVideo(deviceId)
+ }
+
+ /**
+ * Setup the chat UI
+ */
+ function initChat() {
+ // Handle arriving chat messages
+ client.on('chatMessage', pushChatMessage)
+
+ // The UI elements are created in the vue template
+ // Here we add a logic for how they work
+
+ const chat = $(sessionData.chatElement).find('.chat').get(0)
+ const textarea = $(sessionData.chatElement).find('textarea')
+ const button = $(sessionData.menuElement).find('.link-chat')
+
+ textarea.on('keydown', e => {
+ if (e.keyCode == 13 && !e.shiftKey) {
+ if (textarea.val().length) {
+ client.chatMessage(textarea.val())
+ textarea.val('')
+ }
+
+ return false
+ }
+ })
+
+ // Add an element for the count of unread messages on the chat button
+ button.append('<span class="badge bg-dark blinker">')
+ .on('click', () => {
+ button.find('.badge').text('')
+ chatCount = 0
+ // When opening the chat scroll it to the bottom, or we shouldn't?
+ scrollStop = false
+ chat.scrollTop = chat.scrollHeight
+ })
+
+ $(chat).on('scroll', event => {
+ // Detect manual scrollbar moves, disable auto-scrolling until
+ // the scrollbar is positioned on the element bottom again
+ scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight
+ })
+ }
+
+ /**
+ * Add a message to the chat
+ *
+ * @param data Object with a message, nickname, id (of the connection, empty for self)
+ */
+ function pushChatMessage(data) {
+ let message = $('<span>').text(data.message).text() // make the message secure
+
+ // Format the message, convert emails and urls to links
+ message = anchorme({
+ input: message,
+ options: {
+ attributes: {
+ target: "_blank"
+ },
+ // any link above 20 characters will be truncated
+ // to 20 characters and ellipses at the end
+ truncate: 20,
+ // characters will be taken out of the middle
+ middleTruncation: true
+ }
+ // TODO: anchorme is extensible, we could support
+ // github/phabricator's markup e.g. backticks for code samples
+ })
+
+ message = message.replace(/\r?\n/, '<br>')
+
+ // Display the message
+ let chat = $(sessionData.chatElement).find('.chat')
+ let box = chat.find('.message').last()
+
+ message = $('<div>').html(message)
+
+ message.find('a').attr('rel', 'noreferrer')
+
+ if (box.length && box.data('id') == data.peerId) {
+ // A message from the same user as the last message, no new box needed
+ message.appendTo(box)
+ } else {
+ box = $('<div class="message">').data('id', data.peerId)
+ .append($('<div class="nickname">').text(data.nickname || ''))
+ .append(message)
+ .appendTo(chat)
+
+ if (data.isSelf) {
+ box.addClass('self')
+ }
+ }
+
+ // Count unread messages
+ if (!$(sessionData.chatElement).is('.open')) {
+ if (!data.isSelf) {
+ chatCount++
+ }
+ } else {
+ chatCount = 0
+ }
+
+ $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
+
+ // Scroll the chat element to the end
+ if (!scrollStop) {
+ chat.get(0).scrollTop = chat.get(0).scrollHeight
+ }
+ }
+
+ /**
+ * Switch interpreted language channel
+ *
+ * @param channel Two-letter language code
+ */
+ function switchChannel(channel) {
+ client.setLanguageChannel(channel)
+ }
+
+ /**
+ * Mute/Unmute audio for current session publisher
+ */
+ async function switchAudio() {
+ const isActive = client.micStatus()
+
+ if (isActive) {
+ return await client.micMute()
+ } else {
+ return await client.micUnmute()
+ }
+ }
+
+ /**
+ * Mute/Unmute video for current session publisher
+ */
+ async function switchVideo() {
+ const isActive = client.camStatus()
+
+ if (isActive) {
+ return await client.camMute()
+ } else {
+ return await client.camUnmute()
+ }
+ }
+
+ /**
+ * Switch on/off screen sharing
+ */
+ async function switchScreen() {
+ const isActive = client.screenStatus()
+
+ if (isActive) {
+ return await client.screenUnshare()
+ } else {
+ return await client.screenShare()
+ }
+ }
+
+ /**
+ * Detect if screen sharing is supported by the browser
+ */
+ function isScreenSharingSupported() {
+ return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
+ }
+
+ /**
+ * Handler for Hand-Up "signal"
+ */
+ function peerHandUp(peer) {
+ let element = $(nicknameWidget(peer))
+
+ participantUpdate(element, peer)
+
+ element.attr('id', 'qa' + peer.id).appendTo($(sessionData.queueElement).show())
+
+ setTimeout(() => element.addClass('wiggle'), 50)
+ }
+
+ /**
+ * Handler for Hand-Down "signal"
+ */
+ function peerHandDown(peer) {
+ let list = $(sessionData.queueElement)
+
+ list.find('#qa' + peer.id).remove()
+
+ if (!list.find('.meet-nickname').length) {
+ list.hide()
+ }
+ }
+
+ /**
+ * Update participant nickname in the UI
+ *
+ * @param nickname Nickname
+ * @param peerId Connection identifier of the user
+ */
+ function nicknameUpdate(nickname, peerId) {
+ if (peerId) {
+ $(sessionData.chatElement).find('.chat').find('.message').each(function() {
+ let elem = $(this)
+ if (elem.data('id') == peerId) {
+ elem.find('.nickname').text(nickname || '')
+ }
+ })
+
+ $(sessionData.queueElement).find('#qa' + peerId + ' .content').text(nickname || '')
+
+ // Also update the nickname for the shared screen as we do not call
+ // participantUpdate() for this element
+ $('#screen-' + peerId).find('.meet-nickname .content').text(nickname || '')
+ }
+ }
+
+ /**
+ * Create a participant element in the matrix. Depending on the peer role
+ * parameter it will be a video element wrapper inside the matrix or a simple
+ * tag-like element on the subscribers list.
+ *
+ * @param params Peer metadata/params
+ * @param content Optional content to prepend to the element, e.g. video element
+ *
+ * @return The element
+ */
+ function participantCreate(params, content) {
+ let element
+
+ if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
+ // publishers and shared screens
+ element = publisherCreate(params, content)
+ } else {
+ // subscribers and language interpreters
+ element = subscriberCreate(params, content)
+ }
+
+ setTimeout(resize, 50)
+
+ return element
+ }
+
+ /**
+ * Create a <video> element wrapper with controls
+ *
+ * @param params Connection metadata/params
+ * @param content Optional content to prepend to the element
+ */
+ function publisherCreate(params, content) {
+ let isScreen = params.role & Roles.SCREEN
+
+ // Create the element
+ let wrapper = $(
+ '<div class="meet-video">'
+ + svgIcon('user', 'fas', 'watermark')
+ + '<div class="controls">'
+ + '<button type="button" class="btn btn-link link-setup hidden" title="' + $t('meet.media-setup') + '">' + svgIcon('cog') + '</button>'
+ + '<div class="volume hidden"><input type="range" min="0" max="1" step="0.1" /></div>'
+ + '<button type="button" class="btn btn-link link-audio hidden" title="' + $t('meet.menu-audio-mute') + '">' + svgIcon('volume-mute') + '</button>'
+ + '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('expand') + '</button>'
+ + '<button type="button" class="btn btn-link link-fullscreen open hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('compress') + '</button>'
+ + '</div>'
+ + '<div class="status">'
+ + '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
+ + '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
+ + '</div>'
+ + '</div>'
+ )
+
+ // Append the nickname widget
+ wrapper.find('.controls').before(nicknameWidget(params))
+
+ if (content) {
+ wrapper.prepend(content)
+ }
+
+ if (isScreen) {
+ wrapper.addClass('screen')
+ }
+
+ if (params.isSelf) {
+ wrapper.find('.link-setup').removeClass('hidden').on('click', () => sessionData.onMediaSetup())
+ } else if (!isScreen) {
+ let volumeInput = wrapper.find('.volume input')
+ let audioButton = wrapper.find('.link-audio')
+ let inVolume = false
+ let hideVolumeTimeout
+ let hideVolume = () => {
+ if (inVolume) {
+ hideVolumeTimeout = setTimeout(hideVolume, 1000)
+ } else {
+ volumeInput.parent().addClass('hidden')
+ }
+ }
+ let setVolume = (video, volume) => {
+ video.volume = volume
+ video.muted = volume == 0
+ audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
+ client[video.muted ? 'peerMicMute' : 'peerMicUnmute'](params.id)
+ }
+
+ // Enable and set up the audio mute button
+ audioButton.removeClass('hidden')
+ .on('click', e => {
+ let video = wrapper.find('video')[0]
+
+ setVolume(video, !video.muted ? 0 : 1)
+ volumeInput.val(video.volume)
+ })
+ // Show the volume slider when mouse is over the audio mute/unmute button
+ .on('mouseenter', () => {
+ let video = wrapper.find('video')[0]
+
+ clearTimeout(hideVolumeTimeout)
+ volumeInput.parent().removeClass('hidden')
+ volumeInput.val(video.volume)
+ })
+ .on('mouseleave', () => {
+ hideVolumeTimeout = setTimeout(hideVolume, 1000)
+ })
+
+ // Set up the audio volume control
+ volumeInput
+ .on('mouseenter', () => { inVolume = true })
+ .on('mouseleave', () => { inVolume = false })
+ .on('change input', () => {
+ setVolume(wrapper.find('video')[0], volumeInput.val())
+ })
+ }
+
+ participantUpdate(wrapper, params)
+
+ // Fullscreen control
+ if (document.fullscreenEnabled) {
+ wrapper.find('.link-fullscreen.closed').removeClass('hidden')
+ .on('click', () => {
+ wrapper.get(0).requestFullscreen()
+ })
+
+ wrapper.find('.link-fullscreen.open')
+ .on('click', () => {
+ document.exitFullscreen()
+ })
+
+ wrapper.on('fullscreenchange', () => {
+ // const enabled = document.fullscreenElement
+ wrapper.find('.link-fullscreen').toggleClass('hidden')
+ })
+ }
+
+ // Remove the subscriber element, if exists
+ $('#subscriber-' + params.id).remove()
+
+ let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
+
+ return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
+ .attr('id', (isScreen ? 'screen-' : 'publisher-') + params.id)
+ .get(0)
+ }
+
+ /**
+ * Update the publisher/subscriber element controls
+ *
+ * @param wrapper The wrapper element
+ * @param params Peer metadata/params
+ */
+ function participantUpdate(wrapper, params) {
+ const element = $(wrapper)
+ const isSelf = params.isSelf
+ const rolePublisher = params.role & Roles.PUBLISHER
+ const roleModerator = params.role & Roles.MODERATOR
+ const roleScreen = params.role & Roles.SCREEN
+ const roleOwner = params.role & Roles.OWNER
+ const roleInterpreter = rolePublisher && !!params.language
+ const audioActive = roleScreen ? true : params.audioActive
+ const videoActive = roleScreen ? true : params.videoActive
+
+ element.find('.status-audio')[audioActive ? 'addClass' : 'removeClass']('hidden')
+ element.find('.status-video')[videoActive ? 'addClass' : 'removeClass']('hidden')
+ element.find('.meet-nickname > .content').text(params.nickname || '')
+
+ if (isSelf) {
+ element.addClass('self')
+ } else if (!roleScreen) {
+ element.find('.link-audio').removeClass('hidden')
+ }
+
+ const isModerator = peers[selfId] && peers[selfId].role & Roles.MODERATOR
+ const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf)
+ const withMenu = isSelf || (isModerator && !roleOwner)
+
+ if (isModerator) {
+ element.addClass('moderated')
+ }
+
+ // TODO: This probably could be better done with css
+ let elements = {
+ '.dropdown-menu': withMenu,
+ '.permissions': withPerm,
+ '.interpreting': withPerm && rolePublisher,
+ 'svg.moderator': roleModerator,
+ 'svg.user': !roleModerator && !roleInterpreter,
+ 'svg.interpreter': !roleModerator && roleInterpreter
+ }
+
+ Object.keys(elements).forEach(key => {
+ element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
+ })
+
+ element.find('.action-role-publisher input').prop('checked', rolePublisher)
+ element.find('.action-role-moderator input').prop('checked', roleModerator)
+ .prop('disabled', roleOwner)
+
+ element.find('.interpreting select').val(roleInterpreter ? params.language : '')
+ }
+
+ /**
+ * Update/refresh state of all participants' elements
+ */
+ function participantUpdateAll() {
+ Object.keys(peers).forEach(peerId => {
+ const peer = peers[peerId]
+ participantUpdate(peer.element, peer)
+ })
+ }
+
+ /**
+ * Create a tag-like element for a subscriber participant
+ *
+ * @param params Connection metadata/params
+ * @param content Optional content to prepend to the element
+ */
+ function subscriberCreate(params, content) {
+ // Create the element
+ let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
+
+ if (content) {
+ wrapper.prepend(content)
+ }
+
+ participantUpdate(wrapper, params)
+
+ // Two event handlers below fix the dropdown in the subscribers list.
+ // Normally it gets hidden because of the overflow/height of the container.
+ // FIXME: I think it wasn't needed in BS4, but it's a problem with BS5.
+ const nickname = wrapper.children('.dropdown')[0]
+ nickname.addEventListener('show.bs.dropdown', () => {
+ $(subscribersContainer).css({
+ 'overflow-y': 'unset',
+ height: $(subscribersContainer).height() + 'px'
+ })
+ })
+ nickname.addEventListener('hide.bs.dropdown', () => {
+ $(subscribersContainer).css({
+ 'overflow-y': 'auto',
+ height: 'unset'
+ })
+ })
+
+ return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
+ .attr('id', 'subscriber-' + params.id)
+ .get(0)
+ }
+
+ /**
+ * Create a tag-like nickname widget
+ *
+ * @param object params Peer metadata/params
+ */
+ function nicknameWidget(params) {
+ let languages = []
+
+ // Append languages selection options
+ Object.keys(sessionData.languages).forEach(code => {
+ languages.push(`<option value="${code}">${$t(sessionData.languages[code])}</option>`)
+ })
+
+ // Create the element
+ let element = $(
+ '<div class="dropdown">'
+ + '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ + '<span class="content"></span>'
+ + '<span class="icon">'
+ + svgIcon('user', null, 'user')
+ + svgIcon('crown', null, 'moderator hidden')
+ + svgIcon('headphones', null, 'interpreter hidden')
+ + '</span>'
+ + '</a>'
+ + '<div class="dropdown-menu">'
+ + '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ + '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ + '<div class="dropdown-divider permissions"></div>'
+ + '<div class="permissions">'
+ + '<h6 class="dropdown-header">' + $t('meet.perm') + '</h6>'
+ + '<label class="dropdown-item action-role-publisher form-check form-switch">'
+ + '<input type="checkbox" class="form-check-input">'
+ + ' <span class="form-check-label">' + $t('meet.perm-av') + '</span>'
+ + '</label>'
+ + '<label class="dropdown-item action-role-moderator form-check form-switch">'
+ + '<input type="checkbox" class="form-check-input">'
+ + ' <span class="form-check-label">' + $t('meet.perm-mod') + '</span>'
+ + '</label>'
+ + '</div>'
+ + '<div class="dropdown-divider interpreting"></div>'
+ + '<div class="interpreting">'
+ + '<h6 class="dropdown-header">' + $t('meet.lang-int') + '</h6>'
+ + '<div class="ps-3 pe-3"><select class="form-select">'
+ + '<option value="">- ' + $t('form.none') + ' -</option>'
+ + languages.join('')
+ + '</select></div>'
+ + '</div>'
+ + '</div>'
+ + '</div>'
+ )
+
+ let nickname = element.find('.meet-nickname')
+ .addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
+ .attr({title: $t('meet.menu-options'), 'data-bs-toggle': 'dropdown'})
+
+ const dropdown = new Dropdown(nickname[0], { boundary: container.parentNode })
+
+ if (params.isSelf) {
+ // Add events for nickname change
+ let editable = element.find('.content')[0]
+ let editableEnable = () => {
+ editable.contentEditable = true
+ editable.focus()
+ }
+ let editableUpdate = () => {
+ // Skip redundant update on blur, if it was already updated
+ if (editable.contentEditable !== 'false') {
+ editable.contentEditable = false
+ client.setNickname(editable.innerText)
+ }
+ }
+
+ element.find('.action-nickname').on('click', editableEnable)
+ element.find('.action-dismiss').remove()
+
+ $(editable).on('blur', editableUpdate)
+ .on('keydown', e => {
+ // Enter or Esc
+ if (e.keyCode == 13 || e.keyCode == 27) {
+ editableUpdate()
+ return false
+ }
+
+ // Do not propagate the event, so it does not interfere with our
+ // keyboard shortcuts
+ e.stopPropagation()
+ })
+ } else {
+ element.find('.action-nickname').remove()
+
+ element.find('.action-dismiss').on('click', () => {
+ client.kickPeer(params.id)
+ })
+ }
+
+ // Don't close the menu on permission change
+ element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
+
+ element.find('.action-role-publisher input').on('change', e => {
+ client[e.target.checked ? 'addRole' : 'removeRole'](params.id, Roles.PUBLISHER)
+ })
+
+ element.find('.action-role-moderator input').on('change', e => {
+ client[e.target.checked ? 'addRole' : 'removeRole'](params.id, Roles.MODERATOR)
+ })
+
+ element.find('.interpreting select')
+ .on('change', e => {
+ const language = $(e.target).val()
+ client.setLanguage(params.id, language)
+ dropdown.hide()
+ })
+ .on('click', e => {
+ // Prevents from closing the dropdown menu on click
+ e.stopPropagation()
+ })
+
+ return element.get(0)
+ }
+
+ /**
+ * Window onresize event handler (updates room layout)
+ */
+ function resize() {
+ if (publishersContainer) {
+ updateLayout()
+ }
+
+ $(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
+ }
+
+ /**
+ * Update the room "matrix" layout
+ */
+ function updateLayout() {
+ let publishers = $(publishersContainer).find('.meet-video')
+ let numOfVideos = publishers.length
+
+ if (sessionData && sessionData.counterElement) {
+ sessionData.counterElement.innerHTML = Object.keys(peers).length
+ }
+
+ if (!numOfVideos) {
+ subscribersContainer.style.minHeight = 'auto'
+ return
+ }
+
+ // Note: offsetHeight/offsetWidth return rounded values, but for proper matrix
+ // calculations we need more precision, therefore we use getBoundingClientRect()
+
+ let allHeight = container.offsetHeight
+ let scrollHeight = subscribersContainer.scrollHeight
+ let bcr = publishersContainer.getBoundingClientRect()
+ let containerWidth = bcr.width
+ let containerHeight = bcr.height
+ let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
+
+ // Fix subscribers list height
+ if (subscribersContainer.offsetHeight <= scrollHeight) {
+ limit = Math.min(scrollHeight, limit)
+ subscribersContainer.style.minHeight = limit + 'px'
+ containerHeight = allHeight - limit
+ } else {
+ subscribersContainer.style.minHeight = 'auto'
+ }
+
+ let css, rows, cols, height, padding = 0
+
+ // Make the first screen sharing tile big
+ let screenVideo = publishers.filter('.screen').find('video').get(0)
+
+ if (screenVideo) {
+ const element = screenVideo.parentNode
+ const peerId = element.id.replace(/^screen-/, '')
+ const peer = peers[peerId]
+
+ // We know the shared screen video dimensions, we can calculate
+ // width/height of the tile in the matrix
+ if (peer && peer.screenWidth) {
+ let screenWidth = peer.screenWidth
+ let screenHeight = containerHeight
+
+ // TODO: When the shared window is minimized the width/height is set to 1 (or 2)
+ // - at least on my system. We might need to handle this case nicer. Right now
+ // it create a 1-2px line on the left of the matrix - not a big issue.
+ // TODO: Make the 0.666 factor bigger for wide screen and small number of participants?
+ let maxWidth = Math.ceil(containerWidth * 0.666)
+
+ if (screenWidth > maxWidth) {
+ screenWidth = maxWidth
+ }
+
+ // Set the tile position and size
+ $(element).css({
+ width: screenWidth + 'px',
+ height: screenHeight + 'px',
+ position: 'absolute',
+ top: 0,
+ left: 0
+ })
+
+ padding = screenWidth + 'px'
+
+ // Now the estate for the rest of participants is what's left on the right side
+ containerWidth -= screenWidth
+ publishers = publishers.not(element)
+ numOfVideos -= 1
+ }
+ }
+
+ // Compensate the shared screen estate with a padding
+ $(publishersContainer).css('padding-left', padding)
+
+ const factor = containerWidth / containerHeight
+
+ if (factor >= 16/9) {
+ if (numOfVideos <= 3) {
+ rows = 1
+ } else if (numOfVideos <= 8) {
+ rows = 2
+ } else if (numOfVideos <= 15) {
+ rows = 3
+ } else if (numOfVideos <= 20) {
+ rows = 4
+ } else {
+ rows = 5
+ }
+
+ cols = Math.ceil(numOfVideos / rows)
+ } else {
+ if (numOfVideos == 1) {
+ cols = 1
+ } else if (numOfVideos <= 4) {
+ cols = 2
+ } else if (numOfVideos <= 9) {
+ cols = 3
+ } else if (numOfVideos <= 16) {
+ cols = 4
+ } else if (numOfVideos <= 25) {
+ cols = 5
+ } else {
+ cols = 6
+ }
+
+ rows = Math.ceil(numOfVideos / cols)
+
+ if (rows < cols && containerWidth < containerHeight) {
+ cols = rows
+ rows = Math.ceil(numOfVideos / cols)
+ }
+ }
+
+ // console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows)
+
+ // Update all tiles (except the main shared screen) in the matrix
+ publishers.css({
+ width: (containerWidth / cols) + 'px',
+ // Height must be in pixels to make object-fit:cover working
+ height: (containerHeight / rows) + 'px'
+ })
+ }
+
+ /**
+ * Create an svg element (string) for a FontAwesome icon
+ *
+ * @todo Find if there's a "official" way to do this
+ */
+ function svgIcon(name, type, className) {
+ // Note: the library will contain definitions for all icons registered elswhere
+ const icon = library.definitions[type || 'fas'][name]
+
+ let attrs = {
+ 'class': 'svg-inline--fa',
+ 'aria-hidden': true,
+ focusable: false,
+ role: 'img',
+ xmlns: 'http://www.w3.org/2000/svg',
+ viewBox: `0 0 ${icon[0]} ${icon[1]}`
+ }
+
+ if (className) {
+ attrs['class'] += ' ' + className
+ }
+
+ return $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
+ .attr(attrs)
+ .get(0).outerHTML
+ }
+}
+
+export { Room }
diff --git a/src/resources/js/meet/socket.js b/src/resources/js/meet/socket.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/meet/socket.js
@@ -0,0 +1,126 @@
+'use strict'
+
+import io from "socket.io-client"
+import Config from './config.js'
+
+function Socket(url, options)
+{
+ let eventHandlers = {}
+
+ const socket = io(url, {
+ path: '/meetmedia/signaling/',
+ transports: [ 'websocket' ]
+ })
+
+ socket.io.on("reconnect_attempt", () => {
+ console.log(`WebSocket re-connect attempt`)
+ });
+
+ socket.io.on("reconnect_error", () => {
+ console.log(`WebSocket re-connect error`)
+ });
+
+ socket.io.on("reconnect", () => {
+ console.log(`WebSocket re-connect`)
+ });
+
+ socket.io.on('reconnect_failed', () => {
+ console.log('WebSocket re-connect failed')
+
+ this.trigger('reconnectFailed')
+ })
+
+ socket.on('connect', () => {
+ console.log('WebSocket connect: ' + socket.id)
+ })
+
+ socket.on('connect_error', () => {
+ console.log('WebSocket connect_error: ' + socket.id)
+ })
+
+ socket.on('disconnect', reason => {
+ console.log('WebSocket disconnect: ' + reason)
+
+ this.trigger('disconnect', reason)
+ })
+
+
+ socket.on('request', async (request, cb) => {
+ console.log('Recv: ' + request.method, request.data)
+
+ this.trigger('request', request, cb)
+ })
+
+ socket.on('notification', async notification => {
+ console.log('Recv: ' + notification.method, notification.data)
+
+ this.trigger('notification', notification)
+ })
+
+ this.close = () => {
+ socket.close()
+ }
+
+ this.getRtpCapabilities = async () => {
+ return await this.sendRequest('getRouterRtpCapabilities')
+ }
+
+ /**
+ * Register event handlers
+ */
+ this.on = (eventName, callback) => {
+ eventHandlers[eventName] = callback
+ }
+
+ /**
+ * Execute an event handler
+ */
+ this.trigger = (...args) => {
+ const eventName = args.shift()
+
+ if (eventName in eventHandlers) {
+ eventHandlers[eventName].apply(null, args)
+ }
+ }
+
+ this.sendRequest = (method, data) => {
+ return new Promise((resolve, reject) => {
+ console.log('Send: ' + method, data)
+
+ socket.emit(
+ 'request',
+ { method, data },
+ withTimeout((error, response) => {
+ if (error) {
+ reject(error)
+ } else {
+ console.log('Recv: ' + method, response)
+ resolve(response)
+ }
+ })
+ )
+ })
+ }
+
+ const withTimeout = (callback) => {
+ let called = false
+
+ const timer = setTimeout(
+ () => {
+ if (called) return
+ called = true
+ callback(new Error('Request timed out'))
+ },
+ Config.requestTimeout
+ )
+
+ return (...args) => {
+ if (called) return
+ called = true
+ clearTimeout(timer)
+ callback(...args)
+ }
+ }
+}
+
+export { Socket }
diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss
--- a/src/resources/themes/meet.scss
+++ b/src/resources/themes/meet.scss
@@ -399,6 +399,7 @@
&.self {
background: lighten($main-color, 30%);
+ border-color: transparent;
}
&:last-child {
@@ -434,7 +435,7 @@
width: 100%;
}
- &.widdle {
+ &.wiggle {
top: 0;
animation-name: wiggle;
animation-duration: 1s;
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -10,7 +10,7 @@
<button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="$t('meet.menu-video-' + (videoActive ? 'mute' : 'unmute'))">
<svg-icon :icon="videoActive ? 'video' : 'video-slash'"></svg-icon>
</button>
- <button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" :title="$t('meet.menu-screen')">
+ <button :class="'btn link-screen' + (screenActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" :title="$t('meet.menu-screen')">
<svg-icon icon="desktop"></svg-icon>
</button>
<button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="$t('meet.menu-hand-' + (handRaised ? 'lower' : 'raise'))">
@@ -176,15 +176,19 @@
</div>
<room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></room-options>
+ <room-stats ref="roomStatsDialog" :stats="stats" :room="room"></room-stats>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
- import { Meet, Roles } from '../../js/meet/app.js'
+ import { Modal, Dropdown } from 'bootstrap'
+ import { Media } from '../../js/meet/media.js'
+ import { Room as Meet } from '../../js/meet/room.js'
+ import { Roles } from '../../js/meet/constants.js'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import RoomOptions from './RoomOptions'
+ import RoomStats from './RoomStats'
// Register additional icons
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -231,12 +235,14 @@
)
let roomRequest
+ let statsRequest
const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
LogonForm,
RoomOptions,
+ RoomStats,
StatusMessage
},
data() {
@@ -272,17 +278,18 @@
500: 'meet.status-500'
},
session: {},
+ stats: {},
audioActive: false,
videoActive: false,
chatActive: false,
handRaised: false,
- screenShareActive: false
+ screenActive: false
}
},
mounted() {
this.room = this.$route.params.room
- // Initialize OpenVidu and do some basic checks
+ // Initialize Meet client and do some basic checks
this.meet = new Meet($('#meet-session')[0]);
this.canShareScreen = this.meet.isScreenSharingSupported()
@@ -301,11 +308,14 @@
})
const dialog = $('#media-setup-dialog')[0]
- dialog.addEventListener('show.bs.modal', () => { this.meet.setupStart() })
+ dialog.addEventListener('show.bs.modal', () => { this.setupSession() })
dialog.addEventListener('hide.bs.modal', () => { this.meet.setupStop() })
+
+ this.roomStatsDialog = new Modal('#room-stats-dialog')
},
beforeDestroy() {
clearTimeout(roomRequest)
+ clearInterval(statsRequest)
$('#app').removeClass('meet')
@@ -328,8 +338,9 @@
configUpdate(config) {
this.session.config = Object.assign({}, this.session.config, config)
},
- dismissParticipant(id) {
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss')
+ async refreshStats() {
+ let stats = await this.meet.getStats()
+ this.stats = stats
},
initSession(init) {
const button = $('#join-button').prop('disabled', true)
@@ -346,7 +357,7 @@
$('#setup-password,#setup-nickname').removeClass('is-invalid')
- axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
+ axios.post('/api/v4/meet/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
button.prop('disabled', false)
@@ -361,10 +372,6 @@
if (init) {
this.joinSession()
}
-
- if (this.session.authToken) {
- axios.defaults.headers.common[authHeader] = this.session.authToken
- }
})
.catch(error => {
if (!error.response) {
@@ -447,51 +454,6 @@
isRoomReady() {
return ['ready', 322, 324, 325, 326, 327].includes(this.roomState)
},
- // An event received by the room owner when a participant is asking for a permission to join the room
- joinRequest(data) {
- // The toast for this user request already exists, ignore
- // It's not really needed as we do this on server-side already
- if ($('#i' + data.requestId).length) {
- return
- }
-
- // FIXME: Should the message close button act as the Deny button? Do we need the Deny button?
-
- let body = $(
- `<div>`
- + `<div class="picture"><img src="${data.picture}"></div>`
- + `<div class="content">`
- + `<p class="mb-2"></p>`
- + `<div class="text-end">`
- + `<button type="button" class="btn btn-sm btn-success accept">${this.$t('btn.accept')}</button>`
- + `<button type="button" class="btn btn-sm btn-danger deny ms-2">${this.$t('btn.deny')}</button>`
- )
-
- this.$toast.message({
- className: 'join-request',
- icon: 'user',
- timeout: 0,
- title: this.$t('meet.join-request'),
- // titleClassName: '',
- body: body.html(),
- onShow: element => {
- const id = data.requestId
-
- $(element).find('p').text(this.$t('meet.join-requested', { user: data.nickname || '' }))
-
- // add id attribute, so we can identify it
- $(element).attr('id', 'i' + id)
- // add action to the buttons
- .find('button.accept,button.deny').on('click', e => {
- const action = $(e.target).is('.accept') ? 'accept' : 'deny'
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action)
- .then(response => {
- $('#i' + id).remove()
- })
- })
- }
- })
- },
// Entering the room
joinSession() {
// The form can be submitted not only via the submit button,
@@ -512,6 +474,7 @@
}
clearTimeout(roomRequest)
+ clearInterval(statsRequest)
this.session.nickname = this.nickname
this.session.languages = this.languages
@@ -520,6 +483,7 @@
this.session.queueElement = $('#meet-queue')[0]
this.session.counterElement = $('#meet-counter span')[0]
this.session.translate = (label, args) => this.$t(label, args)
+ this.session.toast = this.$toast
this.session.onSuccess = () => {
$('#app').addClass('meet')
$('#meet-setup').addClass('hidden')
@@ -529,20 +493,18 @@
this.roomState = 500
}
this.session.onDestroy = event => {
- // TODO: Display different message for each reason: forceDisconnectByUser,
- // forceDisconnectByServer, sessionClosedByServer?
- if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.isRoomOwner()) {
+ // TODO: Display different message for every other reason
+ if (event.reason == 'session-closed' && !this.isRoomOwner()) {
new Modal('#leave-dialog').show()
}
}
- this.session.onDismiss = connId => { this.dismissParticipant(connId) }
- this.session.onSessionDataUpdate = data => { this.updateSession(data) }
- this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) }
- this.session.onJoinRequest = data => { this.joinRequest(data) }
+ this.session.onUpdate = data => { this.updateSession(data) }
this.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
+ this.refreshStats()
+
this.keyboardShortcuts()
},
keyboardShortcuts() {
@@ -557,50 +519,19 @@
this.switchSound()
}
}
+ //Show stats with '?' key
+ if (e.key == '?' || e.key == '?') {
+ this.roomStats()
+ }
})
},
logout() {
- const logout = () => {
- this.meet.leaveRoom()
- this.meet = null
- this.$router.push({ name: 'dashboard' })
- }
-
- if (this.isRoomOwner()) {
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
- } else {
- logout()
- }
+ this.meet.leaveRoom(true)
+ this.meet = null
+ this.$router.push({ name: 'dashboard' })
},
makePicture() {
- const video = $("#meet-setup video")[0];
-
- // Skip if video is not "playing"
- if (!video.videoWidth || !this.camera) {
- return ''
- }
-
- // we're going to crop a square from the video and resize it
- const maxSize = 64
-
- // Calculate sizing
- let sh = Math.floor(video.videoHeight / 1.5)
- let sw = sh
- let sx = (video.videoWidth - sw) / 2
- let sy = (video.videoHeight - sh) / 2
-
- let dh = Math.min(sh, maxSize)
- let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
-
- const canvas = $("<canvas>")[0];
- canvas.width = dw;
- canvas.height = dh;
-
- // draw the image on the canvas (square cropped and resized)
- canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh);
-
- // convert it to a usable data URL (png format)
- return canvas.toDataURL();
+ return (new Media()).makePicture($("#meet-setup video")[0]) || '';
},
requestId() {
const key = 'kolab-meet-uid'
@@ -627,6 +558,18 @@
roomOptions() {
new Modal('#room-options-dialog').show()
},
+ roomStats() {
+ clearInterval(statsRequest)
+ if (this.roomStatsDialog.visible) {
+ this.roomStatsDialog.hide()
+ } else {
+ this.refreshStats()
+ statsRequest = setInterval(() => {
+ this.refreshStats()
+ }, 3000)
+ this.roomStatsDialog.show()
+ }
+ },
setupMedia() {
const dialog = $('#media-setup-dialog')[0]
@@ -638,37 +581,32 @@
},
setupSession() {
this.meet.setupStart({
- videoElement: $('#meet-setup video')[0],
- volumeElement: $('#meet-setup .volume')[0],
+ videoElement: $('#meet-setup video')[0] || $('#media-setup-dialog video')[0],
+ volumeElement: $('#meet-setup .volume')[0] || $('#media-setup-dialog .volume')[0],
onSuccess: setup => {
this.setup = setup
this.microphone = setup.audioSource
this.camera = setup.videoSource
-
this.audioActive = setup.audioActive
this.videoActive = setup.videoActive
},
onError: error => {
+ console.warn("Media setup failed: ", error);
this.audioActive = false
this.videoActive = false
}
})
},
- setupCameraChange() {
- this.meet.setupSetVideoDevice(this.camera).then(enabled => {
- this.videoActive = enabled
- })
+ async setupCameraChange() {
+ this.videoActive = await this.meet.setupSetVideoDevice(this.camera)
},
- setupMicrophoneChange() {
- this.meet.setupSetAudioDevice(this.microphone).then(enabled => {
- this.audioActive = enabled
- })
+ async setupMicrophoneChange() {
+ this.audioActive = await this.meet.setupSetAudioDevice(this.microphone)
},
switchChannel(e) {
- let channel = $(e.target).data('code')
-
- this.$set(this.session, 'channel', channel)
- this.meet.switchChannel(channel)
+ this.meet.switchChannel($(e.target).data('code'))
+ // FIXME: Why is the menu not closing by itself?
+ new Dropdown('#meet-session-menu .link-channel').hide()
},
switchChat() {
let chat = $('#meet-chat')
@@ -702,59 +640,27 @@
element.requestFullscreen()
}
},
- switchHand() {
- this.updateSelf({ hand: !this.handRaised })
+ async switchHand() {
+ this.handRaised = await this.meet.raiseHand(!this.handRaised)
},
- switchSound() {
- this.audioActive = this.meet.switchAudio()
+ async switchSound() {
+ this.audioActive = await this.meet.switchAudio()
},
- switchVideo() {
- this.videoActive = this.meet.switchVideo()
+ async switchVideo() {
+ this.videoActive = await this.meet.switchVideo()
},
- switchScreen() {
- const switchScreenAction = () => {
- this.meet.switchScreen((enabled, error) => {
- this.screenShareActive = enabled
- if (!enabled && !error) {
- // Closing a screen sharing connection invalidates the token
- delete this.session.shareToken
- }
- })
- }
-
- if (this.session.shareToken || this.screenShareActive) {
- switchScreenAction()
- } else {
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
- .then(response => {
- this.session.shareToken = response.data.token
- this.meet.updateSession(this.session)
- switchScreenAction()
- })
- }
- },
- updateParticipant(connId, params) {
- if (this.isModerator()) {
- axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
- }
- },
- updateSelf(params, onSuccess) {
- axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + this.session.connectionId, params)
- .then(response => {
- if (onSuccess) {
- onSuccess(response)
- }
- })
+ async switchScreen() {
+ this.screenActive = await this.meet.switchScreen()
},
updateSession(data) {
- this.session = data
+ this.session = Object.assign({}, this.session, data)
this.channels = data.channels || []
const isPublisher = this.isPublisher()
this.videoActive = isPublisher ? data.videoActive : false
this.audioActive = isPublisher ? data.audioActive : false
- this.handRaised = data.hand
+ this.handRaised = data.raisedHand
}
}
}
diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -68,7 +68,7 @@
const post = {}
post[name] = value
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/config', post)
+ axios.post('/api/v4/meet/rooms/' + this.room + '/config', post)
.then(response => {
this.$set(this.config, name, value)
if (callback) {
diff --git a/src/resources/vue/Meet/RoomStats.vue b/src/resources/vue/Meet/RoomStats.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Meet/RoomStats.vue
@@ -0,0 +1,91 @@
+
+<template>
+ <div id="room-stats-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Statistics</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ </div>
+ <div class="modal-body">
+ <form>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Room Id:</span>
+ <span class="input-group-text">{{ room }}</span>
+ </div>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Mediaserver Room Id:</span>
+ <span class="input-group-text">{{ stats.roomId }}</span>
+ </div>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Send Transport state:</span>
+ <span class="input-group-text">{{ stats.sendTransportState }}</span>
+ </div>
+ <pre class="text-muted">
+ {{ JSON.stringify(stats.sendTransportStats, null, '\t') }}
+ </pre>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Receive Transport state:</span>
+ <span class="input-group-text">{{ stats.receiveTransportState }}</span>
+ </div>
+ <pre class="text-muted">
+ {{ JSON.stringify(stats.receiveTransportStats, null, '\t') }}
+ </pre>
+ </form>
+ <hr>
+ <form>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Consumers:</span>
+ </div>
+ <pre class="text-muted">
+ {{ JSON.stringify(stats.consumerStats, null, '\t') }}
+ </pre>
+ </form>
+ <hr>
+ <form>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Camera Producer:</span>
+ </div>
+ <pre class="text-muted">
+ {{ JSON.stringify(stats.camProducerStats, null, '\t') }}
+ </pre>
+ </form>
+ <hr>
+ <form>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Mic Producer:</span>
+ </div>
+ <pre class="text-muted">
+ {{ JSON.stringify(stats.micProducerStats, null, '\t') }}
+ </pre>
+ </form>
+ <hr>
+ <form>
+ <div class="input-group input-group-activable mb-2">
+ <span class="input-group-text label">Screen Producer:</span>
+ </div>
+ <pre class="text-muted">
+ {{ JSON.stringify(stats.screenProducerStats, null, '\t') }}
+ </pre>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ stats: { type: Object, default: () => null },
+ room: { type: String, default: () => null }
+ },
+ mounted() {
+ $('#room-stats-dialog')[0].addEventListener('show.bs.modal', e => {
+ $(e.target).find('.input-group-activable.active').removeClass('active')
+ })
+ },
+ methods: {
+ }
+ }
+</script>
diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue
--- a/src/resources/vue/Rooms.vue
+++ b/src/resources/vue/Rooms.vue
@@ -48,7 +48,7 @@
this.$root.startLoading()
- axios.get('/api/v4/openvidu/rooms')
+ axios.get('/api/v4/meet/rooms')
.then(response => {
this.$root.stopLoading()
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -80,6 +80,9 @@
Route::apiResource('packages', 'API\V4\PackagesController');
+ Route::get('meet/rooms', 'API\V4\MeetController@index');
+ Route::post('meet/rooms/{id}/config', 'API\V4\MeetController@setRoomConfig');
+
Route::apiResource('resources', 'API\V4\ResourcesController');
Route::get('resources/{id}/status', 'API\V4\ResourcesController@status');
Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig');
@@ -109,16 +112,6 @@
Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments');
-
- Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
- Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
- Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig');
-
- // FIXME: I'm not sure about this one, should we use DELETE request maybe?
- Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
- Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
- Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
- Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
@@ -129,13 +122,7 @@
'prefix' => $prefix . 'api/v4'
],
function () {
- Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
- Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
- // FIXME: I'm not sure about this one, should we use DELETE request maybe?
- Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
- Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
- Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
- Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
+ Route::post('meet/rooms/{id}', 'API\V4\MeetController@joinRoom');
}
);
@@ -157,7 +144,7 @@
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
- Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook');
+ Route::post('meet', 'API\V4\MeetController@webhook');
}
);
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -262,4 +262,38 @@
return $this;
}
+
+ /**
+ * Store the console output with the given name. Overwrites Dusk's method.
+ *
+ * @param string $name
+ * @return $this
+ */
+ public function storeConsoleLog($name)
+ {
+ if (in_array($this->driver->getCapabilities()->getBrowserName(), static::$supportsRemoteLogs)) {
+ $console = $this->driver->manage()->getLog('browser');
+
+ // Ignore errors/warnings irrelevant for testing
+ foreach ($console as $idx => $entry) {
+ if (
+ $entry['level'] != 'SEVERE'
+ || strpos($entry['message'], 'Failed to load resource: the server responded with a status of')
+ ) {
+ $console[$idx] = null;
+ }
+ }
+
+ $console = array_values(array_filter($console));
+
+ if (!empty($console)) {
+ $file = sprintf('%s/%s.log', rtrim(static::$storeConsoleLogAt, '/'), $name);
+ $content = json_encode($console, JSON_PRETTY_PRINT);
+
+ file_put_contents($file, $content);
+ }
+ }
+
+ return $this;
+ }
}
diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php
--- a/src/tests/Browser/Meet/RoomControlsTest.php
+++ b/src/tests/Browser/Meet/RoomControlsTest.php
@@ -2,7 +2,7 @@
namespace Tests\Browser\Meet;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
@@ -27,7 +27,7 @@
/**
* Test fullscreen buttons
*
- * @group openvidu
+ * @group meet
*/
public function testFullscreen(): void
{
@@ -76,7 +76,7 @@
/**
* Test nickname and audio/video muting/volume controls
*
- * @group openvidu
+ * @group meet
*/
public function testNicknameAndMuting(): void
{
@@ -170,7 +170,7 @@
->waitFor('div.meet-video.self .status .status-audio');
// FIXME: It looks that we can't just check the <video> element state
- // We might consider using OpenVidu API to make sure
+ // We might consider using some API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-audio');
// Test unmuting audio
@@ -202,7 +202,7 @@
->assertVisible('div.meet-video.self .status .status-video');
// FIXME: It looks that we can't just check the <video> element state
- // We might consider using OpenVidu API to make sure
+ // We might consider using some API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-video');
// Test unmuting video
@@ -261,7 +261,7 @@
/**
* Test text chat
*
- * @group openvidu
+ * @group meet
*/
public function testChat(): void
{
@@ -356,7 +356,7 @@
/**
* Test screen sharing
*
- * @group openvidu
+ * @group meet
*/
public function testShareScreen(): void
{
@@ -387,8 +387,8 @@
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
- ->assertVisible('.controls button.link-audio')
- ->assertVisible('.status .status-audio')
+ ->assertMissing('.controls button.link-audio')
+ ->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
@@ -400,8 +400,8 @@
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
- ->assertVisible('.controls button.link-audio')
- ->assertVisible('.status .status-audio')
+ ->assertMissing('.controls button.link-audio')
+ ->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
diff --git a/src/tests/Browser/Meet/RoomInterpretersTest.php b/src/tests/Browser/Meet/RoomInterpretersTest.php
--- a/src/tests/Browser/Meet/RoomInterpretersTest.php
+++ b/src/tests/Browser/Meet/RoomInterpretersTest.php
@@ -2,7 +2,7 @@
namespace Tests\Browser\Meet;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
@@ -27,7 +27,7 @@
/**
* Test language interpreted channels functionality
*
- * @group openvidu
+ * @group meet
*/
public function testInterpreters(): void
{
diff --git a/src/tests/Browser/Meet/RoomModeratorTest.php b/src/tests/Browser/Meet/RoomModeratorTest.php
--- a/src/tests/Browser/Meet/RoomModeratorTest.php
+++ b/src/tests/Browser/Meet/RoomModeratorTest.php
@@ -2,7 +2,7 @@
namespace Tests\Browser\Meet;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
@@ -29,7 +29,7 @@
/**
* Test three users in a room, one will be promoted/demoted to/from a moderator
*
- * @group openvidu
+ * @group meet
*/
public function testModeratorPromotion(): void
{
@@ -86,15 +86,13 @@
$guest2->waitFor('@session video')
->assertVisible('@session div.meet-video svg.user') // self
- ->assertMissing('@session div.meet-video svg.moderator'); // self
- /*
- it does not work because the order is different all the time
-
+ ->assertMissing('@session div.meet-video svg.moderator') // self
+ // the following 4 assertions used to be flaky on openvidu
+ // because the order is different all the time
->assertMissing('@session div.meet-subscriber:nth-child(1) svg.user') // owner
->assertVisible('@session div.meet-subscriber:nth-child(1) svg.moderator') // owner
->assertVisible('@session div.meet-subscriber:nth-child(2) svg.user') // guest1
->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); // guest1
- */
// Promote guest1 to a moderator
$browser->waitFor('@session video')
diff --git a/src/tests/Browser/Meet/RoomOptionsTest.php b/src/tests/Browser/Meet/RoomOptionsTest.php
--- a/src/tests/Browser/Meet/RoomOptionsTest.php
+++ b/src/tests/Browser/Meet/RoomOptionsTest.php
@@ -2,7 +2,7 @@
namespace Tests\Browser\Meet;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
@@ -29,7 +29,7 @@
/**
* Test password protected room
*
- * @group openvidu
+ * @group meet
*/
public function testRoomPassword(): void
{
@@ -117,7 +117,7 @@
/**
* Test locked room (denying the join request)
*
- * @group openvidu
+ * @group meet
*/
public function testLockedRoomDeny(): void
{
@@ -191,7 +191,7 @@
/**
* Test locked room (accepting the join request, and dismissing a user)
*
- * @group openvidu
+ * @group meet
*/
public function testLockedRoomAcceptAndDismiss(): void
{
@@ -269,7 +269,7 @@
/**
* Test nomedia (subscribers only) feature
*
- * @group openvidu
+ * @group meet
*/
public function testSubscribersOnly(): void
{
diff --git a/src/tests/Browser/Meet/RoomQATest.php b/src/tests/Browser/Meet/RoomQATest.php
--- a/src/tests/Browser/Meet/RoomQATest.php
+++ b/src/tests/Browser/Meet/RoomQATest.php
@@ -2,7 +2,7 @@
namespace Tests\Browser\Meet;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
@@ -27,7 +27,7 @@
/**
* Test Q&A queue
*
- * @group openvidu
+ * @group meet
*/
public function testQA(): void
{
@@ -109,7 +109,7 @@
// Promote guest (2) to publisher
$owner->waitFor('@queue .dropdown:not(.self)')
- ->pause(8000) // wait until it's not moving, otherwise click() will be possible
+ ->pause(8000) // wait until it's not moving, otherwise click() will fail
->click('@queue .dropdown:not(.self)')
->whenAvailable('@queue .dropdown:not(.self) .dropdown-menu', function ($browser) {
$browser->click('.action-role-publisher input');
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -2,7 +2,7 @@
namespace Tests\Browser\Meet;
-use App\OpenVidu\Room;
+use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
@@ -29,7 +29,7 @@
/**
* Test non-existing room
*
- * @group openvidu
+ * @group meet
*/
public function testRoomNonExistingRoom(): void
{
@@ -63,7 +63,7 @@
/**
* Test the room setup page
*
- * @group openvidu
+ * @group meet
*/
public function testRoomSetup(): void
{
@@ -116,7 +116,7 @@
/**
* Test two users in a room (joining/leaving and some basic functionality)
*
- * @group openvidu
+ * @group meet
* @depends testRoomSetup
*/
public function testTwoUsersInARoom(): void
@@ -165,11 +165,7 @@
->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.")
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'john')
- // Join the room (click the button twice, to make sure it does not
- // produce redundant participants/subscribers in the room)
->clickWhenEnabled('@setup-button')
- ->pause(5)
- ->click('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
@@ -280,7 +276,7 @@
/**
* Test two subscribers-only users in a room
*
- * @group openvidu
+ * @group meet
* @depends testTwoUsersInARoom
*/
public function testSubscribers(): void
@@ -369,7 +365,7 @@
/**
* Test demoting publisher to a subscriber
*
- * @group openvidu
+ * @group meet
* @depends testSubscribers
*/
public function testDemoteToSubscriber(): void
@@ -511,7 +507,7 @@
/**
* Test the media setup dialog
*
- * @group openvidu
+ * @group meet
* @depends testDemoteToSubscriber
*/
public function testMediaSetupDialog(): void
@@ -567,7 +563,7 @@
->assertVisible('@session .meet-video .status .status-video');
$guest->waitFor('@session video')
- ->assertVisible('@session .meet-video .status .status-audio')
+ ->waitFor('@session .meet-video .status .status-audio')
->assertVisible('@session .meet-video .status .status-video');
});
}
diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php
--- a/src/tests/Browser/Meet/RoomsTest.php
+++ b/src/tests/Browser/Meet/RoomsTest.php
@@ -34,7 +34,7 @@
/**
* Test rooms page (unauthenticated and unauthorized)
*
- * @group openvidu
+ * @group meet
*/
public function testRoomsUnauth(): void
{
@@ -53,7 +53,7 @@
/**
* Test rooms page
*
- * @group openvidu
+ * @group meet
*/
public function testRooms(): void
{
diff --git a/src/tests/Feature/Controller/MeetTest.php b/src/tests/Feature/Controller/MeetTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/MeetTest.php
@@ -0,0 +1,450 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\V4\MeetController;
+use App\Meet\Room;
+use Tests\TestCase;
+
+class MeetTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->clearMeetEntitlements();
+ $room = Room::where('name', 'john')->first();
+ $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
+ }
+
+ public function tearDown(): void
+ {
+ $this->clearMeetEntitlements();
+ $room = Room::where('name', 'john')->first();
+ $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test listing user rooms
+ *
+ * @group meet
+ */
+ public function testIndex(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ Room::where('user_id', $jack->id)->delete();
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/meet/rooms");
+ $response->assertStatus(401);
+
+ // John has one room
+ $response = $this->actingAs($john)->get("api/v4/meet/rooms");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame('john', $json['list'][0]['name']);
+
+ // Jack has no room, but it will be auto-created
+ $response = $this->actingAs($jack)->get("api/v4/meet/rooms");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertMatchesRegularExpression('/^[0-9a-z-]{11}$/', $json['list'][0]['name']);
+ }
+
+ /**
+ * Test joining the room
+ *
+ * @group meet
+ */
+ public function testJoinRoom(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $room = Room::where('name', 'john')->first();
+ $room->session_id = null;
+ $room->save();
+
+ $this->assignMeetEntitlement($john);
+
+ // Unauth access, no session yet
+ $response = $this->post("api/v4/meet/rooms/{$room->name}");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame(323, $json['code']);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/non-existing");
+ $response->assertStatus(404);
+
+ // TODO: Test accessing an existing room of deleted owner
+
+ // Non-owner, no session yet
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame(323, $json['code']);
+
+ // Room owner, no session yet
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame(324, $json['code']);
+
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $session_id = $room->fresh()->session_id;
+
+ $this->assertSame(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $json['role']);
+ $this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
+ $this->assertMatchesRegularExpression('|&roomId=' . $session_id . '|', $json['token']);
+
+ $john_token = $json['token'];
+
+ // Non-owner, now the session exists, no 'init' argument
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame(322, $json['code']);
+ $this->assertTrue(empty($json['token']));
+
+ // Non-owner, now the session exists, with 'init', but no 'canPublish' argument
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(Room::ROLE_SUBSCRIBER, $json['role']);
+ $this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
+ $this->assertMatchesRegularExpression('|&roomId=' . $session_id . '|', $json['token']);
+ $this->assertTrue($json['token'] != $john_token);
+
+ // Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
+ $post = ['canPublish' => true, 'init' => 1];
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
+ $this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
+ $this->assertMatchesRegularExpression('|&roomId=' . $session_id . '|', $json['token']);
+ $this->assertTrue($json['token'] != $john_token);
+ $this->assertEmpty($json['config']['password']);
+ $this->assertEmpty($json['config']['requires_password']);
+
+ // Non-owner, password protected room, password not provided
+ $room->setSettings(['password' => 'pass']);
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(4, $json);
+ $this->assertSame(325, $json['code']);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to join the session. Invalid password.', $json['message']);
+ $this->assertEmpty($json['config']['password']);
+ $this->assertTrue($json['config']['requires_password']);
+
+ // Non-owner, password protected room, invalid provided
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", ['password' => 'aa']);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame(325, $json['code']);
+
+ // Non-owner, password protected room, valid password provided
+ // TODO: Test without init=1
+ $post = ['password' => 'pass', 'init' => 'init'];
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ // Make sure the room owner can access the password protected room w/o password
+ // TODO: Test without init=1
+ $post = ['init' => 'init'];
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ // Test 'nomedia' room option
+ $room->setSettings(['nomedia' => 'true', 'password' => null]);
+
+ $post = ['init' => 'init', 'canPublish' => true];
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(Room::ROLE_PUBLISHER & $json['role'], Room::ROLE_PUBLISHER);
+
+ $post = ['init' => 'init', 'canPublish' => true];
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(Room::ROLE_PUBLISHER & $json['role'], 0);
+ }
+
+ /**
+ * Test locked room and join requests
+ *
+ * @group meet
+ */
+ public function testJoinRequests(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $room = Room::where('name', 'john')->first();
+ $room->session_id = null;
+ $room->save();
+ $room->setSettings(['password' => null, 'locked' => 'true']);
+
+ $this->assignMeetEntitlement($john);
+
+ // Create the session (also makes sure the owner can access a locked room)
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ // Non-owner, locked room, invalid/missing input
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(4, $json);
+ $this->assertSame(326, $json['code']);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to join the session. Room locked.', $json['message']);
+ $this->assertTrue($json['config']['locked']);
+
+ // Non-owner, locked room, invalid requestId
+ $post = ['nickname' => 'name', 'requestId' => '-----', 'init' => 1];
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame(326, $json['code']);
+
+ // Non-owner, locked room, invalid requestId
+ $post = ['nickname' => 'name', 'init' => 1];
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame(326, $json['code']);
+
+ // Non-owner, locked room, valid input
+ $reqId = '12345678';
+ $post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234'];
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(4, $json);
+ $this->assertSame(327, $json['code']);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to join the session. Room locked.', $json['message']);
+ $this->assertTrue($json['config']['locked']);
+
+ $room->refresh();
+
+ $request = $room->requestGet($reqId);
+
+ $this->assertSame($post['nickname'], $request['nickname']);
+ $this->assertSame($post['requestId'], $request['requestId']);
+
+ $room->requestAccept($reqId);
+
+ // Non-owner, locked room, join request accepted
+ $post['init'] = 1;
+ $post['canPublish'] = true;
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
+ $this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
+
+ // TODO: Test a scenario where both password and lock are enabled
+ // TODO: Test accepting/denying as a non-owner moderator
+ // TODO: Test somehow websocket communication
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test joining the room
+ *
+ * @group meet
+ * @depends testJoinRoom
+ */
+ public function testJoinRoomGuest(): void
+ {
+ $this->assignMeetEntitlement('john@kolab.org');
+
+ // There's no easy way to logout the user in the same test after
+ // using actingAs(). That's why this is moved to a separate test
+ $room = Room::where('name', 'john')->first();
+
+ // Guest, request with screenShare token
+ $post = ['canPublish' => true, 'screenShare' => 1, 'init' => 1];
+ $response = $this->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
+ $this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
+ }
+
+ /**
+ * Test configuring the room (session)
+ *
+ * @group meet
+ */
+ public function testSetRoomConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $room = Room::where('name', 'john')->first();
+
+ // Unauth access not allowed
+ $response = $this->post("api/v4/meet/rooms/{$room->name}/config", []);
+ $response->assertStatus(401);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/non-existing/config", []);
+ $response->assertStatus(404);
+
+ // TODO: Test a room with a deleted owner
+
+ // Non-owner
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}/config", []);
+ $response->assertStatus(403);
+
+ // Room owner
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room configuration updated successfully.", $json['message']);
+
+ // Set password and room lock
+ $post = ['password' => 'aaa', 'locked' => 1];
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room configuration updated successfully.", $json['message']);
+ $room->refresh();
+ $this->assertSame('aaa', $room->getSetting('password'));
+ $this->assertSame('true', $room->getSetting('locked'));
+
+ // Unset password and room lock
+ $post = ['password' => '', 'locked' => 0];
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room configuration updated successfully.", $json['message']);
+ $room->refresh();
+ $this->assertSame(null, $room->getSetting('password'));
+ $this->assertSame(null, $room->getSetting('locked'));
+
+ // Test invalid option error
+ $post = ['password' => 'eee', 'unknown' => 0];
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Invalid room configuration option.", $json['errors']['unknown']);
+
+ $room->refresh();
+ $this->assertSame(null, $room->getSetting('password'));
+ }
+
+ /**
+ * Test the webhook
+ *
+ * @group meet
+ */
+ public function testWebhook(): void
+ {
+ $this->assignMeetEntitlement('john@kolab.org');
+
+ $john = $this->getTestUser('john@kolab.org');
+ $room = Room::where('name', 'john')->first();
+ $headers = ['X-Auth-Token' => \config('meet.webhook_token')];
+
+ // First, create the session
+ $post = ['init' => 1];
+ $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $sessionId = $room->fresh()->session_id;
+
+ // Test accepting a join request
+ $room->requestSave('1234', ['nickname' => 'test']);
+
+ $post = ['roomId' => $sessionId, 'requestId' => '1234', 'event' => 'joinRequestAccepted'];
+ $response = $this->post("api/webhooks/meet", $post);
+ $response->assertStatus(403); // 403 because no auth token
+
+ $response = $this->withHeaders($headers)->post("api/webhooks/meet", $post);
+ $response->assertStatus(200);
+
+ $request = $room->requestGet('1234');
+
+ $this->assertSame(Room::REQUEST_ACCEPTED, $request['status']);
+
+ // Test denying a join request
+ $room->requestSave('1234', ['nickname' => 'test']);
+
+ $post = ['roomId' => $sessionId, 'requestId' => '1234', 'event' => 'joinRequestDenied'];
+ $response = $this->withHeaders($headers)->post("api/webhooks/meet", $post);
+ $response->assertStatus(200);
+
+ $request = $room->requestGet('1234');
+
+ $this->assertSame(Room::REQUEST_DENIED, $request['status']);
+
+ // Test closing the session
+ $post = ['roomId' => $sessionId, 'event' => 'roomClosed'];
+ $response = $this->withHeaders($headers)->post("api/webhooks/meet", $post);
+ $response->assertStatus(200);
+
+ $this->assertNull($room->fresh()->session_id);
+ }
+}
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
deleted file mode 100644
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ /dev/null
@@ -1,781 +0,0 @@
-<?php
-
-namespace Tests\Feature\Controller;
-
-use App\Http\Controllers\API\V4\OpenViduController;
-use App\OpenVidu\Connection;
-use App\OpenVidu\Room;
-use Tests\TestCase;
-
-class OpenViduTest extends TestCase
-{
- /**
- * {@inheritDoc}
- */
- public function setUp(): void
- {
- parent::setUp();
-
- $this->clearMeetEntitlements();
- $room = Room::where('name', 'john')->first();
- $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
- }
-
- public function tearDown(): void
- {
- $this->clearMeetEntitlements();
- $room = Room::where('name', 'john')->first();
- $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
-
- parent::tearDown();
- }
-
- /**
- * Test listing user rooms
- *
- * @group openvidu
- */
- public function testIndex(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- Room::where('user_id', $jack->id)->delete();
-
- // Unauth access not allowed
- $response = $this->get("api/v4/openvidu/rooms");
- $response->assertStatus(401);
-
- // John has one room
- $response = $this->actingAs($john)->get("api/v4/openvidu/rooms");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(1, $json['count']);
- $this->assertCount(1, $json['list']);
- $this->assertSame('john', $json['list'][0]['name']);
-
- // Jack has no room, but it will be auto-created
- $response = $this->actingAs($jack)->get("api/v4/openvidu/rooms");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(1, $json['count']);
- $this->assertCount(1, $json['list']);
- $this->assertMatchesRegularExpression('/^[0-9a-z-]{11}$/', $json['list'][0]['name']);
- }
-
- /**
- * Test joining the room
- *
- * @group openvidu
- */
- public function testJoinRoom(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
- $room->session_id = null;
- $room->save();
-
- $this->assignMeetEntitlement($john);
-
- // Unauth access, no session yet
- $response = $this->post("api/v4/openvidu/rooms/{$room->name}");
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(323, $json['code']);
-
- // Non-existing room name
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing");
- $response->assertStatus(404);
-
- // TODO: Test accessing an existing room of deleted owner
-
- // Non-owner, no session yet
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(323, $json['code']);
-
- // Room owner, no session yet
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}");
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(324, $json['code']);
-
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $session_id = $room->fresh()->session_id;
-
- $this->assertSame(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $json['role']);
- $this->assertSame($session_id, $json['session']);
- $this->assertTrue(is_string($session_id) && !empty($session_id));
- $this->assertTrue(strpos($json['token'], 'wss://') === 0);
-
- $john_token = $json['token'];
-
- // Non-owner, now the session exists, no 'init' argument
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame(322, $json['code']);
- $this->assertTrue(empty($json['token']));
-
- // Non-owner, now the session exists, with 'init', but no 'canPublish' argument
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(Room::ROLE_SUBSCRIBER, $json['role']);
- $this->assertSame($session_id, $json['session']);
- $this->assertTrue(strpos($json['token'], 'wss://') === 0);
- $this->assertTrue($json['token'] != $john_token);
-
- // Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
- $post = ['canPublish' => true, 'init' => 1];
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
- $this->assertSame($session_id, $json['session']);
- $this->assertTrue(strpos($json['token'], 'wss://') === 0);
- $this->assertTrue($json['token'] != $john_token);
- $this->assertEmpty($json['config']['password']);
- $this->assertEmpty($json['config']['requires_password']);
-
- // Non-owner, password protected room, password not provided
- $room->setSettings(['password' => 'pass']);
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertCount(4, $json);
- $this->assertSame(325, $json['code']);
- $this->assertSame('error', $json['status']);
- $this->assertSame('Failed to join the session. Invalid password.', $json['message']);
- $this->assertEmpty($json['config']['password']);
- $this->assertTrue($json['config']['requires_password']);
-
- // Non-owner, password protected room, invalid provided
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['password' => 'aa']);
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(325, $json['code']);
-
- // Non-owner, password protected room, valid password provided
- // TODO: Test without init=1
- $post = ['password' => 'pass', 'init' => 'init'];
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame($session_id, $json['session']);
-
- // Make sure the room owner can access the password protected room w/o password
- // TODO: Test without init=1
- $post = ['init' => 'init'];
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- // Test 'nomedia' room option
- $room->setSettings(['nomedia' => 'true', 'password' => null]);
-
- $post = ['init' => 'init', 'canPublish' => true];
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
- $this->assertSame(Room::ROLE_PUBLISHER & $json['role'], Room::ROLE_PUBLISHER);
-
- $post = ['init' => 'init', 'canPublish' => true];
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
- $this->assertSame(Room::ROLE_PUBLISHER & $json['role'], 0);
- }
-
- /**
- * Test locked room and join requests
- *
- * @group openvidu
- */
- public function testJoinRequests(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
- $room->session_id = null;
- $room->save();
- $room->setSettings(['password' => null, 'locked' => 'true']);
-
- $this->assignMeetEntitlement($john);
-
- // Create the session (also makes sure the owner can access a locked room)
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- // Non-owner, locked room, invalid/missing input
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertCount(4, $json);
- $this->assertSame(326, $json['code']);
- $this->assertSame('error', $json['status']);
- $this->assertSame('Failed to join the session. Room locked.', $json['message']);
- $this->assertTrue($json['config']['locked']);
-
- // Non-owner, locked room, invalid requestId
- $post = ['nickname' => 'name', 'requestId' => '-----', 'init' => 1];
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(326, $json['code']);
-
- // Non-owner, locked room, invalid requestId
- $post = ['nickname' => 'name', 'init' => 1];
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(326, $json['code']);
-
- // Non-owner, locked room, valid input
- $reqId = '12345678';
- $post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234'];
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertCount(4, $json);
- $this->assertSame(327, $json['code']);
- $this->assertSame('error', $json['status']);
- $this->assertSame('Failed to join the session. Room locked.', $json['message']);
- $this->assertTrue($json['config']['locked']);
-
- // TODO: How do we assert that a signal has been sent to the owner?
-
- // Test denying a request
-
- // Unknown room
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/deny");
- $response->assertStatus(404);
-
- // Unknown request Id
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/deny");
- $response->assertStatus(500);
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame('Failed to deny the join request.', $json['message']);
-
- // Non-owner access forbidden
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny");
- $response->assertStatus(403);
-
- // Valid request
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny");
- $response->assertStatus(200);
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
-
- // Non-owner, locked room, join request denied
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
- $this->assertSame(327, $json['code']);
-
- // Test accepting a request
-
- // Unknown room
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/accept");
- $response->assertStatus(404);
-
- // Unknown request Id
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/accept");
- $response->assertStatus(500);
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame('Failed to accept the join request.', $json['message']);
-
- // Non-owner access forbidden
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept");
- $response->assertStatus(403);
-
- // Valid request
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept");
- $response->assertStatus(200);
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
-
- // Non-owner, locked room, join request accepted
- $post['init'] = 1;
- $post['canPublish'] = true;
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
- $json = $response->json();
-
- $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
- $this->assertTrue(strpos($json['token'], 'wss://') === 0);
-
- // TODO: Test a scenario where both password and lock are enabled
- // TODO: Test accepting/denying as a non-owner moderator
- }
-
- /**
- * Test joining the room
- *
- * @group openvidu
- * @depends testJoinRoom
- */
- public function testJoinRoomGuest(): void
- {
- $this->assignMeetEntitlement('john@kolab.org');
-
- // There's no asy way to logout the user in the same test after
- // using actingAs(). That's why this is moved to a separate test
- $room = Room::where('name', 'john')->first();
-
- // Guest, request with screenShare token
- $post = ['canPublish' => true, 'screenShare' => 1, 'init' => 1];
- $response = $this->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
- $this->assertSame($room->session_id, $json['session']);
- $this->assertTrue(strpos($json['token'], 'wss://') === 0);
- }
-
- /**
- * Test closing the room (session)
- *
- * @group openvidu
- * @depends testJoinRoom
- */
- public function testCloseRoom(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
-
- // Unauth access not allowed
- $response = $this->post("api/v4/openvidu/rooms/{$room->name}/close", []);
- $response->assertStatus(401);
-
- // Non-existing room name
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/close", []);
- $response->assertStatus(404);
-
- // Non-owner
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/close", []);
- $response->assertStatus(403);
-
- // Room owner
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertNull($room->fresh()->session_id);
- $this->assertSame('success', $json['status']);
- $this->assertSame("The session has been closed successfully.", $json['message']);
- $this->assertCount(2, $json);
-
- // TODO: Test if the session is removed from the OpenVidu server too
-
- // Test error handling when it's not possible to delete the session on
- // the OpenVidu server (use fake session_id)
- $room->session_id = 'aaa';
- $room->save();
-
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []);
- $response->assertStatus(500);
-
- $json = $response->json();
-
- $this->assertSame('aaa', $room->fresh()->session_id);
- $this->assertSame('error', $json['status']);
- $this->assertSame("Failed to close the session.", $json['message']);
- $this->assertCount(2, $json);
- }
-
- /**
- * Test creating an extra connection for screen sharing
- *
- * @group openvidu
- */
- public function testCreateConnection(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
- $room->session_id = null;
- $room->save();
-
- $this->assignMeetEntitlement($john);
-
- // First we create the session
- $post = ['init' => 1, 'canPublish' => 1];
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
- $owner_auth_token = $json['authToken'];
-
- // And the other user connection
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $conn_id = $json['connectionId'];
- $auth_token = $json['authToken'];
-
- // Non-existing room name
- $response = $this->post("api/v4/openvidu/rooms/non-existing/connections", []);
- $response->assertStatus(404);
-
- // No connection token provided
- $response = $this->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
- $response->assertStatus(403);
-
- // Invalid token
- $response = $this->actingAs($jack)
- ->withHeaders([OpenViduController::AUTH_HEADER => '123'])
- ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
-
- $response->assertStatus(403);
-
- // Subscriber can't get the screen-sharing connection
- // Note: We're acting as Jack because there's no easy way to unset the 'actingAs' user
- // throughout the test
- $response = $this->actingAs($jack)
- ->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
- ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
-
- $response->assertStatus(403);
-
- // Publisher can get the connection
- $response = $this->actingAs($jack)
- ->withHeaders([OpenViduController::AUTH_HEADER => $owner_auth_token])
- ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
-
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertTrue(strpos($json['token'], 'wss://') === 0);
- // OpenVidu 2.18 does not send 'role' param in the token uri
- // $this->assertTrue(strpos($json['token'], 'role=PUBLISHER') !== false);
- }
-
- /**
- * Test dismissing a participant (closing a connection)
- *
- * @group openvidu
- */
- public function testDismissConnection(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
- $room->session_id = null;
- $room->save();
-
- $this->assignMeetEntitlement($john);
-
- // First we create the session
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- // And the other user connection
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $conn_id = $json['connectionId'];
- $room->refresh();
- $conn_data = $room->getOVConnection($conn_id);
-
- $this->assertSame($conn_id, $conn_data['connectionId']);
-
- // Non-existing room name
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}/dismiss");
- $response->assertStatus(404);
-
- // TODO: Test accessing an existing room of deleted owner
-
- // Non-existing connection
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/123/dismiss");
- $response->assertStatus(404);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame('The connection does not exist.', $json['message']);
-
- // Non-owner access
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
- $response->assertStatus(403);
-
- // Expected success
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertNull($room->getOVConnection($conn_id));
-
- // Test acting as a moderator
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
- $json = $response->json();
- $conn_id = $json['connectionId'];
-
- // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
- // throughout the test
- $response = $this->actingAs($jack)
- ->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)])
- ->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
-
- $response->assertStatus(200);
- }
-
- /**
- * Test configuring the room (session)
- *
- * @group openvidu
- */
- public function testSetRoomConfig(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
-
- // Unauth access not allowed
- $response = $this->post("api/v4/openvidu/rooms/{$room->name}/config", []);
- $response->assertStatus(401);
-
- // Non-existing room name
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/config", []);
- $response->assertStatus(404);
-
- // TODO: Test a room with a deleted owner
-
- // Non-owner
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/config", []);
- $response->assertStatus(403);
-
- // Room owner
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", []);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('success', $json['status']);
- $this->assertSame("Room configuration updated successfully.", $json['message']);
-
- // Set password and room lock
- $post = ['password' => 'aaa', 'locked' => 1];
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('success', $json['status']);
- $this->assertSame("Room configuration updated successfully.", $json['message']);
- $room->refresh();
- $this->assertSame('aaa', $room->getSetting('password'));
- $this->assertSame('true', $room->getSetting('locked'));
-
- // Unset password and room lock
- $post = ['password' => '', 'locked' => 0];
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('success', $json['status']);
- $this->assertSame("Room configuration updated successfully.", $json['message']);
- $room->refresh();
- $this->assertSame(null, $room->getSetting('password'));
- $this->assertSame(null, $room->getSetting('locked'));
-
- // Test invalid option error
- $post = ['password' => 'eee', 'unknown' => 0];
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame("Invalid room configuration option.", $json['errors']['unknown']);
-
- $room->refresh();
- $this->assertSame(null, $room->getSetting('password'));
- }
-
- /**
- * Test updating a participant (connection)
- *
- * @group openvidu
- */
- public function testUpdateConnection(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
- $room->session_id = null;
- $room->save();
-
- $this->assignMeetEntitlement($john);
-
- // First we create the session
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
- $owner_conn_id = $json['connectionId'];
-
- // And the other user connection
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $conn_id = $json['connectionId'];
- $auth_token = $json['authToken'];
- $room->refresh();
- $conn_data = $room->getOVConnection($conn_id);
-
- $this->assertSame($conn_id, $conn_data['connectionId']);
-
- // Non-existing room name
- $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}", []);
- $response->assertStatus(404);
-
- // Non-existing connection
- $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/123", []);
- $response->assertStatus(404);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame('The connection does not exist.', $json['message']);
-
- // Non-owner access (empty post)
- $response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []);
- $response->assertStatus(200);
-
- // Non-owner access (role update)
- $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
- $response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
- $response->assertStatus(403);
-
- // Expected success
- $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
- $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertSame($post['role'], Connection::find($conn_id)->role);
-
- // Access as moderator
- // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
- // throughout the test
- $token = $this->getModeratorToken($room);
- $post = ['role' => Room::ROLE_PUBLISHER];
- $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
- ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
- $response->assertStatus(200);
-
- $this->assertSame('success', $json['status']);
- $this->assertSame($post['role'], Connection::find($conn_id)->role);
-
- // Assert that it's not possible to add/remove the 'owner' role
- $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
- $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
- ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
-
- $response->assertStatus(403);
-
- $post = ['role' => Room::ROLE_PUBLISHER];
- $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
- ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
-
- $response->assertStatus(403);
-
- // Assert that removing a 'moderator' role from the owner is not possible
- $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
- $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
- ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
-
- $response->assertStatus(200);
-
- $this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role);
-
- // Assert that non-moderator token does not allow access
- $post = ['role' => Room::ROLE_SUBSCRIBER];
- $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
- ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
-
- $response->assertStatus(403);
-
- // TODO: Test updating 'language' and 'hand' properties
- }
-
- /**
- * Create a moderator connection to the room session.
- *
- * @param \App\OpenVidu\Room $room The room
- *
- * @return string The connection authentication token
- */
- private function getModeratorToken(Room $room): string
- {
- $result = $room->getSessionToken(Room::ROLE_MODERATOR);
-
- return $result['authToken'];
- }
-}
diff --git a/src/tests/TestCaseMeetTrait.php b/src/tests/TestCaseMeetTrait.php
--- a/src/tests/TestCaseMeetTrait.php
+++ b/src/tests/TestCaseMeetTrait.php
@@ -2,7 +2,7 @@
namespace Tests;
-use App\OpenVidu\Room;
+use App\Meet\Room;
trait TestCaseMeetTrait
{

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 4:32 AM (10 h, 28 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822582
Default Alt Text
D2978.1775190742.diff (585 KB)

Event Timeline