mirror of
https://github.com/edumeet/edumeet.git
synced 2026-08-03 16:22:31 +00:00
Merge branch 'develop' into feat-advanced-audio-options
This commit is contained in:
commit
29a2edc13c
41 changed files with 3486 additions and 2888 deletions
70
README.md
70
README.md
|
|
@ -60,31 +60,42 @@ $ sudo systemctl start edumeet
|
|||
|
||||
## Manual installation
|
||||
|
||||
* Prerequisites:
|
||||
Currently edumeet will run on nodejs v14.x
|
||||
To install see here [here](https://github.com/nodesource/distributions/blob/master/README.md#debinstall).
|
||||
* Install all the required dependencies and NodeJS v14 (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
$ sudo apt install git npm yarnpkg build-essential redis libssl-dev openssl pkg-config
|
||||
sudo apt install -y curl git python build-essential redis openssl libssl-dev pkg-config
|
||||
curl -fsSL https://deb.nodesource.com/setup_14.x | sudo bash -
|
||||
sudo apt update && sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
* Install the Yarn package manager (recommended):
|
||||
|
||||
```bash
|
||||
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/yarnkey.gpg >/dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||
sudo apt update && sudo apt install -y yarn
|
||||
```
|
||||
|
||||
* Clone the project:
|
||||
|
||||
```bash
|
||||
$ git clone https://github.com/edumeet/edumeet.git
|
||||
$ cd edumeet
|
||||
git clone https://github.com/edumeet/edumeet.git
|
||||
cd edumeet
|
||||
|
||||
# switch to the "develop" branch to get the latest version for developing
|
||||
git checkout develop
|
||||
```
|
||||
|
||||
* Copy `server/config/config.example.js` to `server/config/config.js` :
|
||||
|
||||
```bash
|
||||
$ cp server/config/config.example.js server/config/config.js
|
||||
cp server/config/config.example.js server/config/config.js
|
||||
```
|
||||
|
||||
* Copy `app/public/config/config.example.js` to `app/public/config/config.js` :
|
||||
|
||||
```bash
|
||||
$ cp app/public/config/config.example.js app/public/config/config.js
|
||||
cp app/public/config/config.example.js app/public/config/config.js
|
||||
```
|
||||
|
||||
* Edit your two `config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, don't forget ip setting in last section in server config: (webRtcTransport), etc).
|
||||
|
|
@ -92,19 +103,27 @@ $ cp app/public/config/config.example.js app/public/config/config.js
|
|||
* Set up the browser app:
|
||||
|
||||
```bash
|
||||
$ cd app
|
||||
$ yarn
|
||||
$ yarn build
|
||||
cd app
|
||||
|
||||
# using Yarn (recommended)
|
||||
yarn && yarn build
|
||||
|
||||
# using NPM
|
||||
npm i && npm run build
|
||||
```
|
||||
|
||||
This will build the client application and copy everythink to `server/public` from where the server can host client code to browser requests.
|
||||
This will build the client application and copy everything to `server/public` from where the server can host client code to browser requests.
|
||||
|
||||
* Set up the server:
|
||||
|
||||
```bash
|
||||
$ cd ..
|
||||
$ cd server
|
||||
$ yarn
|
||||
cd ../server
|
||||
|
||||
# using Yarn (recommended)
|
||||
yarn && yarn build
|
||||
|
||||
# using NPM
|
||||
npm i && npm run build
|
||||
```
|
||||
|
||||
## Run it locally
|
||||
|
|
@ -112,8 +131,13 @@ $ yarn
|
|||
* Run the Node.js server application in a terminal:
|
||||
|
||||
```bash
|
||||
$ cd server
|
||||
$ yarn start
|
||||
cd server
|
||||
|
||||
# using Yarn (recommended)
|
||||
yarn start
|
||||
|
||||
# using NPM
|
||||
npm run start
|
||||
```
|
||||
|
||||
* Note: Do not run the server as root. If you need to use port 80/443 make a iptables-mapping for that or use systemd configuration for that (see further down this doc).
|
||||
|
|
@ -124,21 +148,23 @@ $ yarn start
|
|||
* Stop your locally running server. Copy systemd-service file `edumeet.service` to `/etc/systemd/system/` and check location path settings:
|
||||
|
||||
```bash
|
||||
$ cp edumeet.service /etc/systemd/system/
|
||||
$ edit /etc/systemd/system/edumeet.service
|
||||
cp edumeet.service /etc/systemd/system/
|
||||
|
||||
# modify the install paths, if required
|
||||
sudo edit /etc/systemd/system/edumeet.service
|
||||
```
|
||||
|
||||
* Reload systemd configuration and start service:
|
||||
|
||||
```bash
|
||||
$ systemctl daemon-reload
|
||||
$ systemctl start edumeet
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start edumeet
|
||||
```
|
||||
|
||||
* If you want to start edumeet at boot time:
|
||||
|
||||
```bash
|
||||
$ systemctl enable edumeet
|
||||
sudo systemctl enable edumeet
|
||||
```
|
||||
|
||||
## Ports and firewall
|
||||
|
|
|
|||
|
|
@ -6,91 +6,48 @@
|
|||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files":["**/*.{ts,tsx}"],
|
||||
"plugins": ["prettier", "@typescript-eslint"],
|
||||
"extends": ["airbnb-typescript", "react-app", "prettier"],
|
||||
"files": ["**/*.{ts,tsx}"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends":[
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": {
|
||||
"alwaysTryTypes": true
|
||||
}
|
||||
}
|
||||
"import/resolver": {
|
||||
"typescript": {
|
||||
"alwaysTryTypes": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"object-curly-spacing": ["warn", "always"],
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "none"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/semi": [
|
||||
"off"
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "none"
|
||||
}
|
||||
],
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 100,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true,
|
||||
"ignoreComments": true
|
||||
}
|
||||
],
|
||||
"prefer-destructuring": [
|
||||
"error",
|
||||
{
|
||||
"VariableDeclarator": {
|
||||
"array": false,
|
||||
"object": true
|
||||
},
|
||||
"AssignmentExpression": {
|
||||
"array": false,
|
||||
"object": false
|
||||
}
|
||||
}, {
|
||||
"enforceForRenamedProperties": false
|
||||
}
|
||||
],
|
||||
"no-unused-vars" : 0,
|
||||
"@typescript-eslint/ban-types" : 0,
|
||||
"@typescript-eslint/ban-ts-comment" : 0,
|
||||
"@typescript-eslint/ban-ts-ignore" : 0,
|
||||
"@typescript-eslint/explicit-module-boundary-types" : 0,
|
||||
"@typescript-eslint/member-delimiter-style" : [ 2,
|
||||
|
||||
"no-plusplus": [
|
||||
"error",
|
||||
{
|
||||
"allowForLoopAfterthoughts": true
|
||||
}
|
||||
],
|
||||
"react/jsx-key": "error",
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": [
|
||||
"**/*.test.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"src/tests/**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"react/jsx-boolean-value": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/jsx-one-expression-per-line": "off",
|
||||
"react/jsx-wrap-multilines": "off",
|
||||
"react/destructuring-assignment": "off"
|
||||
{
|
||||
"multiline" : { "delimiter": "semi", "requireLast": true },
|
||||
"singleline" : { "delimiter": "semi", "requireLast": false }
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any" : 0,
|
||||
"@typescript-eslint/no-unused-vars" : [ 2,
|
||||
{
|
||||
"vars" : "all",
|
||||
"args" : "after-used",
|
||||
"ignoreRestSiblings" : false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-use-before-define" : [ 2, { "functions": false } ],
|
||||
"@typescript-eslint/no-empty-function" : 0,
|
||||
"@typescript-eslint/no-non-null-assertion" : 0
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2
|
||||
"tabWidth": 4
|
||||
}
|
||||
|
|
@ -20,12 +20,15 @@
|
|||
"@material-ui/lab": "^4.0.0-alpha.57",
|
||||
"@react-hook/window-size": "^3.0.7",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/react": "^16.10.2",
|
||||
"@types/react-dom": "^16.9.12",
|
||||
"bowser": "^2.11.0",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"bowser": "^2.7.0",
|
||||
"classnames": "^2.2.6",
|
||||
"chroma-js": "^2.1.1",
|
||||
"classnames": "^2.3.1",
|
||||
"convict": "^6.0.1",
|
||||
"convict-format-with-validator": "^6.0.1",
|
||||
"create-torrent": "^4.4.1",
|
||||
"deep-object-diff": "^1.1.0",
|
||||
"dompurify": "^2.0.7",
|
||||
"domready": "^1.0.8",
|
||||
"end-of-stream": "1.4.1",
|
||||
|
|
@ -71,6 +74,7 @@
|
|||
"electron": "electron --no-sandbox .",
|
||||
"dev": "nf start -p 3000",
|
||||
"lint": "eslint -c .eslintrc.json --ext .js,.ts,.tsx src",
|
||||
"lint:fix": "eslint -c .eslintrc.json --ext .js,.ts,.tsx src --fix",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"browserslist": [
|
||||
|
|
@ -81,6 +85,8 @@
|
|||
],
|
||||
"devDependencies": {
|
||||
"@types/chroma-js": "^2.1.3",
|
||||
"@types/convict": "^6.0.1",
|
||||
"@types/convict-format-with-validator": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.20.0",
|
||||
"@typescript-eslint/parser": "^4.20.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
|
|
@ -101,6 +107,7 @@
|
|||
"prettier": "^2.2.1",
|
||||
"prettier-eslint": "^12.0.0",
|
||||
"prettier-eslint-cli": "^5.0.1",
|
||||
"redux-mock-store": "^1.5.3"
|
||||
"redux-mock-store": "^1.5.3",
|
||||
"ts-node": "^9.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,17 +78,17 @@ var config =
|
|||
} ],
|
||||
// The aspect ratio of the video from the camera
|
||||
// this is not changeable in settings, only config
|
||||
videoAspectRatio : 1.777,
|
||||
defaultResolution : 'medium',
|
||||
defaultFrameRate : 15,
|
||||
defaultScreenResolution : 'veryhigh',
|
||||
defaultScreenSharingFrameRate : 5,
|
||||
videoAspectRatio : 1.777,
|
||||
resolution : 'medium',
|
||||
frameRate : 15,
|
||||
screenResolution : 'veryhigh',
|
||||
screenSharingFrameRate : 5,
|
||||
// Enable or disable simulcast for webcam video
|
||||
simulcast : true,
|
||||
simulcast : true,
|
||||
// Enable or disable simulcast for screen sharing video
|
||||
simulcastSharing : false,
|
||||
simulcastSharing : false,
|
||||
// Define different encodings for various resolutions of the video
|
||||
simulcastProfiles :
|
||||
simulcastProfiles :
|
||||
{
|
||||
3840 :
|
||||
[
|
||||
|
|
|
|||
99
app/src/components/ConfigDocumentation.tsx
Normal file
99
app/src/components/ConfigDocumentation.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react'; // eslint-disable-line no-use-before-define
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableContainer from '@material-ui/core/TableContainer';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Button from '@material-ui/core/Button';
|
||||
|
||||
import { configDocs } from '../config';
|
||||
|
||||
const styles = () =>
|
||||
({
|
||||
table : {
|
||||
minWidth : 700
|
||||
},
|
||||
pre : {
|
||||
fontSize : '0.8rem'
|
||||
},
|
||||
cell : {
|
||||
maxWidth : '25vw',
|
||||
overflow : 'auto'
|
||||
}
|
||||
});
|
||||
|
||||
const ConfigDocumentation = ({
|
||||
classes
|
||||
}: {
|
||||
classes : any;
|
||||
}) =>
|
||||
{
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
<CardContent>
|
||||
<Typography className={classes.title} variant='h5' component='h2'>
|
||||
<FormattedMessage
|
||||
id='configDocumentation.title'
|
||||
defaultMessage='Edumeet configuration'
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant='body2' component='div'>
|
||||
<TableContainer component={Paper}>
|
||||
<Table className={classes.table} size='small' aria-label='Configuration'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Property</TableCell>
|
||||
<TableCell align='left'>Description</TableCell>
|
||||
<TableCell align='left'>Format</TableCell>
|
||||
<TableCell align='left'>Default value</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(configDocs).map(([ name, value ] : [ string, any ]) =>
|
||||
{
|
||||
return (
|
||||
<TableRow key={name}>
|
||||
<TableCell component='th' scope='row' className={classes.cell}>{name}</TableCell>
|
||||
<TableCell className={classes.cell}>{value.doc}</TableCell>
|
||||
<TableCell className={classes.cell}>
|
||||
<pre>{value.format}</pre>
|
||||
</TableCell>
|
||||
<TableCell className={classes.cell}>
|
||||
<pre className={classes.pre}>{value.default}</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size='small' onClick={(e) =>
|
||||
{
|
||||
e.preventDefault();
|
||||
window.location.href = '/';
|
||||
}}
|
||||
>Home</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ConfigDocumentation.propTypes =
|
||||
{
|
||||
classes : PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default withStyles(styles)(ConfigDocumentation);
|
||||
75
app/src/components/ConfigError.tsx
Normal file
75
app/src/components/ConfigError.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react'; // eslint-disable-line no-use-before-define
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import ErrorIcon from '@material-ui/icons/Error';
|
||||
import Button from '@material-ui/core/Button';
|
||||
|
||||
const styles = () =>
|
||||
({
|
||||
error : {
|
||||
color : 'red'
|
||||
}
|
||||
});
|
||||
|
||||
const ConfigError = ({
|
||||
classes,
|
||||
configError
|
||||
}: {
|
||||
classes : any;
|
||||
configError : string;
|
||||
}) =>
|
||||
{
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
scroll={'body'}
|
||||
classes={{
|
||||
paper : classes.dialogPaper
|
||||
}}
|
||||
>
|
||||
<DialogTitle id='form-dialog-title'>
|
||||
<ErrorIcon className={classes.errorAvatar} color='error'/>
|
||||
<FormattedMessage
|
||||
id='configError.title'
|
||||
defaultMessage='Configuration error'
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<FormattedMessage
|
||||
id='configError.bodyText'
|
||||
defaultMessage='The Edumeet configuration contains errors:'
|
||||
/>
|
||||
<Grid container spacing={2} alignItems='center'>
|
||||
<Grid item>
|
||||
<p className={classes.error}>{configError}</p>
|
||||
</Grid>
|
||||
<Button size='small' onClick={(e) =>
|
||||
{
|
||||
e.preventDefault();
|
||||
window.location.href = '/config';
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='configError.link'
|
||||
defaultMessage='See the configuration documentation'
|
||||
/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
ConfigError.propTypes =
|
||||
{
|
||||
classes : PropTypes.object.isRequired,
|
||||
configError : PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default withStyles(styles)(ConfigError);
|
||||
|
|
@ -50,6 +50,8 @@ const styles = (theme) =>
|
|||
{
|
||||
borderRadius : '50%',
|
||||
height : '2rem',
|
||||
width : '2rem',
|
||||
objectFit : 'cover',
|
||||
alignSelf : 'center'
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ const styles = (theme) =>
|
|||
avatar :
|
||||
{
|
||||
borderRadius : '50%',
|
||||
height : '2rem'
|
||||
height : '2rem',
|
||||
width : '2rem',
|
||||
objectFit : 'cover'
|
||||
},
|
||||
text :
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ const styles = (theme) =>
|
|||
{
|
||||
borderRadius : '50%',
|
||||
height : '2rem',
|
||||
width : '2rem',
|
||||
objectFit : 'cover',
|
||||
marginTop : theme.spacing(0.5)
|
||||
},
|
||||
peerInfo :
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ const styles = (theme) =>
|
|||
{
|
||||
borderRadius : '50%',
|
||||
height : '2rem',
|
||||
width : '2rem',
|
||||
objectFit : 'cover',
|
||||
marginTop : theme.spacing(0.5)
|
||||
},
|
||||
peerInfo :
|
||||
|
|
|
|||
580
app/src/config.ts
Normal file
580
app/src/config.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
import convict from 'convict';
|
||||
import * as convictFormatWithValidator from 'convict-format-with-validator';
|
||||
|
||||
convict.addFormats(convictFormatWithValidator);
|
||||
|
||||
function assert(assertion: Boolean, msg: string)
|
||||
{
|
||||
if (!assertion)
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
convict.addFormat({
|
||||
name : 'float',
|
||||
coerce : (v: string) => parseFloat(v),
|
||||
validate : (v: number) => assert(Number.isFinite(v), 'must be a number')
|
||||
});
|
||||
|
||||
const configSchema = convict({
|
||||
loginEnabled :
|
||||
{
|
||||
doc : 'If the login is enabled.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
developmentPort :
|
||||
{
|
||||
doc : 'The development server listening port.',
|
||||
format : 'port',
|
||||
default : 3443
|
||||
},
|
||||
productionPort :
|
||||
{
|
||||
doc : 'The production server listening port.',
|
||||
format : 'port',
|
||||
default : 443
|
||||
},
|
||||
serverHostname :
|
||||
{
|
||||
doc : 'If the server component runs on a different host than the app you can specify the host name.',
|
||||
format : '*',
|
||||
default : null
|
||||
},
|
||||
|
||||
/**
|
||||
* Supported browsers version in bowser satisfy format.
|
||||
* See more:
|
||||
* https://www.npmjs.com/package/bowser#filtering-browsers
|
||||
* Otherwise you got a unsupported browser page
|
||||
*/
|
||||
supportedBrowsers :
|
||||
{
|
||||
doc : 'Supported browsers version in bowser satisfy format.',
|
||||
format : Object,
|
||||
default :
|
||||
{
|
||||
'windows' : {
|
||||
'internet explorer' : '>12',
|
||||
'microsoft edge' : '>18'
|
||||
},
|
||||
'microsoft edge' : '>18',
|
||||
'safari' : '>12',
|
||||
'firefox' : '>=60',
|
||||
'chrome' : '>=74',
|
||||
'chromium' : '>=74',
|
||||
'opera' : '>=62',
|
||||
'samsung internet for android' : '>=11.1.1.52'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Network priorities
|
||||
* DSCP bits set by browser according this priority values.
|
||||
* ("high" means actually: EF for audio, and AF41 for Video in chrome)
|
||||
* https://en.wikipedia.org/wiki/Differentiated_services
|
||||
*/
|
||||
networkPriorities :
|
||||
{
|
||||
doc : 'Network priorities.',
|
||||
format : Object,
|
||||
default :
|
||||
{
|
||||
audio : 'high',
|
||||
mainVideo : 'high',
|
||||
additionalVideos : 'medium',
|
||||
screenShare : 'medium'
|
||||
}
|
||||
},
|
||||
// The aspect ratio of the videos as shown on the screen.
|
||||
// This is changeable in client settings.
|
||||
// This value must match one of the defined values in
|
||||
// viewAspectRatios EXACTLY (e.g. 1.333)
|
||||
viewAspectRatio :
|
||||
{
|
||||
doc : 'The aspect ratio of the videos as shown on the screen.',
|
||||
format : 'float',
|
||||
default : 1.777
|
||||
},
|
||||
viewAspectRatios :
|
||||
{
|
||||
doc : 'The selectable aspect ratios in the settings.',
|
||||
format : Array,
|
||||
default :
|
||||
[
|
||||
{
|
||||
value : 1.333, // 4 / 3
|
||||
label : '4 : 3'
|
||||
},
|
||||
{
|
||||
value : 1.777, // 16 / 9
|
||||
label : '16 : 9'
|
||||
}
|
||||
]
|
||||
},
|
||||
// The aspect ratio of the video from the camera
|
||||
// this is not changeable in settings, only config
|
||||
videoAspectRatio :
|
||||
{
|
||||
doc : 'The aspect ratio of the video from the camera.',
|
||||
format : 'float',
|
||||
default : 1.777
|
||||
},
|
||||
resolution :
|
||||
{
|
||||
doc : 'The default video camera capture resolution.',
|
||||
format : [ 'low', 'medium', 'high', 'veryhigh', 'ultra' ],
|
||||
default : 'medium'
|
||||
},
|
||||
frameRate :
|
||||
{
|
||||
doc : 'The default video camera capture framerate.',
|
||||
format : 'nat',
|
||||
default : 15
|
||||
},
|
||||
screenResolution :
|
||||
{
|
||||
doc : 'The default screen sharing resolution.',
|
||||
format : [ 'low', 'medium', 'high', 'veryhigh', 'ultra' ],
|
||||
default : 'veryhigh'
|
||||
},
|
||||
screenSharingFrameRate :
|
||||
{
|
||||
doc : 'The default screen sharing framerate.',
|
||||
format : 'nat',
|
||||
default : 5
|
||||
},
|
||||
simulcast :
|
||||
{
|
||||
doc : 'Enable or disable simulcast for webcam video.',
|
||||
format : 'Boolean',
|
||||
default : true
|
||||
},
|
||||
simulcastSharing :
|
||||
{
|
||||
doc : 'Enable or disable simulcast for screen sharing video.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
simulcastProfiles :
|
||||
{
|
||||
doc : 'Define different encodings for various resolutions of the video.',
|
||||
format : Object,
|
||||
default :
|
||||
{
|
||||
3840 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 4, maxBitRate: 1500000 },
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 4000000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 10000000 }
|
||||
],
|
||||
1920 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 4, maxBitRate: 750000 },
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 1500000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 4000000 }
|
||||
],
|
||||
1280 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 4, maxBitRate: 250000 },
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 900000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 3000000 }
|
||||
],
|
||||
640 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 250000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 900000 }
|
||||
],
|
||||
320 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 250000 }
|
||||
]
|
||||
}
|
||||
},
|
||||
// The adaptive spatial layer selection scaling factor (in the range [0.5, 1.0])
|
||||
// example:
|
||||
// with level width=640px, the minimum width required to trigger the
|
||||
// level change will be: 640 * 0.75 = 480px
|
||||
adaptiveScalingFactor :
|
||||
{
|
||||
doc : 'The adaptive spatial layer selection scaling factor.',
|
||||
format : (value: number) => value >= 0.5 && value <= 1.0,
|
||||
default : 0.75
|
||||
},
|
||||
|
||||
/**
|
||||
* White listing browsers that support audio output device selection.
|
||||
* It is not yet fully implemented in Firefox.
|
||||
* See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512
|
||||
*/
|
||||
audioOutputSupportedBrowsers :
|
||||
{
|
||||
doc : 'White listing browsers that support audio output device selection.',
|
||||
format : Array,
|
||||
default : [
|
||||
'chrome',
|
||||
'opera'
|
||||
]
|
||||
},
|
||||
requestTimeout :
|
||||
{
|
||||
doc : 'The Socket.io request timeout.',
|
||||
format : 'nat',
|
||||
default : 20000
|
||||
},
|
||||
requestRetries :
|
||||
{
|
||||
doc : 'The Socket.io request maximum retries.',
|
||||
format : 'nat',
|
||||
default : 3
|
||||
},
|
||||
transportOptions :
|
||||
{
|
||||
doc : '',
|
||||
format : Object,
|
||||
default : {
|
||||
tcp : true
|
||||
}
|
||||
},
|
||||
autoGainControl :
|
||||
{
|
||||
doc : 'Auto gain control enabled.',
|
||||
format : 'Boolean',
|
||||
default : true
|
||||
},
|
||||
echoCancellation :
|
||||
{
|
||||
doc : 'Echo cancellation enabled.',
|
||||
format : 'Boolean',
|
||||
default : true
|
||||
},
|
||||
noiseSuppression :
|
||||
{
|
||||
doc : 'Noise suppression enabled.',
|
||||
format : 'Boolean',
|
||||
default : true
|
||||
},
|
||||
voiceActivatedUnmute :
|
||||
{
|
||||
doc : 'Automatically unmute speaking above noiseThreshold.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
noiseThreshold :
|
||||
{
|
||||
doc : 'This is only for voiceActivatedUnmute and audio-indicator.',
|
||||
format : 'int',
|
||||
default : -60
|
||||
},
|
||||
// Audio options for now only centrally from config file:
|
||||
centralAudioOptions :
|
||||
{
|
||||
doc : 'Defaults audio settings.',
|
||||
format : Object,
|
||||
default :
|
||||
{
|
||||
// will not eat that much bandwith thanks to opus
|
||||
sampleRate : 48000, // default : 48000 and don't go higher
|
||||
// usually mics are mono so this saves bandwidth
|
||||
channelCount : 1, // default : 1
|
||||
volume : 1.0, // default : 1.0
|
||||
sampleSize : 16, // default : 16
|
||||
// usually mics are mono so this saves bandwidth
|
||||
opusStereo : false, // default : false
|
||||
opusDtx : true, // default : true / will save bandwidth
|
||||
opusFec : true, // default : true / forward error correction
|
||||
opusPtime : '20', // default : 20 / minimum packet time (3, 5, 10, 20, 40, 60, 120)
|
||||
opusMaxPlaybackRate : 48000 // default : 48000 and don't go higher
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set max 'int' participants in one room that join
|
||||
* unmuted. Next participant will join automatically muted
|
||||
* Default value is 4
|
||||
*
|
||||
* Set it to 0 to auto mute all,
|
||||
* Set it to negative (-1) to never automatically auto mute
|
||||
* but use it with caution
|
||||
* full mesh audio strongly decrease room capacity!
|
||||
*/
|
||||
autoMuteThreshold :
|
||||
{
|
||||
doc : 'Set the max number of participants in one room that join unmuted.',
|
||||
format : 'nat',
|
||||
default : 4
|
||||
},
|
||||
background :
|
||||
{
|
||||
doc : 'The page background image URL',
|
||||
format : String,
|
||||
default : 'images/background.jpg'
|
||||
},
|
||||
defaultLayout :
|
||||
{
|
||||
doc : 'The default layout',
|
||||
format : [ 'democratic', 'filmstrip' ],
|
||||
default : 'democratic'
|
||||
},
|
||||
buttonControlBar :
|
||||
{
|
||||
doc : 'If true, will show media control buttons in separate control bar, not in the ME container.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
drawerOverlayed :
|
||||
{
|
||||
doc : 'If false, will push videos away to make room for side drawer. If true, will overlay side drawer over videos.',
|
||||
format : 'Boolean',
|
||||
default : true
|
||||
},
|
||||
notificationPosition :
|
||||
{
|
||||
doc : 'The position of notifications.',
|
||||
format : [ 'left', 'right' ],
|
||||
default : 'right'
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the notificationSounds. Valid keys are:
|
||||
* 'parkedPeer', 'parkedPeers', 'raisedHand', 'chatMessage',
|
||||
* 'sendFile', 'newPeer' and 'default'.
|
||||
*
|
||||
* Not defining a key is equivalent to using the default notification sound.
|
||||
* Setting 'play' to null disables the sound notification.
|
||||
*/
|
||||
notificationSounds :
|
||||
{
|
||||
doc : 'Set the notifications sounds.',
|
||||
format : Object,
|
||||
default :
|
||||
{
|
||||
chatMessage : {
|
||||
play : '/sounds/notify-chat.mp3'
|
||||
},
|
||||
raisedHand : {
|
||||
play : '/sounds/notify-hand.mp3'
|
||||
},
|
||||
default : {
|
||||
delay : 5000, // minimum delay between alert sounds [ms]
|
||||
play : '/sounds/notify.mp3'
|
||||
}
|
||||
}
|
||||
},
|
||||
hideTimeout :
|
||||
{
|
||||
doc : 'Timeout for autohiding topbar and button control bar.',
|
||||
format : 'int',
|
||||
default : 3000
|
||||
},
|
||||
lastN :
|
||||
{
|
||||
doc : 'The max number of participant that will be visible in as speaker.',
|
||||
format : 'nat',
|
||||
default : 4
|
||||
},
|
||||
mobileLastN :
|
||||
{
|
||||
doc : 'The max number of participant that will be visible in as speaker for mobile users.',
|
||||
format : 'nat',
|
||||
default : 1
|
||||
},
|
||||
maxLastN :
|
||||
{
|
||||
doc : 'The highest number of lastN the user can select manually in the user interface.',
|
||||
format : 'nat',
|
||||
default : 5
|
||||
},
|
||||
lockLastN :
|
||||
{
|
||||
doc : 'If truthy, users can NOT change number of speakers visible.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
// Show logo if "logo" is not null, else show title
|
||||
// Set logo file name using logo.* pattern like "logo.png" to not track it by git
|
||||
logo :
|
||||
{
|
||||
doc : 'If not null, it shows the logo loaded from URL, else it shows the title.',
|
||||
format : 'url',
|
||||
default : 'images/logo.edumeet.svg'
|
||||
},
|
||||
title :
|
||||
{
|
||||
doc : 'The title to show if the logo is not specified.',
|
||||
format : String,
|
||||
default : 'edumeet'
|
||||
},
|
||||
supportUrl :
|
||||
{
|
||||
doc : 'Service & Support URL if not set then not displayed on the about modals.',
|
||||
format : 'url',
|
||||
default : 'https://support.example.com'
|
||||
},
|
||||
// Privacy and data protection URL or path by default privacy/privacy.html
|
||||
// that is a placeholder for your policies
|
||||
//
|
||||
// but an external url could be also used here
|
||||
privacyUrl :
|
||||
{
|
||||
doc : 'Privacy and data protection URL or path by default privacy/privacy.html.',
|
||||
format : String,
|
||||
default : 'privacy/privacy.html'
|
||||
},
|
||||
// UI theme elements
|
||||
theme :
|
||||
{
|
||||
palette :
|
||||
{
|
||||
primary :
|
||||
{
|
||||
main : { format: String, default: '#313131' }
|
||||
}
|
||||
},
|
||||
overrides :
|
||||
{
|
||||
MuiAppBar :
|
||||
{
|
||||
colorPrimary :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#313131' }
|
||||
}
|
||||
},
|
||||
MuiButton :
|
||||
{
|
||||
containedPrimary :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#5F9B2D' },
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#5F9B2D' }
|
||||
}
|
||||
},
|
||||
containedSecondary :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#f50057' },
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#f50057' }
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/*
|
||||
MuiIconButton :
|
||||
{
|
||||
colorPrimary :
|
||||
{
|
||||
backgroundColor : '#5F9B2D',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#5F9B2D'
|
||||
}
|
||||
},
|
||||
colorSecondary :
|
||||
{
|
||||
backgroundColor : '#f50057',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#f50057'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
*/
|
||||
|
||||
MuiFab :
|
||||
{
|
||||
primary :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#5F9B2D' },
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#5F9B2D' }
|
||||
}
|
||||
},
|
||||
secondary :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#f50057' },
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#f50057' }
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
MuiBadge :
|
||||
{
|
||||
colorPrimary :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#5F9B2D' },
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : { format: String, default: '#518029' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
typography :
|
||||
{
|
||||
useNextVariants : { format: 'Boolean', default: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function formatDocs(docs: any, property: string | null, schema: any)
|
||||
{
|
||||
if (schema._cvtProperties)
|
||||
{
|
||||
Object.entries(schema._cvtProperties).forEach(([ name, value ]) =>
|
||||
{
|
||||
formatDocs(docs, `${property ? `${property}.` : ''}${name}`, value);
|
||||
});
|
||||
|
||||
return docs;
|
||||
}
|
||||
else if (property)
|
||||
{
|
||||
docs[property] =
|
||||
{
|
||||
doc : schema.doc,
|
||||
format : JSON.stringify(schema.format, null, 2),
|
||||
default : JSON.stringify(schema.default, null, 2)
|
||||
};
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
let config: any = {};
|
||||
let configError = '';
|
||||
|
||||
// Load config from window object
|
||||
configSchema.load((window as any).config);
|
||||
|
||||
// Perform validation
|
||||
try
|
||||
{
|
||||
configSchema.validate({ allowed: 'strict' });
|
||||
config = configSchema.getProperties();
|
||||
}
|
||||
catch (error: any)
|
||||
{
|
||||
configError = error.message;
|
||||
}
|
||||
|
||||
// format docs
|
||||
const configDocs = formatDocs({}, null, configSchema.getSchema());
|
||||
|
||||
// eslint-disable-next-line
|
||||
console.log('Using config:', config, configDocs);
|
||||
|
||||
// Override the window config with the validated properties.
|
||||
(window as any)['config'] = config;
|
||||
|
||||
export {
|
||||
configSchema,
|
||||
config,
|
||||
configError,
|
||||
configDocs
|
||||
};
|
||||
|
|
@ -16,6 +16,8 @@ import RoomContext from './RoomContext';
|
|||
import deviceInfo from './deviceInfo';
|
||||
import * as meActions from './actions/meActions';
|
||||
import UnsupportedBrowser from './components/UnsupportedBrowser';
|
||||
import ConfigDocumentation from './components/ConfigDocumentation';
|
||||
import ConfigError from './components/ConfigError';
|
||||
import JoinDialog from './components/JoinDialog';
|
||||
import LoginDialog from './components/AccessControl/LoginDialog';
|
||||
import LoadingView from './components/Loader/LoadingView';
|
||||
|
|
@ -29,11 +31,14 @@ import { detectDevice } from 'mediasoup-client';
|
|||
|
||||
import './index.css';
|
||||
|
||||
import { config, configError } from './config';
|
||||
|
||||
const App = LazyPreload(() => import(/* webpackChunkName: "app" */ './components/App'));
|
||||
|
||||
// const cache = createIntlCache();
|
||||
|
||||
const supportedBrowsers={
|
||||
const supportedBrowsers =
|
||||
{
|
||||
'windows' : {
|
||||
'internet explorer' : '>12',
|
||||
'microsoft edge' : '>18'
|
||||
|
|
@ -141,14 +146,48 @@ function run()
|
|||
if (unsupportedBrowser || webrtcUnavailable)
|
||||
{
|
||||
render(
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<IntlProvider value={intl}>
|
||||
<UnsupportedBrowser
|
||||
webrtcUnavailable={webrtcUnavailable}
|
||||
platform={device.platform}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</MuiThemeProvider>,
|
||||
<Provider store={store}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<IntlProvider value={intl}>
|
||||
<UnsupportedBrowser
|
||||
webrtcUnavailable={webrtcUnavailable}
|
||||
platform={device.platform}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</MuiThemeProvider>
|
||||
</Provider>,
|
||||
document.getElementById('edumeet')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (basePath === '/config')
|
||||
{
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<IntlProvider value={intl}>
|
||||
<ConfigDocumentation />
|
||||
</IntlProvider>
|
||||
</MuiThemeProvider>
|
||||
</Provider>,
|
||||
document.getElementById('edumeet')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (configError)
|
||||
{
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<IntlProvider value={intl}>
|
||||
<ConfigError configError={configError} />
|
||||
</IntlProvider>
|
||||
</MuiThemeProvider>
|
||||
</Provider>,
|
||||
document.getElementById('edumeet')
|
||||
);
|
||||
|
||||
|
|
|
|||
22
app/src/reducers/config.js
Normal file
22
app/src/reducers/config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { config as defaultConfig } from '../config';
|
||||
|
||||
const initialState =
|
||||
{
|
||||
...defaultConfig
|
||||
};
|
||||
|
||||
const config = (state = initialState, action) =>
|
||||
{
|
||||
switch (action.type)
|
||||
{
|
||||
case 'CONFIG_SET':
|
||||
{
|
||||
return { ...action.payload };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -13,6 +13,7 @@ import files from './files';
|
|||
import settings from './settings';
|
||||
import transports from './transports';
|
||||
import intl from './intl';
|
||||
import config from './config';
|
||||
// import { intlReducer } from 'react-intl-redux';
|
||||
|
||||
export default combineReducers({
|
||||
|
|
@ -30,5 +31,6 @@ export default combineReducers({
|
|||
files,
|
||||
settings,
|
||||
// intl : intlReducer
|
||||
intl
|
||||
intl,
|
||||
config
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,37 @@
|
|||
export const defaultSettings = {
|
||||
audioPreset : 'conference',
|
||||
audioPresets :
|
||||
import { config } from '../config';
|
||||
|
||||
const initialState =
|
||||
{
|
||||
displayName : '',
|
||||
selectedWebcam : null,
|
||||
selectedAudioDevice : null,
|
||||
advancedMode : false,
|
||||
autoGainControl : config.autoGainControl,
|
||||
echoCancellation : config.echoCancellation,
|
||||
noiseSuppression : config.noiseSuppression,
|
||||
voiceActivatedUnmute : config.voiceActivatedUnmute,
|
||||
noiseThreshold : config.noiseThreshold,
|
||||
audioMuted : false,
|
||||
videoMuted : false,
|
||||
// low, medium, high, veryhigh, ultra
|
||||
resolution : config.resolution,
|
||||
frameRate : config.frameRate,
|
||||
screenSharingResolution : config.screenResolution,
|
||||
screenSharingFrameRate : config.screenSharingFrameRate,
|
||||
lastN : 4,
|
||||
permanentTopBar : true,
|
||||
hiddenControls : false,
|
||||
showNotifications : true,
|
||||
notificationSounds : true,
|
||||
mirrorOwnVideo : true,
|
||||
hideNoVideoParticipants : false,
|
||||
buttonControlBar : config.buttonControlBar,
|
||||
drawerOverlayed : config.drawerOverlayed,
|
||||
aspectRatio : config.viewAspectRatio,
|
||||
mediaPerms : { audio: true, video: true },
|
||||
localPicture : null,
|
||||
audioPreset : 'conference',
|
||||
audioPresets :
|
||||
{
|
||||
conference :
|
||||
{
|
||||
|
|
@ -49,41 +80,6 @@ export const defaultSettings = {
|
|||
}
|
||||
};
|
||||
|
||||
const initialState =
|
||||
{
|
||||
displayName : '',
|
||||
selectedWebcam : null,
|
||||
selectedAudioDevice : null,
|
||||
advancedMode : false,
|
||||
autoGainControl : true,
|
||||
echoCancellation : true,
|
||||
noiseSuppression : true,
|
||||
voiceActivatedUnmute : false,
|
||||
noiseThreshold : -50,
|
||||
audioMuted : false,
|
||||
videoMuted : false,
|
||||
// low, medium, high, veryhigh, ultra
|
||||
resolution : window.config.defaultResolution || 'medium',
|
||||
frameRate : window.config.defaultFrameRate || 15,
|
||||
screenSharingResolution : window.config.defaultScreenResolution || 'veryhigh',
|
||||
screenSharingFrameRate : window.config.defaultScreenSharingFrameRate || 5,
|
||||
lastN : 4,
|
||||
permanentTopBar : true,
|
||||
hiddenControls : false,
|
||||
showNotifications : true,
|
||||
notificationSounds : true,
|
||||
mirrorOwnVideo : true,
|
||||
hideNoVideoParticipants : false,
|
||||
buttonControlBar : window.config.buttonControlBar || false,
|
||||
drawerOverlayed : (typeof window.config.drawerOverlayed === 'undefined') ? true : window.config.drawerOverlayed,
|
||||
aspectRatio : window.config.viewAspectRatio || 1.777, // 16 : 9
|
||||
mediaPerms : { audio: true, video: true },
|
||||
localPicture : null
|
||||
};
|
||||
|
||||
Object.assign(initialState, defaultSettings);
|
||||
Object.assign(initialState, defaultSettings.audioPresets[defaultSettings.audioPreset]);
|
||||
|
||||
const settings = (state = initialState, action) =>
|
||||
{
|
||||
switch (action.type)
|
||||
|
|
@ -355,6 +351,11 @@ const settings = (state = initialState, action) =>
|
|||
return { ...state, localPicture };
|
||||
}
|
||||
|
||||
case 'SETTINGS_UPDATE':
|
||||
{
|
||||
return { ...state, ...action.payload };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import { createLogger } from 'redux-logger';
|
|||
import { createMigrate, persistStore, persistReducer } from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';
|
||||
import rootReducer from './reducers/rootReducer';
|
||||
import { createFilter } from 'redux-persist-transform-filter';
|
||||
import { defaultSettings } from './reducers/settings';
|
||||
import { diff } from 'deep-object-diff';
|
||||
import rootReducer from './reducers/rootReducer';
|
||||
import Logger from './Logger';
|
||||
import { config } from './config';
|
||||
|
||||
const logger = new Logger('store');
|
||||
|
||||
const migrations =
|
||||
{
|
||||
|
|
@ -65,7 +70,7 @@ const persistConfig =
|
|||
version : 3,
|
||||
migrate : createMigrate(migrations, { debug: true }),
|
||||
stateReconciler : autoMergeLevel2,
|
||||
whitelist : [ 'settings', 'intl' ]
|
||||
whitelist : [ 'settings', 'intl', 'config' ]
|
||||
};
|
||||
|
||||
const saveSubsetFilter = createFilter(
|
||||
|
|
@ -124,7 +129,27 @@ export const store = createStore(
|
|||
enhancer
|
||||
);
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
export const persistor = persistStore(store, null, () =>
|
||||
{
|
||||
// Check if the app config differs from the stored version.
|
||||
const currentConfig = store.getState().config;
|
||||
const changed = diff(currentConfig, config);
|
||||
const changedKeys = Object.keys(changed);
|
||||
|
||||
if (changedKeys.length)
|
||||
{
|
||||
logger.debug('store config changed:', changed);
|
||||
const changedSettings = {};
|
||||
|
||||
changedKeys.forEach((key) =>
|
||||
{
|
||||
changedSettings[key] = config[key];
|
||||
});
|
||||
|
||||
store.dispatch({ type: 'SETTINGS_UPDATE', payload: changedSettings });
|
||||
store.dispatch({ type: 'CONFIG_SET', payload: config });
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
|
|
|
|||
|
|
@ -2,151 +2,158 @@
|
|||
/* eslint-disable import/no-dynamic-require */
|
||||
|
||||
const list = [
|
||||
{
|
||||
name: 'English',
|
||||
file: 'en',
|
||||
locale: ['en', 'en-en'],
|
||||
},
|
||||
{
|
||||
name: 'Czech',
|
||||
file: 'cs',
|
||||
locale: ['cs', 'cs-cs'],
|
||||
},
|
||||
{
|
||||
name: 'Chinese (Simplified)',
|
||||
file: 'cn',
|
||||
locale: ['zn', 'zn-zn', 'zn-cn'],
|
||||
}, // hans
|
||||
{
|
||||
name: 'Chinese (Traditional)',
|
||||
file: 'tw',
|
||||
locale: ['zn-tw', 'zn-hk', 'zn-sg'],
|
||||
}, // hant
|
||||
{
|
||||
name: 'Croatian',
|
||||
file: 'hr',
|
||||
locale: ['hr', 'hr-hr'],
|
||||
},
|
||||
{
|
||||
name: 'Danish',
|
||||
file: 'dk',
|
||||
locale: ['dk', 'dk-dk'],
|
||||
},
|
||||
{
|
||||
name: 'French',
|
||||
file: 'fr',
|
||||
locale: ['fr', 'fr-fr'],
|
||||
},
|
||||
{
|
||||
name: 'German',
|
||||
file: 'de',
|
||||
locale: ['de', 'de-de'],
|
||||
},
|
||||
{
|
||||
name: 'Greek',
|
||||
file: 'el',
|
||||
locale: ['el', 'el-el'],
|
||||
},
|
||||
{
|
||||
name: 'Hindi',
|
||||
file: 'hi',
|
||||
locale: ['hi', 'hi-hi'],
|
||||
},
|
||||
{
|
||||
name: 'Hungarian',
|
||||
file: 'hu',
|
||||
locale: ['hu', 'hu-hu'],
|
||||
},
|
||||
{
|
||||
name: 'Italian',
|
||||
file: 'it',
|
||||
locale: ['it', 'it-it'],
|
||||
},
|
||||
{
|
||||
name: 'Kazakh',
|
||||
file: 'kk',
|
||||
locale: ['kk', 'kk-kz '],
|
||||
},
|
||||
{
|
||||
name: 'Latvian',
|
||||
file: 'lv',
|
||||
locale: ['lv', 'lv-lv'],
|
||||
},
|
||||
{
|
||||
name: 'Norwegian',
|
||||
file: 'nb',
|
||||
locale: ['nb', 'nb-no'],
|
||||
},
|
||||
{
|
||||
name: 'Polish',
|
||||
file: 'pl',
|
||||
locale: ['pl', 'pl-pl'],
|
||||
},
|
||||
{
|
||||
name: 'Portuguese',
|
||||
file: 'pt',
|
||||
locale: ['pt', 'pt-pt'],
|
||||
},
|
||||
{
|
||||
name: 'Romanian',
|
||||
file: 'ro',
|
||||
locale: ['ro', 'ro-ro'],
|
||||
},
|
||||
{
|
||||
name: 'Russian',
|
||||
file: 'ru',
|
||||
locale: ['ru', 'ru-ru'],
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
file: 'es',
|
||||
locale: ['es', 'es-es'],
|
||||
},
|
||||
{
|
||||
name: 'Turkish',
|
||||
file: 'tr',
|
||||
locale: ['tr', 'tr-tr'],
|
||||
},
|
||||
{
|
||||
name: 'Ukrainian',
|
||||
file: 'uk',
|
||||
locale: ['uk', 'uk-uk'],
|
||||
},
|
||||
]
|
||||
{
|
||||
name : 'English',
|
||||
file : 'en',
|
||||
locale : [ 'en', 'en-en' ]
|
||||
},
|
||||
{
|
||||
name : 'Czech',
|
||||
file : 'cs',
|
||||
locale : [ 'cs', 'cs-cs' ]
|
||||
},
|
||||
{
|
||||
name : 'Chinese (Simplified)',
|
||||
file : 'cn',
|
||||
locale : [ 'zn', 'zn-zn', 'zn-cn' ]
|
||||
}, // hans
|
||||
{
|
||||
name : 'Chinese (Traditional)',
|
||||
file : 'tw',
|
||||
locale : [ 'zn-tw', 'zn-hk', 'zn-sg' ]
|
||||
}, // hant
|
||||
{
|
||||
name : 'Croatian',
|
||||
file : 'hr',
|
||||
locale : [ 'hr', 'hr-hr' ]
|
||||
},
|
||||
{
|
||||
name : 'Danish',
|
||||
file : 'dk',
|
||||
locale : [ 'dk', 'dk-dk' ]
|
||||
},
|
||||
{
|
||||
name : 'French',
|
||||
file : 'fr',
|
||||
locale : [ 'fr', 'fr-fr' ]
|
||||
},
|
||||
{
|
||||
name : 'German',
|
||||
file : 'de',
|
||||
locale : [ 'de', 'de-de' ]
|
||||
},
|
||||
{
|
||||
name : 'Greek',
|
||||
file : 'el',
|
||||
locale : [ 'el', 'el-el' ]
|
||||
},
|
||||
{
|
||||
name : 'Hindi',
|
||||
file : 'hi',
|
||||
locale : [ 'hi', 'hi-hi' ]
|
||||
},
|
||||
{
|
||||
name : 'Hungarian',
|
||||
file : 'hu',
|
||||
locale : [ 'hu', 'hu-hu' ]
|
||||
},
|
||||
{
|
||||
name : 'Italian',
|
||||
file : 'it',
|
||||
locale : [ 'it', 'it-it' ]
|
||||
},
|
||||
{
|
||||
name : 'Kazakh',
|
||||
file : 'kk',
|
||||
locale : [ 'kk', 'kk-kz ' ]
|
||||
},
|
||||
{
|
||||
name : 'Latvian',
|
||||
file : 'lv',
|
||||
locale : [ 'lv', 'lv-lv' ]
|
||||
},
|
||||
{
|
||||
name : 'Norwegian',
|
||||
file : 'nb',
|
||||
locale : [ 'nb', 'nb-no' ]
|
||||
},
|
||||
{
|
||||
name : 'Polish',
|
||||
file : 'pl',
|
||||
locale : [ 'pl', 'pl-pl' ]
|
||||
},
|
||||
{
|
||||
name : 'Portuguese',
|
||||
file : 'pt',
|
||||
locale : [ 'pt', 'pt-pt' ]
|
||||
},
|
||||
{
|
||||
name : 'Romanian',
|
||||
file : 'ro',
|
||||
locale : [ 'ro', 'ro-ro' ]
|
||||
},
|
||||
{
|
||||
name : 'Russian',
|
||||
file : 'ru',
|
||||
locale : [ 'ru', 'ru-ru' ]
|
||||
},
|
||||
{
|
||||
name : 'Spanish',
|
||||
file : 'es',
|
||||
locale : [ 'es', 'es-es' ]
|
||||
},
|
||||
{
|
||||
name : 'Turkish',
|
||||
file : 'tr',
|
||||
locale : [ 'tr', 'tr-tr' ]
|
||||
},
|
||||
{
|
||||
name : 'Ukrainian',
|
||||
file : 'uk',
|
||||
locale : [ 'uk', 'uk-uk' ]
|
||||
}
|
||||
];
|
||||
|
||||
export const detect = () => {
|
||||
const localeFull = (navigator.language || (navigator as any).browserLanguage).toLowerCase()
|
||||
export const detect = () =>
|
||||
{
|
||||
const localeFull = (navigator.language ||
|
||||
(navigator as any).browserLanguage).toLowerCase();
|
||||
|
||||
// const localeCountry = localeFull.split(/[-_]/)[0];
|
||||
// const localeCountry = localeFull.split(/[-_]/)[0];
|
||||
|
||||
// const localeRegion = localeFull.split(/[-_]/)[1] || null;
|
||||
// const localeRegion = localeFull.split(/[-_]/)[1] || null;
|
||||
|
||||
return localeFull
|
||||
}
|
||||
return localeFull;
|
||||
};
|
||||
|
||||
export const getList = () => list
|
||||
export const getList = () => list;
|
||||
|
||||
export interface ILocale {
|
||||
name: string
|
||||
file: string
|
||||
locale: string[]
|
||||
messages: any
|
||||
name: string;
|
||||
file: string;
|
||||
locale: string[];
|
||||
messages: any;
|
||||
}
|
||||
|
||||
export const loadOne = (locale: string): ILocale => {
|
||||
let res: any = {}
|
||||
export const loadOne = (locale: string): ILocale =>
|
||||
{
|
||||
let res: any = {};
|
||||
|
||||
try {
|
||||
res = list.filter(
|
||||
(item) => item.locale.includes(locale) || item.locale.includes(locale.split(/[-_]/)[0])
|
||||
)[0]
|
||||
try
|
||||
{
|
||||
res = list.filter(
|
||||
// (item) => item.locale.includes(locale) || item.locale.includes(locale.split(/[-_]/)[0])
|
||||
(item) => item.locale.includes(locale)
|
||||
)[0];
|
||||
|
||||
res.messages = require(`./${res.file}`)
|
||||
} catch {
|
||||
res = list.filter((item) => item.locale.includes('en'))[0]
|
||||
res.messages = require(`./${res.file}`);
|
||||
}
|
||||
catch
|
||||
{
|
||||
res = list.filter((item) => item.locale.includes('en'))[0];
|
||||
|
||||
res.messages = require(`./${res.file}`)
|
||||
}
|
||||
res.messages = require(`./${res.file}`);
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,58 +1,58 @@
|
|||
{
|
||||
"socket.disconnected": "您已斷開連接",
|
||||
"socket.reconnecting": "嘗試重新連接",
|
||||
"socket.reconnected": "您已重新連接",
|
||||
"socket.requestError": "服務器請求錯誤",
|
||||
"socket.disconnected": "您已中斷連線",
|
||||
"socket.reconnecting": "嘗試重新連線",
|
||||
"socket.reconnected": "已恢復連線",
|
||||
"socket.requestError": "伺服器請求發生錯誤",
|
||||
|
||||
"room.chooseRoom": "選擇您要加入的房間的名稱",
|
||||
"room.cookieConsent": "這個網站使用Cookies來提升您的使用者體驗",
|
||||
"room.chooseRoom": "選擇您要加入的會議室名稱",
|
||||
"room.cookieConsent": "本網站使用 Cookies 技術來提升您的使用者體驗",
|
||||
"room.consentUnderstand": "了解",
|
||||
"room.joined": "您已加入房間",
|
||||
"room.cantJoin": "無法加入房間",
|
||||
"room.youLocked": "您已鎖定房間",
|
||||
"room.cantLock": "無法鎖定房間",
|
||||
"room.youUnLocked": "您解鎖了房間",
|
||||
"room.cantUnLock": "無法解鎖房間",
|
||||
"room.locked": "房間已鎖定",
|
||||
"room.unlocked": "房間現已解鎖",
|
||||
"room.joined": "您已加入會議室",
|
||||
"room.cantJoin": "無法加入會議室",
|
||||
"room.youLocked": "您已鎖定會議室",
|
||||
"room.cantLock": "無法鎖定會議室",
|
||||
"room.youUnLocked": "您解鎖了會議室",
|
||||
"room.cantUnLock": "無法解鎖會議室",
|
||||
"room.locked": "會議室已鎖定",
|
||||
"room.unlocked": "會議室現已解鎖",
|
||||
"room.newLobbyPeer": "新參與者進入大廳",
|
||||
"room.lobbyPeerLeft": "參與者離開大廳",
|
||||
"room.lobbyPeerChangedDisplayName": "大廳的參與者將名稱變更為 {displayName}",
|
||||
"room.lobbyPeerChangedPicture": "大廳的參與者變更了圖片",
|
||||
"room.setAccessCode": "設置房間的進入密碼",
|
||||
"room.accessCodeOn": "房間的進入密碼現已啟用",
|
||||
"room.accessCodeOff": "房間的進入密碼已停用",
|
||||
"room.peerChangedDisplayName": "{oldDisplayName} 已變更名稱為 {displayName}",
|
||||
"room.setAccessCode": "設定會議室的密碼",
|
||||
"room.accessCodeOn": "已啟用會議室密碼",
|
||||
"room.accessCodeOff": "已停用會議室密碼",
|
||||
"room.peerChangedDisplayName": "{oldDisplayName} 已更名為 {displayName}",
|
||||
"room.newPeer": "{displayName} 加入了會議室",
|
||||
"room.newFile": "有新文件",
|
||||
"room.newFile": "有新檔案",
|
||||
"room.toggleAdvancedMode": "切換進階模式",
|
||||
"room.setDemocraticView": "已更改為使用者佈局",
|
||||
"room.setFilmStripView": "已更改為投影片佈局",
|
||||
"room.setDemocraticView": "已更改為使用者版面",
|
||||
"room.setFilmStripView": "已更改為投影片版面",
|
||||
"room.loggedIn": "您已登入",
|
||||
"room.loggedOut": "您已登出",
|
||||
"room.changedDisplayName": "您的顯示名稱已變更為 {displayName}",
|
||||
"room.changeDisplayNameError": "更改顯示名稱時發生錯誤",
|
||||
"room.chatError": "無法發送聊天消息",
|
||||
"room.aboutToJoin": "您即將參加會議",
|
||||
"room.roomId": "房間ID: {roomName}",
|
||||
"room.setYourName": "設置您的顯示名稱,並選擇您想加入的方式:",
|
||||
"room.roomId": "會議室 ID: {roomName}",
|
||||
"room.setYourName": "設定您的顯示名稱,並選擇您想加入的方式:",
|
||||
"room.audioOnly": "僅通話",
|
||||
"room.audioVideo": "通話和視訊",
|
||||
"room.youAreReady": "準備完畢!",
|
||||
"room.emptyRequireLogin": "房間是空的! 您可以登錄以開始會議或等待主持人加入",
|
||||
"room.locketWait": "房間已鎖定! 請等待其他人允許您進入...",
|
||||
"room.emptyRequireLogin": "會議室空蕩蕩的… 您可以登入以開始會議或等待主持人加入",
|
||||
"room.locketWait": "會議室已上鎖! 請等待其他人允許您進入…",
|
||||
"room.lobbyAdministration": "大廳管理",
|
||||
"room.peersInLobby": "大廳的參與者",
|
||||
"room.lobbyEmpty": "大廳目前沒有人",
|
||||
"room.hiddenPeers": "{hiddenPeersCount, plural, one {participant} other {participants}}",
|
||||
"room.me": "我",
|
||||
"room.spotlights": "Spotlight中的參與者",
|
||||
"room.spotlights": "Spotlight 中的參與者",
|
||||
"room.passive": "被動參與者",
|
||||
"room.videoPaused": "視訊已關閉",
|
||||
"room.muteAll": "全部靜音",
|
||||
"room.stopAllVideo": "關閉全部視訊",
|
||||
"room.closeMeeting": "關閉會議",
|
||||
"room.clearChat": "清除聊天",
|
||||
"room.clearChat": "清除聊天訊息",
|
||||
"room.clearFileSharing": "清除檔案",
|
||||
"room.speechUnsupported": "您的瀏覽器不支援語音辨識",
|
||||
"room.moderatoractions": "管理員動作",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"room.loweredHand": "{displayName} 放下了他的手",
|
||||
"room.extraVideo": "其他視訊",
|
||||
"room.extraVideoDuplication": null,
|
||||
"room.overRoomLimit": "房間已滿,請稍後重試",
|
||||
"room.overRoomLimit": "會議室已滿,請稍後重試",
|
||||
"room.help": "幫助",
|
||||
"room.about": "關於",
|
||||
"room.shortcutKeys": "鍵盤快速鍵",
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
"room.hideSelfView": null,
|
||||
"room.showSelfView": null,
|
||||
|
||||
"me.mutedPTT": "您已靜音,請按下 空白鍵 來說話",
|
||||
"me.mutedPTT": "您已將麥克風靜音,請按下「空白鍵」來發言",
|
||||
|
||||
"roles.gotRole": "您已取得身份: {role}",
|
||||
"roles.lostRole": "您的 {role} 身份已被撤銷",
|
||||
|
|
@ -76,14 +76,14 @@
|
|||
"tooltip.login": "登入",
|
||||
"tooltip.logout": "登出",
|
||||
"tooltip.admitFromLobby": "從大廳允許",
|
||||
"tooltip.lockRoom": "鎖定房間",
|
||||
"tooltip.unLockRoom": "解鎖房間",
|
||||
"tooltip.lockRoom": "鎖上會議室",
|
||||
"tooltip.unLockRoom": "解鎖會議室",
|
||||
"tooltip.enterFullscreen": "進入全螢幕",
|
||||
"tooltip.leaveFullscreen": "退出全螢幕",
|
||||
"tooltip.lobby": "顯示大廳",
|
||||
"tooltip.settings": "顯示設置",
|
||||
"tooltip.settings": "設定",
|
||||
"tooltip.participants": "顯示參加者",
|
||||
"tooltip.kickParticipant": "踢出",
|
||||
"tooltip.kickParticipant": "踢出會議室",
|
||||
"tooltip.muteParticipant": "靜音",
|
||||
"tooltip.muteParticipantVideo": "隱藏視訊",
|
||||
"tooltip.inSpotlight": null,
|
||||
|
|
@ -95,11 +95,11 @@
|
|||
"tooltip.unMuteParticipant": null,
|
||||
"tooltip.addParticipantToSpotlight": null,
|
||||
"tooltip.removeParticipantFromSpotlight": null,
|
||||
"tooltip.muteParticipantAudioModerator": "關閉聲音",
|
||||
"tooltip.muteParticipantAudioModerator": "噤聲",
|
||||
"tooltip.muteParticipantVideoModerator": "關閉視訊",
|
||||
"tooltip.muteScreenSharingModerator": "關閉螢幕分享",
|
||||
|
||||
"label.roomName": "房間名稱",
|
||||
"label.roomName": "會議室名稱",
|
||||
"label.chooseRoomButton": "繼續",
|
||||
"label.yourName": "您的名字",
|
||||
"label.newWindow": "新視窗",
|
||||
|
|
@ -108,14 +108,14 @@
|
|||
"label.leave": "離開",
|
||||
"label.chatInput": "輸入聊天訊息",
|
||||
"label.chat": "聊天",
|
||||
"label.filesharing": "文件分享",
|
||||
"label.filesharing": "檔案分享",
|
||||
"label.participants": "參與者",
|
||||
"label.shareFile": "分享文件",
|
||||
"label.shareFile": "分享檔案",
|
||||
"label.shareGalleryFile": "分享圖片",
|
||||
"label.fileSharingUnsupported": "不支援文件分享",
|
||||
"label.fileSharingUnsupported": "不支援檔案分享",
|
||||
"label.unknown": "未知",
|
||||
"label.democratic": "使用者佈局",
|
||||
"label.filmstrip": "投影片佈局",
|
||||
"label.democratic": "使用者版面",
|
||||
"label.filmstrip": "投影片版面",
|
||||
"label.low": "低",
|
||||
"label.medium": "中",
|
||||
"label.high": "高 (HD)",
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
"label.logout": null,
|
||||
"label.join": null,
|
||||
|
||||
"settings.settings": "設置",
|
||||
"settings.settings": "設定",
|
||||
"settings.camera": "視訊來源",
|
||||
"settings.selectCamera": "選擇視訊來源",
|
||||
"settings.cantSelectCamera": "無法選擇此視訊來源",
|
||||
|
|
@ -149,8 +149,8 @@
|
|||
"settings.frameRate": null,
|
||||
"settings.screenSharingResolution": null,
|
||||
"settings.screenSharingFrameRate": null,
|
||||
"settings.layout": "房間佈局",
|
||||
"settings.selectRoomLayout": "選擇房間佈局",
|
||||
"settings.layout": "會議室版面",
|
||||
"settings.selectRoomLayout": "選擇會議室版面",
|
||||
"settings.advancedMode": "進階模式",
|
||||
"settings.permanentTopBar": "固定頂端列",
|
||||
"settings.aspectRatio": null,
|
||||
|
|
@ -162,10 +162,10 @@
|
|||
"settings.buttonControlBar": "獨立控制按鈕",
|
||||
"settings.showAdvancedVideo": null,
|
||||
"settings.showAdvancedAudio": null,
|
||||
"settings.echoCancellation": "回音消除",
|
||||
"settings.echoCancellation": "抑制回音",
|
||||
"settings.autoGainControl": "自動增益控制",
|
||||
"settings.noiseSuppression": "噪音消除",
|
||||
"settings.drawerOverlayed": "側邊欄覆蓋畫面",
|
||||
"settings.noiseSuppression": "消除噪音",
|
||||
"settings.drawerOverlayed": "側邊欄覆蓋",
|
||||
"settings.voiceActivatedUnmute": null,
|
||||
"settings.noiseThreshold": null,
|
||||
"settings.mirrorOwnVideo": null,
|
||||
|
|
@ -176,18 +176,18 @@
|
|||
"settings.myPhotoSizeError": null,
|
||||
"settings.myPhotoTypeError": null,
|
||||
|
||||
"filesharing.saveFileError": "無法保存文件",
|
||||
"filesharing.startingFileShare": "開始分享文件",
|
||||
"filesharing.successfulFileShare": "文件已成功分享",
|
||||
"filesharing.unableToShare": "無法分享文件",
|
||||
"filesharing.error": "文件分享發生錯誤",
|
||||
"filesharing.finished": "文件分享成功",
|
||||
"filesharing.save": "保存文件",
|
||||
"filesharing.sharedFile": "{displayName} 分享了一個文件",
|
||||
"filesharing.download": "下載文件",
|
||||
"filesharing.missingSeeds": "如果過了很久還是無法下載,則可能沒有人播種了。請讓上傳者重新上傳您想要的文件。",
|
||||
"filesharing.saveFileError": "無法儲存檔案",
|
||||
"filesharing.startingFileShare": "開始分享檔案",
|
||||
"filesharing.successfulFileShare": "成功分享檔案",
|
||||
"filesharing.unableToShare": "無法分享檔案",
|
||||
"filesharing.error": "分享檔案時發生錯誤",
|
||||
"filesharing.finished": "檔案分享完成",
|
||||
"filesharing.save": "儲存檔案",
|
||||
"filesharing.sharedFile": "{displayName} 分享了一份檔案",
|
||||
"filesharing.download": "下載檔案",
|
||||
"filesharing.missingSeeds": "如果過了很久還是無法下載,則可能沒有人播種了。請讓上傳者重新上傳您需要的檔案。",
|
||||
|
||||
"devices.devicesChanged": "您的設備已更改,請在設置中設定您的設備",
|
||||
"devices.devicesChanged": "您已變更裝置,請在設定中調整您的裝置",
|
||||
|
||||
"devices.enableOnlyMicrophone": null,
|
||||
"device.audioUnsupported": "不支援您的音訊格式",
|
||||
|
|
@ -201,18 +201,18 @@
|
|||
"device.stopVideo": "關閉視訊",
|
||||
|
||||
"device.screenSharingUnsupported": "不支援您的螢幕分享格式",
|
||||
"device.startScreenSharing": "開始螢幕分享",
|
||||
"device.startScreenSharing": "開始分享螢幕",
|
||||
"device.stopScreenSharing": "停止螢幕分享",
|
||||
|
||||
"devices.microphoneDisconnected": "麥克風已斷開",
|
||||
"devices.microphoneDisconnected": "麥克風連線中斷",
|
||||
"devices.microphoneError": "麥克風發生錯誤",
|
||||
"devices.microphoneMute": "麥克風靜音",
|
||||
"devices.microphoneUnMute": "取消麥克風靜音",
|
||||
"devices.microphoneEnable": "麥克風已啟用",
|
||||
"devices.microphoneEnable": "已啟用麥克風",
|
||||
"devices.microphoneMuteError": "無法使麥克風靜音",
|
||||
"devices.microphoneUnMuteError": "無法取消麥克風靜音",
|
||||
|
||||
"devices.screenSharingDisconnected": "螢幕分享已斷開",
|
||||
"devices.screenSharingDisconnected": "螢幕分享已中斷",
|
||||
"devices.screenSharingError": "螢幕分享時發生錯誤",
|
||||
|
||||
"devices.cameraDisconnected": "相機已斷開連接",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
"jsx": "react-jsx",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts"]
|
||||
}
|
||||
|
|
|
|||
1977
app/yarn.lock
1977
app/yarn.lock
File diff suppressed because it is too large
Load diff
|
|
@ -28,5 +28,15 @@ also for the websocket connection.
|
|||
Rebuild the web application bundle:
|
||||
|
||||
```sh
|
||||
docker-compose exec edumeet sh -c "cd app && yarn && yarn build"
|
||||
docker-compose exec -u $UID edumeet sh -c "cd app && yarn && yarn build"
|
||||
```
|
||||
|
||||
## Known issues
|
||||
|
||||
The docker virtual network used by this compose configuration (`172.22.0.0/24`)
|
||||
should be reacheble from all the started services. If iptables is filtering the
|
||||
INPUT chain, this rule is required to make the services communicating with each other:
|
||||
|
||||
```
|
||||
iptables -A INPUT --src 172.22.0.0/16 -j ACCEPT
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,311 +1,6 @@
|
|||
// eslint-disable-next-line
|
||||
var config =
|
||||
{
|
||||
loginEnabled : false,
|
||||
developmentPort : 8443,
|
||||
productionPort : 3443,
|
||||
|
||||
/*
|
||||
// If the server component runs on a different host than the app
|
||||
// you can uncomment the following line and specify the host name.
|
||||
serverHostname : 'external-server.com',
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported browsers version
|
||||
* in bowser satisfy format.
|
||||
* See more:
|
||||
* https://www.npmjs.com/package/bowser#filtering-browsers
|
||||
* Otherwise you got a unsupported browser page
|
||||
*/
|
||||
supportedBrowsers :
|
||||
{
|
||||
'windows' : {
|
||||
'internet explorer' : '>12',
|
||||
'microsoft edge' : '>18'
|
||||
},
|
||||
'safari' : '>12',
|
||||
'firefox' : '>=60',
|
||||
'chrome' : '>=74',
|
||||
'chromium' : '>=74',
|
||||
'opera' : '>=62',
|
||||
'samsung internet for android' : '>=11.1.1.52'
|
||||
},
|
||||
|
||||
/**
|
||||
* Network priorities
|
||||
* DSCP bits set by browser according this priority values.
|
||||
* ("high" means actually: EF for audio, and AF41 for Video in chrome)
|
||||
* https://en.wikipedia.org/wiki/Differentiated_services
|
||||
*/
|
||||
networkPriorities :
|
||||
{
|
||||
'audio' : 'high',
|
||||
'mainVideo' : 'high',
|
||||
'additionalVideos' : 'medium',
|
||||
'screenShare' : 'medium'
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolutions:
|
||||
*
|
||||
* low ~ 320x240
|
||||
* medium ~ 640x480
|
||||
* high ~ 1280x720
|
||||
* veryhigh ~ 1920x1080
|
||||
* ultra ~ 3840x2560
|
||||
*
|
||||
**/
|
||||
|
||||
/**
|
||||
* Frame rates:
|
||||
*
|
||||
* 1, 5, 10, 15, 20, 25, 30
|
||||
*
|
||||
**/
|
||||
// The aspect ratio of the videos as shown on
|
||||
// the screen. This is changeable in client settings.
|
||||
// This value must match one of the defined values in
|
||||
// viewAspectRatios EXACTLY (e.g. 1.333)
|
||||
viewAspectRatio : 1.777,
|
||||
// These are the selectable aspect ratios in the settings
|
||||
viewAspectRatios : [ {
|
||||
value : 1.333, // 4 / 3
|
||||
label : '4 : 3'
|
||||
}, {
|
||||
value : 1.777, // 16 / 9
|
||||
label : '16 : 9'
|
||||
} ],
|
||||
// The aspect ratio of the video from the camera
|
||||
// this is not changeable in settings, only config
|
||||
videoAspectRatio : 1.777,
|
||||
defaultResolution : 'medium',
|
||||
defaultFrameRate : 15,
|
||||
defaultScreenResolution : 'veryhigh',
|
||||
defaultScreenSharingFrameRate : 5,
|
||||
// Enable or disable simulcast for webcam video
|
||||
simulcast : true,
|
||||
// Enable or disable simulcast for screen sharing video
|
||||
simulcastSharing : false,
|
||||
// Define different encodings for various resolutions of the video
|
||||
simulcastProfiles :
|
||||
{
|
||||
3840 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 4, maxBitRate: 1500000 },
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 4000000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 10000000 }
|
||||
],
|
||||
1920 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 4, maxBitRate: 750000 },
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 1500000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 4000000 }
|
||||
],
|
||||
1280 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 4, maxBitRate: 250000 },
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 900000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 3000000 }
|
||||
],
|
||||
640 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 2, maxBitRate: 250000 },
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 900000 }
|
||||
],
|
||||
320 :
|
||||
[
|
||||
{ scaleResolutionDownBy: 1, maxBitRate: 250000 }
|
||||
]
|
||||
},
|
||||
|
||||
// The adaptive spatial layer selection scaling factor (in the range [0.5, 1.0])
|
||||
// example:
|
||||
// with level width=640px, the minimum width required to trigger the
|
||||
// level change will be: 640 * 0.75 = 480px
|
||||
adaptiveScalingFactor : 0.75,
|
||||
|
||||
/**
|
||||
* White listing browsers that support audio output device selection.
|
||||
* It is not yet fully implemented in Firefox.
|
||||
* See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512
|
||||
*/
|
||||
audioOutputSupportedBrowsers :
|
||||
[
|
||||
'chrome',
|
||||
'opera'
|
||||
],
|
||||
// Socket.io request timeout
|
||||
requestTimeout : 20000,
|
||||
requestRetries : 3,
|
||||
transportOptions :
|
||||
{
|
||||
tcp : true
|
||||
},
|
||||
|
||||
/**
|
||||
* Set max number participants in one room that join
|
||||
* unmuted. Next participant will join automatically muted
|
||||
* Default value is 4
|
||||
*
|
||||
* Set it to 0 to auto mute all,
|
||||
* Set it to negative (-1) to never automatically auto mute
|
||||
* but use it with caution
|
||||
* full mesh audio strongly decrease room capacity!
|
||||
*/
|
||||
autoMuteThreshold : 4,
|
||||
background : 'images/background.jpg',
|
||||
defaultLayout : 'democratic', // democratic, filmstrip
|
||||
// If true, will show media control buttons in separate
|
||||
// control bar, not in the ME container.
|
||||
buttonControlBar : false,
|
||||
// If false, will push videos away to make room for side
|
||||
// drawer. If true, will overlay side drawer over videos
|
||||
drawerOverlayed : true,
|
||||
// Position of notifications
|
||||
notificationPosition : 'right',
|
||||
|
||||
/**
|
||||
* Set the notificationSounds. Valid keys are:
|
||||
* 'parkedPeer', 'parkedPeers', 'raisedHand', 'chatMessage',
|
||||
* 'sendFile', 'newPeer' and 'default'.
|
||||
*
|
||||
* Not defining a key is equivalent to using the default notification sound.
|
||||
* Setting 'play' to null disables the sound notification.
|
||||
*/
|
||||
notificationSounds : {
|
||||
chatMessage : {
|
||||
play : '/sounds/notify-chat.mp3'
|
||||
},
|
||||
raisedHand : {
|
||||
play : '/sounds/notify-hand.mp3'
|
||||
},
|
||||
default : {
|
||||
delay : 5000, // minimum delay between alert sounds [ms]
|
||||
play : '/sounds/notify.mp3'
|
||||
}
|
||||
},
|
||||
// Timeout for autohiding topbar and button control bar
|
||||
hideTimeout : 3000,
|
||||
// max number of participant that will be visible in
|
||||
// as speaker
|
||||
lastN : 4,
|
||||
mobileLastN : 1,
|
||||
// Highest number of lastN the user can select manually in
|
||||
// userinteface
|
||||
maxLastN : 5,
|
||||
// If truthy, users can NOT change number of speakers visible
|
||||
lockLastN : false,
|
||||
// Show logo if "logo" is not null, else show title
|
||||
// Set logo file name using logo.* pattern like "logo.png" to not track it by git
|
||||
logo : 'images/logo.edumeet.svg',
|
||||
title : 'edumeet',
|
||||
// Service & Support URL
|
||||
// if not set then not displayed on the about modals
|
||||
supportUrl : 'https://support.example.com',
|
||||
// Privacy and dataprotection URL or path
|
||||
// by default privacy/privacy.html
|
||||
// that is a placeholder for your policies
|
||||
//
|
||||
// but an external url could be also used here
|
||||
privacyUrl : 'privacy/privacy.html',
|
||||
theme :
|
||||
{
|
||||
palette :
|
||||
{
|
||||
primary :
|
||||
{
|
||||
main : '#313131'
|
||||
}
|
||||
},
|
||||
overrides :
|
||||
{
|
||||
MuiAppBar :
|
||||
{
|
||||
colorPrimary :
|
||||
{
|
||||
backgroundColor : '#313131'
|
||||
}
|
||||
},
|
||||
MuiButton :
|
||||
{
|
||||
containedPrimary :
|
||||
{
|
||||
backgroundColor : '#5F9B2D',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#5F9B2D'
|
||||
}
|
||||
},
|
||||
containedSecondary :
|
||||
{
|
||||
backgroundColor : '#f50057',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#f50057'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/*
|
||||
MuiIconButton :
|
||||
{
|
||||
colorPrimary :
|
||||
{
|
||||
backgroundColor : '#5F9B2D',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#5F9B2D'
|
||||
}
|
||||
},
|
||||
colorSecondary :
|
||||
{
|
||||
backgroundColor : '#f50057',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#f50057'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
*/
|
||||
|
||||
MuiFab :
|
||||
{
|
||||
primary :
|
||||
{
|
||||
backgroundColor : '#5F9B2D',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#5F9B2D'
|
||||
}
|
||||
},
|
||||
secondary :
|
||||
{
|
||||
backgroundColor : '#f50057',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#f50057'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
MuiBadge :
|
||||
{
|
||||
colorPrimary :
|
||||
{
|
||||
backgroundColor : '#5F9B2D',
|
||||
'&:hover' :
|
||||
{
|
||||
backgroundColor : '#518029'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
typography :
|
||||
{
|
||||
useNextVariants : true
|
||||
}
|
||||
}
|
||||
productionPort : 3443
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,33 +1,7 @@
|
|||
const os = require('os');
|
||||
// const fs = require('fs');
|
||||
|
||||
const userRoles = require('../userRoles');
|
||||
|
||||
import {
|
||||
BYPASS_ROOM_LOCK,
|
||||
BYPASS_LOBBY
|
||||
} from '../access';
|
||||
|
||||
const {
|
||||
CHANGE_ROOM_LOCK,
|
||||
PROMOTE_PEER,
|
||||
MODIFY_ROLE,
|
||||
SEND_CHAT,
|
||||
MODERATE_CHAT,
|
||||
SHARE_AUDIO,
|
||||
SHARE_VIDEO,
|
||||
SHARE_SCREEN,
|
||||
EXTRA_VIDEO,
|
||||
SHARE_FILE,
|
||||
MODERATE_FILES,
|
||||
MODERATE_ROOM
|
||||
} = require('../permissions');
|
||||
|
||||
// const AwaitQueue = require('awaitqueue');
|
||||
// const axios = require('axios');
|
||||
|
||||
// To gather ip address only on interface like eth0, ens0p3
|
||||
const ifaceWhiteListRegex = /^(eth.*)|(ens.*)|(tun.*)/
|
||||
const ifaceWhiteListRegex = /^(eth.*)|(ens.*)|(tun.*)|(wlp.*)/
|
||||
|
||||
function getListenIps() {
|
||||
let listenIP = [];
|
||||
|
|
@ -53,351 +27,6 @@ function getListenIps() {
|
|||
|
||||
module.exports =
|
||||
{
|
||||
|
||||
// Auth conf
|
||||
/*
|
||||
auth :
|
||||
{
|
||||
// Always enabled if configured
|
||||
lti :
|
||||
{
|
||||
consumerKey : 'key',
|
||||
consumerSecret : 'secret'
|
||||
},
|
||||
|
||||
// Auth strategy to use (default oidc)
|
||||
strategy : 'oidc',
|
||||
oidc :
|
||||
{
|
||||
// The issuer URL for OpenID Connect discovery
|
||||
// The OpenID Provider Configuration Document
|
||||
// could be discovered on:
|
||||
// issuerURL + '/.well-known/openid-configuration'
|
||||
|
||||
// e.g. google OIDC config
|
||||
// Follow this guide to get credential:
|
||||
// https://developers.google.com/identity/protocols/oauth2/openid-connect
|
||||
// use this issuerURL
|
||||
// issuerURL : 'https://accounts.google.com/',
|
||||
|
||||
issuerURL : 'https://example.com',
|
||||
clientOptions :
|
||||
{
|
||||
client_id : '',
|
||||
client_secret : '',
|
||||
scope : 'openid email profile',
|
||||
// where client.example.com is your edumeet server
|
||||
redirect_uri : 'https://client.example.com/auth/callback'
|
||||
}
|
||||
|
||||
},
|
||||
saml :
|
||||
{
|
||||
// where edumeet.example.com is your edumeet server
|
||||
callbackUrl : 'https://edumeet.example.com/auth/callback',
|
||||
issuer : 'https://edumeet.example.com',
|
||||
entryPoint : 'https://openidp.feide.no/simplesaml/saml2/idp/SSOService.php',
|
||||
privateCert : fs.readFileSync('config/saml_privkey.pem', 'utf-8'),
|
||||
signingCert : fs.readFileSync('config/saml_cert.pem', 'utf-8'),
|
||||
decryptionPvk : fs.readFileSync('config/saml_privkey.pem', 'utf-8'),
|
||||
decryptionCert : fs.readFileSync('config/saml_cert.pem', 'utf-8'),
|
||||
// Federation cert
|
||||
cert : fs.readFileSync('config/federation_cert.pem', 'utf-8')
|
||||
},
|
||||
|
||||
// to create password hash use: node server/utils/password_encode.js cleartextpassword
|
||||
local :
|
||||
{
|
||||
users : [
|
||||
{
|
||||
id : 1,
|
||||
username : 'alice',
|
||||
passwordHash : '$2b$10$PAXXw.6cL3zJLd7ZX.AnL.sFg2nxjQPDmMmGSOQYIJSa0TrZ9azG6',
|
||||
displayName : 'Alice',
|
||||
emails : [ { value: 'alice@atlanta.com' } ]
|
||||
},
|
||||
{
|
||||
id : 2,
|
||||
username : 'bob',
|
||||
passwordHash : '$2b$10$BzAkXcZ54JxhHTqCQcFn8.H6klY/G48t4jDBeTE2d2lZJk/.tvv0G',
|
||||
displayName : 'Bob',
|
||||
emails : [ { value: 'bob@biloxi.com' } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
*/
|
||||
// URI and key for requesting geoip-based TURN server closest to the client
|
||||
//turnAPIKey : 'examplekey',
|
||||
//turnAPIURI : 'https://example.com/api/turn',
|
||||
//turnAPIparams : {
|
||||
// 'uri_schema' : 'turn',
|
||||
// 'transport' : 'tcp',
|
||||
// 'ip_ver' : 'ipv4',
|
||||
// 'servercount' : '2'
|
||||
//},
|
||||
//turnAPITimeout : 2 * 1000,
|
||||
// Backup turnservers if REST fails or is not configured
|
||||
//backupTurnServers : [
|
||||
// {
|
||||
// urls : [
|
||||
// 'turn:turn.example.com:443?transport=tcp'
|
||||
// ],
|
||||
// username : 'example',
|
||||
// credential : 'example'
|
||||
// }
|
||||
//],
|
||||
// bittorrent tracker
|
||||
fileTracker : 'wss://tracker.lab.vvc.niif.hu:443',
|
||||
// redis server options
|
||||
redisOptions : {
|
||||
host: 'redis',
|
||||
port: 6379
|
||||
},
|
||||
// session cookie secret
|
||||
cookieSecret : 'T0P-S3cR3t_cook!e',
|
||||
cookieName : 'edumeet.sid',
|
||||
// if you use encrypted private key the set the passphrase
|
||||
tls :
|
||||
{
|
||||
cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`,
|
||||
// passphrase: 'key_password'
|
||||
key : `${__dirname}/../certs/mediasoup-demo.localhost.key.pem`
|
||||
},
|
||||
// listening Host or IP
|
||||
// If omitted listens on every IP. ("0.0.0.0" and "::")
|
||||
// listeningHost: 'localhost',
|
||||
// Listening port for https server.
|
||||
listeningPort : 3443,
|
||||
// Any http request is redirected to https.
|
||||
// Listening port for http server.
|
||||
listeningRedirectPort : 8080,
|
||||
// Listens only on http, only on listeningPort
|
||||
// listeningRedirectPort disabled
|
||||
// use case: loadbalancer backend
|
||||
httpOnly : false,
|
||||
// WebServer/Express trust proxy config for httpOnly mode
|
||||
// You can find more info:
|
||||
// - https://expressjs.com/en/guide/behind-proxies.html
|
||||
// - https://www.npmjs.com/package/proxy-addr
|
||||
// use case: loadbalancer backend
|
||||
trustProxy : '',
|
||||
// This logger class will have the log function
|
||||
// called every time there is a room created or destroyed,
|
||||
// or peer created or destroyed. This would then be able
|
||||
// to log to a file or external service.
|
||||
/* StatusLogger : class
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
this._queue = new AwaitQueue();
|
||||
}
|
||||
|
||||
// rooms: rooms object
|
||||
// peers: peers object
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async log({ rooms, peers })
|
||||
{
|
||||
this._queue.push(async () =>
|
||||
{
|
||||
// Do your logging in here, use queue to keep correct order
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Number of rooms: ', rooms.size);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Number of peers: ', peers.size);
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('error in log', error);
|
||||
});
|
||||
}
|
||||
}, */
|
||||
// This function will be called on successful login through oidc.
|
||||
// Use this function to map your oidc userinfo to the Peer object.
|
||||
// The roomId is equal to the room name.
|
||||
// See examples below.
|
||||
// Examples:
|
||||
/*
|
||||
// All authenicated users will be MODERATOR and AUTHENTICATED
|
||||
userMapping : async ({ peer, room, roomId, userinfo }) =>
|
||||
{
|
||||
peer.addRole(userRoles.MODERATOR);
|
||||
peer.addRole(userRoles.AUTHENTICATED);
|
||||
},
|
||||
// All authenicated users will be AUTHENTICATED,
|
||||
// and those with the moderator role set in the userinfo
|
||||
// will also be MODERATOR
|
||||
userMapping : async ({ peer, room, roomId, userinfo }) =>
|
||||
{
|
||||
if (
|
||||
Array.isArray(userinfo.meet_roles) &&
|
||||
userinfo.meet_roles.includes('moderator')
|
||||
)
|
||||
{
|
||||
peer.addRole(userRoles.MODERATOR);
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(userinfo.meet_roles) &&
|
||||
userinfo.meet_roles.includes('meetingadmin')
|
||||
)
|
||||
{
|
||||
peer.addRole(userRoles.ADMIN);
|
||||
}
|
||||
|
||||
peer.addRole(userRoles.AUTHENTICATED);
|
||||
},
|
||||
// First authenticated user will be moderator,
|
||||
// all others will be AUTHENTICATED
|
||||
userMapping : async ({ peer, room, roomId, userinfo }) =>
|
||||
{
|
||||
if (room)
|
||||
{
|
||||
const peers = room.getJoinedPeers();
|
||||
|
||||
if (peers.some((_peer) => _peer.authenticated))
|
||||
peer.addRole(userRoles.AUTHENTICATED);
|
||||
else
|
||||
{
|
||||
peer.addRole(userRoles.MODERATOR);
|
||||
peer.addRole(userRoles.AUTHENTICATED);
|
||||
}
|
||||
}
|
||||
},
|
||||
// All authenicated users will be AUTHENTICATED,
|
||||
// and those with email ending with @example.com
|
||||
// will also be MODERATOR
|
||||
userMapping : async ({ peer, room, roomId, userinfo }) =>
|
||||
{
|
||||
if (userinfo.email && userinfo.email.endsWith('@example.com'))
|
||||
{
|
||||
peer.addRole(userRoles.MODERATOR);
|
||||
}
|
||||
|
||||
peer.addRole(userRoles.AUTHENTICATED);
|
||||
},
|
||||
// All authenicated users will be AUTHENTICATED,
|
||||
// and those with email ending with @example.com
|
||||
// will also be MODERATOR
|
||||
userMapping : async ({ peer, room, roomId, userinfo }) =>
|
||||
{
|
||||
if (userinfo.email && userinfo.email.endsWith('@example.com'))
|
||||
{
|
||||
peer.addRole(userRoles.MODERATOR);
|
||||
}
|
||||
|
||||
peer.addRole(userRoles.AUTHENTICATED);
|
||||
},
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
userMapping : async ({ peer, room, roomId, userinfo }) =>
|
||||
{
|
||||
if (userinfo.picture != null)
|
||||
{
|
||||
if (!userinfo.picture.match(/^http/g))
|
||||
{
|
||||
peer.picture = `data:image/jpeg;base64, ${userinfo.picture}`;
|
||||
}
|
||||
else
|
||||
{
|
||||
peer.picture = userinfo.picture;
|
||||
}
|
||||
}
|
||||
if (userinfo['urn:oid:0.9.2342.19200300.100.1.60'] != null)
|
||||
{
|
||||
peer.picture = `data:image/jpeg;base64, ${userinfo['urn:oid:0.9.2342.19200300.100.1.60']}`;
|
||||
}
|
||||
|
||||
if (userinfo.nickname != null)
|
||||
{
|
||||
peer.displayName = userinfo.nickname;
|
||||
}
|
||||
|
||||
if (userinfo.name != null)
|
||||
{
|
||||
peer.displayName = userinfo.name;
|
||||
}
|
||||
|
||||
if (userinfo.displayName != null)
|
||||
{
|
||||
peer.displayName = userinfo.displayName;
|
||||
}
|
||||
|
||||
if (userinfo['urn:oid:2.16.840.1.113730.3.1.241'] != null)
|
||||
{
|
||||
peer.displayName = userinfo['urn:oid:2.16.840.1.113730.3.1.241'];
|
||||
}
|
||||
|
||||
if (userinfo.email != null)
|
||||
{
|
||||
peer.email = userinfo.email;
|
||||
}
|
||||
},
|
||||
// All users have the role "NORMAL" by default. Other roles need to be
|
||||
// added in the "userMapping" function. The following accesses and
|
||||
// permissions are arrays of roles. Roles can be changed in userRoles.js
|
||||
//
|
||||
// Example:
|
||||
// [ userRoles.MODERATOR, userRoles.AUTHENTICATED ]
|
||||
accessFromRoles : {
|
||||
// The role(s) will gain access to the room
|
||||
// even if it is locked (!)
|
||||
[BYPASS_ROOM_LOCK] : [ userRoles.ADMIN ],
|
||||
// The role(s) will gain access to the room without
|
||||
// going into the lobby. If you want to restrict access to your
|
||||
// server to only directly allow authenticated users, you could
|
||||
// add the userRoles.AUTHENTICATED to the user in the userMapping
|
||||
// function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ]
|
||||
[BYPASS_LOBBY] : [ userRoles.NORMAL ]
|
||||
},
|
||||
permissionsFromRoles : {
|
||||
// The role(s) have permission to lock/unlock a room
|
||||
[CHANGE_ROOM_LOCK] : [ userRoles.MODERATOR ],
|
||||
// The role(s) have permission to promote a peer from the lobby
|
||||
[PROMOTE_PEER] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to give/remove other peers roles
|
||||
[MODIFY_ROLE] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to send chat messages
|
||||
[SEND_CHAT] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to moderate chat
|
||||
[MODERATE_CHAT] : [ userRoles.MODERATOR ],
|
||||
// The role(s) have permission to share audio
|
||||
[SHARE_AUDIO] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to share video
|
||||
[SHARE_VIDEO] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to share screen
|
||||
[SHARE_SCREEN] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to produce extra video
|
||||
[EXTRA_VIDEO] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to share files
|
||||
[SHARE_FILE] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to moderate files
|
||||
[MODERATE_FILES] : [ userRoles.MODERATOR ],
|
||||
// The role(s) have permission to moderate room (e.g. kick user)
|
||||
[MODERATE_ROOM] : [ userRoles.MODERATOR ]
|
||||
},
|
||||
// Array of permissions. If no peer with the permission in question
|
||||
// is in the room, all peers are permitted to do the action. The peers
|
||||
// that are allowed because of this rule will not be able to do this
|
||||
// action as soon as a peer with the permission joins. In this example
|
||||
// everyone will be able to lock/unlock room until a MODERATOR joins.
|
||||
allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ],
|
||||
// When truthy, the room will be open to all users when as long as there
|
||||
// are allready users in the room
|
||||
activateOnHostJoin : true,
|
||||
// When set, maxUsersPerRoom defines how many users can join
|
||||
// a single room. If not set, there is no limit.
|
||||
// maxUsersPerRoom : 20,
|
||||
// Room size before spreading to new router
|
||||
routerScaleSize : 40,
|
||||
// Socket timout value
|
||||
requestTimeout : 20000,
|
||||
// Socket retries when timeout
|
||||
requestRetries : 3,
|
||||
// If > 0, sets a cache-control max-age (in seconds) to static files responses.
|
||||
staticFilesCachePeriod : 0,
|
||||
// Mediasoup settings
|
||||
mediasoup :
|
||||
{
|
||||
|
|
@ -479,32 +108,6 @@ module.exports =
|
|||
webRtcTransport :
|
||||
{
|
||||
listenIps : getListenIps(),
|
||||
/*[
|
||||
// change 192.0.2.1 IPv4 to your server's IPv4 address!!
|
||||
//{ ip: '192.0.2.1', announcedIp: null }
|
||||
// Can have multiple listening interfaces
|
||||
// change 2001:DB8::1 IPv6 to your server's IPv6 address!!
|
||||
// { ip: '2001:DB8::1', announcedIp: null }
|
||||
],*/
|
||||
initialAvailableOutgoingBitrate : 1000000,
|
||||
minimumAvailableOutgoingBitrate : 600000,
|
||||
// Additional options that are not part of WebRtcTransportOptions.
|
||||
maxIncomingBitrate : 1500000
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
,
|
||||
// Prometheus exporter
|
||||
prometheus : {
|
||||
deidentify : false, // deidentify IP addresses
|
||||
// listen : 'localhost', // exporter listens on this address
|
||||
numeric : true, // show numeric IP addresses
|
||||
port : 8889, // allocated port
|
||||
quiet : false, // include fewer labels
|
||||
// aggregated metrics options
|
||||
period : 15, // update period (seconds)
|
||||
secret : null // if set, checks the authorization header: `Bearer <secret>`
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
12
compose/config/edumeet-server-config.yaml
Normal file
12
compose/config/edumeet-server-config.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
redisOptions:
|
||||
host: redis
|
||||
port: 6379
|
||||
|
||||
listeningPort: 3443
|
||||
listeningRedirectPort: 0
|
||||
httpOnly: false
|
||||
trustProxy: ''
|
||||
|
||||
prometheus:
|
||||
enabled: true
|
||||
listen: 0.0.0.0
|
||||
|
|
@ -18,6 +18,7 @@ services:
|
|||
volumes:
|
||||
- ${PWD}/..:/edumeet
|
||||
- ${PWD}/config/edumeet-server-config.js:/edumeet/server/config/config.js:ro
|
||||
- ${PWD}/config/edumeet-server-config.yaml:/edumeet/server/config/config.yaml:ro
|
||||
- ${PWD}/config/edumeet-app-config.js:/edumeet/app/public/config/config.js:ro
|
||||
network_mode: "host"
|
||||
extra_hosts:
|
||||
|
|
|
|||
|
|
@ -6,5 +6,7 @@ WORKDIR /edumeet
|
|||
ENV DEBUG=edumeet*,mediasoup*
|
||||
RUN npm install -g nodemon && \
|
||||
npm install -g concurrently
|
||||
RUN touch /.yarnrc && mkdir /.yarn && chmod 775 /.yarn /.yarnrc
|
||||
CMD concurrently --names "server,app" "cd server && yarn && yarn dev" "cd app && yarn && yarn build && yarn start"
|
||||
RUN touch /.yarnrc && mkdir -p /.yarn /.cache/yarn && chmod 775 /.yarn /.yarnrc /.cache/yarn
|
||||
CMD concurrently --names "server,app" \
|
||||
"cd server && yarn && yarn dev" \
|
||||
"cd app && yarn && yarn build && yarn start"
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ Description=edumeet is a audio / video meeting service running in the browser an
|
|||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/src/edumeet/server/server.js
|
||||
# modify the paths accordingly with your installation
|
||||
ExecStart=/usr/local/src/edumeet/server/dist/server.js
|
||||
WorkingDirectory=/usr/local/src/edumeet/server
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
Environment=PATH=/usr/bin:/usr/local/bin
|
||||
Environment=NODE_ENV=production
|
||||
WorkingDirectory=/usr/local/src/edumeet/server
|
||||
Environment=DEBUG="*ERROR*,*WARN*,*INFO*"
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@
|
|||
"overrides": [
|
||||
{
|
||||
"files":["**/*.ts"],
|
||||
"plugins": ["prettier", "@typescript-eslint"],
|
||||
"extends": ["airbnb-typescript/base", "prettier"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends":[
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
|
|
@ -21,69 +25,29 @@
|
|||
// }
|
||||
// },
|
||||
"rules": {
|
||||
"no-underscore-dangle":"off",
|
||||
"object-curly-spacing": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
"no-unused-vars" : 0,
|
||||
"@typescript-eslint/ban-types" : 0,
|
||||
"@typescript-eslint/ban-ts-comment" : 0,
|
||||
"@typescript-eslint/ban-ts-ignore" : 0,
|
||||
"@typescript-eslint/explicit-module-boundary-types" : 0,
|
||||
"@typescript-eslint/member-delimiter-style" : [ 2,
|
||||
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "none"
|
||||
"multiline" : { "delimiter": "semi", "requireLast": true },
|
||||
"singleline" : { "delimiter": "semi", "requireLast": false }
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/semi": [
|
||||
"off"
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
"@typescript-eslint/no-explicit-any" : 0,
|
||||
"@typescript-eslint/no-unused-vars" : [ 2,
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "none"
|
||||
"vars" : "all",
|
||||
"args" : "after-used",
|
||||
"ignoreRestSiblings" : false
|
||||
}
|
||||
],
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 100,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true,
|
||||
"ignoreComments": true
|
||||
}
|
||||
],
|
||||
"prefer-destructuring": [
|
||||
"error",
|
||||
{
|
||||
"VariableDeclarator": {
|
||||
"array": false,
|
||||
"object": true
|
||||
},
|
||||
"AssignmentExpression": {
|
||||
"array": false,
|
||||
"object": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"enforceForRenamedProperties": false
|
||||
}
|
||||
],
|
||||
"no-plusplus": [
|
||||
"error",
|
||||
{
|
||||
"allowForLoopAfterthoughts": true
|
||||
}
|
||||
],
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": [
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts",
|
||||
"src/tests/**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
"@typescript-eslint/no-use-before-define" : [ 2, { "functions": false } ],
|
||||
"@typescript-eslint/no-empty-function" : 0,
|
||||
"@typescript-eslint/no-non-null-assertion" : 0
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
81
server/README.md
Normal file
81
server/README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Edumeet Server Configuration
|
||||
|
||||
The server configuration file can use one of the following formats:
|
||||
|
||||
- config/config.json
|
||||
- config/config.json5
|
||||
- config/config.yaml
|
||||
- config/config.yml
|
||||
- config/config.toml
|
||||
|
||||
Example `config.yaml`:
|
||||
|
||||
```yaml
|
||||
redisOptions:
|
||||
host: redis
|
||||
port: 6379
|
||||
|
||||
listeningPort: 3443
|
||||
```
|
||||
|
||||
Additionally, a `config/config.js` can be used to override specific properties
|
||||
with runtime generated values and to set additional configuration functions and classes.
|
||||
Look at the default `config/config.example.js` file for documentation.
|
||||
|
||||
## Configuration properties
|
||||
|
||||
| Name | Description | Format | Default value |
|
||||
| :--- | :---------- | :----- | :------------ |
|
||||
| turnAPIKey | TURN server key for requesting a geoip-based TURN server closest to the client. | `"string"` | ``""`` |
|
||||
| turnAPIURI | TURN server URL for requesting a geoip-based TURN server closest to the client. | `"string"` | ``""`` |
|
||||
| turnAPIparams.uri_schema | TURN server URL schema. | `"string"` | ``"turn"`` |
|
||||
| turnAPIparams.transport | TURN server transport. | `[ "tcp", "udp"]` | ``"tcp"`` |
|
||||
| turnAPIparams.ip_ver | TURN server IP version. | `[ "ipv4", "ipv6"]` | ``"ipv4"`` |
|
||||
| turnAPIparams.servercount | TURN server count. | `"nat"` | ``2`` |
|
||||
| turnAPITimeout | TURN server API timeout (seconds). | `"nat"` | ``2000`` |
|
||||
| backupTurnServers | Backup TURN servers if REST fails or is not configured | `"*"` | ``[ { "urls": [ "turn:turn.example.com:443?transport=tcp" ], "username": "example", "credential": "example" }]`` |
|
||||
| fileTracker | Bittorrent tracker. | `"string"` | ``"wss://tracker.lab.vvc.niif.hu:443"`` |
|
||||
| redisOptions.host | Redis server host. | `"string"` | ``"localhost"`` |
|
||||
| redisOptions.port | Redis server port. | `"port"` | ``6379`` |
|
||||
| redisOptions.password | Redis server password. | `"string"` | ``""`` |
|
||||
| cookieSecret | Session cookie secret. | `"string"` | ``"T0P-S3cR3t_cook!e"`` |
|
||||
| cookieName | Session cookie name. | `"string"` | ``"edumeet.sid"`` |
|
||||
| tls.cert | SSL certificate path. | `"string"` | ``"./certs/mediasoup-demo.localhost.cert.pem"`` |
|
||||
| tls.key | SSL key path. | `"string"` | ``"./certs/mediasoup-demo.localhost.key.pem"`` |
|
||||
| listeningHost | The listening Host or IP address. | `"string"` | ``"0.0.0.0"`` |
|
||||
| listeningPort | The HTTPS listening port. | `"port"` | ``8443`` |
|
||||
| listeningRedirectPort | The HTTP server listening port used for redirecting any HTTP request to HTTPS. If 0, the redirect server is disabled. | `"port"` | ``8080`` |
|
||||
| httpOnly | Listens only on HTTP on listeningPort; listeningRedirectPort disabled. Use case: load balancer backend. | `"boolean"` | ``false`` |
|
||||
| trustProxy | WebServer/Express trust proxy config for httpOnly mode. More infos: [expressjs](https://expressjs.com/en/guide/behind-proxies.html), [proxy-addr](https://www.npmjs.com/package/proxy-addr) | `"string"` | ``""`` |
|
||||
| staticFilesCachePeriod | The max-age in milliseconds for HTTP caching of static resources. This can also be a string accepted by the [ms module](https://www.npmjs.com/package/ms#readme). | `"*"` | ``0`` |
|
||||
| activateOnHostJoin | When true, the room will be open to all users since there are users in the room. | `"boolean"` | ``true`` |
|
||||
| roomsUnlocked | An array of rooms users can enter without waiting in the lobby. | `"array"` | ``[]`` |
|
||||
| maxUsersPerRoom | It defines how many users can join a single room. If not set, no limit is applied. | `"nat"` | ``0`` |
|
||||
| routerScaleSize | Room size before spreading to a new router. | `"nat"` | ``40`` |
|
||||
| requestTimeout | Socket timeout value (ms). | `"nat"` | ``20000`` |
|
||||
| requestRetries | Socket retries when a timeout occurs. | `"nat"` | ``3`` |
|
||||
| mediasoup.numWorkers | The number of Mediasoup workers to spawn. Defaults to the available CPUs count. | `"nat"` | ``8`` |
|
||||
| mediasoup.worker.logLevel | The Mediasoup log level. | `"string"` | ``"warn"`` |
|
||||
| mediasoup.worker.logTags | The Mediasoup log tags. | `"array"` | ``[ "info", "ice", "dtls", "rtp", "srtp", "rtcp"]`` |
|
||||
| mediasoup.worker.rtcMinPort | The Mediasoup start listening port number. | `"port"` | ``40000`` |
|
||||
| mediasoup.worker.rtcMaxPort | The Mediasoup end listening port number. | `"port"` | ``49999`` |
|
||||
| mediasoup.router.mediaCodecs | The Mediasoup codecs settings. [supportedRtpCapabilities](https://github.com/versatica/mediasoup/blob/v3/src/supportedRtpCapabilities.ts) | `"*"` | ``[ { "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.listenIps | The Mediasoup listen IPs. [TransportListenIp](https://mediasoup.org/documentation/v3/mediasoup/api/#TransportListenIp) | `"array"` | ``[ { "ip": "0.0.0.0", "announcedIp": null }]`` |
|
||||
| mediasoup.webRtcTransport.initialAvailableOutgoingBitrate | The Mediasoup initial available outgoing bitrate (in bps). [WebRtcTransportOptions](https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions) | `"nat"` | ``1000000`` |
|
||||
| mediasoup.webRtcTransport.maxIncomingBitrate | The Mediasoup maximum incoming bitrate for each transport. (in bps). [setMaxIncomingBitrate](https://mediasoup.org/documentation/v3/mediasoup/api/#transport-setMaxIncomingBitrate) | `"nat"` | ``1500000`` |
|
||||
| prometheus.enabled | Enables the Prometheus metrics exporter. | `"boolean"` | ``false`` |
|
||||
| prometheus.listen | Prometheus metrics exporter listening address. | `"string"` | ``"localhost"`` |
|
||||
| prometheus.port | The Prometheus metrics exporter listening port. | `"port"` | ``8889`` |
|
||||
| prometheus.deidentify | De-identify IP addresses in Prometheus logs. | `"boolean"` | ``false`` |
|
||||
| prometheus.numeric | Show numeric IP addresses in Prometheus logs. | `"boolean"` | ``false`` |
|
||||
| prometheus.quiet | Include fewer labels in Prometheus metrics. | `"boolean"` | ``false`` |
|
||||
| prometheus.period | The Prometheus metrics exporter update period (seconds). | `"nat"` | ``15`` |
|
||||
| prometheus.secret | The Prometheus metrics exporter authorization header: `Bearer <secret>` required to allow scraping. | `"string"` | ``""`` |
|
||||
| accessFromRoles | User roles. | `"*"` | ``{ "BYPASS_ROOM_LOCK": [ { "id": 2529, "label": "admin", "level": 50, "promotable": true } ], "BYPASS_LOBBY": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ]}`` |
|
||||
| permissionsFromRoles | User permissions from roles. | `"*"` | ``{ "CHANGE_ROOM_LOCK": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ], "PROMOTE_PEER": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "MODIFY_ROLE": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SEND_CHAT": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "MODERATE_CHAT": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ], "SHARE_AUDIO": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SHARE_VIDEO": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SHARE_SCREEN": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "EXTRA_VIDEO": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SHARE_FILE": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "MODERATE_FILES": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ], "MODERATE_ROOM": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ]}`` |
|
||||
| allowWhenRoleMissing | Allow when role missing. | `"array"` | ``[ "CHANGE_ROOM_LOCK"]`` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
*Document generated with:* `yarn gen-config-docs`
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const os = require('os');
|
||||
// const os = require('os');
|
||||
// const fs = require('fs');
|
||||
|
||||
const userRoles = require('../userRoles');
|
||||
|
|
@ -28,7 +28,6 @@ const {
|
|||
|
||||
module.exports =
|
||||
{
|
||||
|
||||
// Auth conf
|
||||
/*
|
||||
auth :
|
||||
|
|
@ -104,58 +103,6 @@ module.exports =
|
|||
}
|
||||
},
|
||||
*/
|
||||
// URI and key for requesting geoip-based TURN server closest to the client
|
||||
turnAPIKey : 'examplekey',
|
||||
turnAPIURI : 'https://example.com/api/turn',
|
||||
turnAPIparams : {
|
||||
'uri_schema' : 'turn',
|
||||
'transport' : 'tcp',
|
||||
'ip_ver' : 'ipv4',
|
||||
'servercount' : '2'
|
||||
},
|
||||
turnAPITimeout : 2 * 1000,
|
||||
// Backup turnservers if REST fails or is not configured
|
||||
backupTurnServers : [
|
||||
{
|
||||
urls : [
|
||||
'turn:turn.example.com:443?transport=tcp'
|
||||
],
|
||||
username : 'example',
|
||||
credential : 'example'
|
||||
}
|
||||
],
|
||||
// bittorrent tracker
|
||||
fileTracker : 'wss://tracker.lab.vvc.niif.hu:443',
|
||||
// redis server options
|
||||
redisOptions : {},
|
||||
// session cookie secret
|
||||
cookieSecret : 'T0P-S3cR3t_cook!e',
|
||||
cookieName : 'edumeet.sid',
|
||||
// if you use encrypted private key the set the passphrase
|
||||
tls :
|
||||
{
|
||||
cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`,
|
||||
// passphrase: 'key_password'
|
||||
key : `${__dirname}/../certs/mediasoup-demo.localhost.key.pem`
|
||||
},
|
||||
// listening Host or IP
|
||||
// If omitted listens on every IP. ("0.0.0.0" and "::")
|
||||
// listeningHost: 'localhost',
|
||||
// Listening port for https server.
|
||||
listeningPort : 443,
|
||||
// Any http request is redirected to https.
|
||||
// Listening port for http server.
|
||||
listeningRedirectPort : 80,
|
||||
// Listens only on http, only on listeningPort
|
||||
// listeningRedirectPort disabled
|
||||
// use case: loadbalancer backend
|
||||
httpOnly : false,
|
||||
// WebServer/Express trust proxy config for httpOnly mode
|
||||
// You can find more info:
|
||||
// - https://expressjs.com/en/guide/behind-proxies.html
|
||||
// - https://www.npmjs.com/package/proxy-addr
|
||||
// use case: loadbalancer backend
|
||||
trustProxy : '',
|
||||
// This logger class will have the log function
|
||||
// called every time there is a room created or destroyed,
|
||||
// or peer created or destroyed. This would then be able
|
||||
|
|
@ -345,135 +292,5 @@ module.exports =
|
|||
// that are allowed because of this rule will not be able to do this
|
||||
// action as soon as a peer with the permission joins. In this example
|
||||
// everyone will be able to lock/unlock room until a MODERATOR joins.
|
||||
allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ],
|
||||
// When true, the room will be open to all users as long as there
|
||||
// are allready users in the room
|
||||
activateOnHostJoin : true,
|
||||
// roomsUnlocked is an array of rooms users can enter without waiting
|
||||
// in the lobby. If the array is undefined or null, users can enter
|
||||
// any room without waiting in the lobby. This is the default. The
|
||||
// aim of roomsUnlocked is to enforce moderated access to all rooms
|
||||
// with the exception of the rooms defined in the array.
|
||||
// roomsUnlocked : [ 'unlocked1', 'unlocked2', 'unlocked3' ],
|
||||
// When set, maxUsersPerRoom defines how many users can join
|
||||
// a single room. If not set, there is no limit.
|
||||
// maxUsersPerRoom : 20,
|
||||
// Room size before spreading to new router
|
||||
routerScaleSize : 40,
|
||||
// Socket timeout value
|
||||
requestTimeout : 20000,
|
||||
// Socket retries when timeout
|
||||
requestRetries : 3,
|
||||
// If > 0, sets a cache-control max-age (in seconds) to static files responses.
|
||||
staticFilesCachePeriod : 0,
|
||||
// Mediasoup settings
|
||||
mediasoup :
|
||||
{
|
||||
numWorkers : 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 :
|
||||
[
|
||||
// change 192.0.2.1 IPv4 to your server's IPv4 address!!
|
||||
{ ip: '192.0.2.1', announcedIp: null }
|
||||
|
||||
// Can have multiple listening interfaces
|
||||
// change 2001:DB8::1 IPv6 to your server's IPv6 address!!
|
||||
// { ip: '2001:DB8::1', announcedIp: null }
|
||||
],
|
||||
initialAvailableOutgoingBitrate : 1000000,
|
||||
minimumAvailableOutgoingBitrate : 600000,
|
||||
// Additional options that are not part of WebRtcTransportOptions.
|
||||
maxIncomingBitrate : 1500000
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
,
|
||||
// Prometheus exporter
|
||||
prometheus : {
|
||||
deidentify : false, // deidentify IP addresses
|
||||
// listen : 'localhost', // exporter listens on this address
|
||||
numeric : true, // show numeric IP addresses
|
||||
port : 8889, // allocated port
|
||||
quiet : false // include fewer labels
|
||||
// aggregated metrics options
|
||||
period : 15 // update period (seconds)
|
||||
secret : null // if set, checks the authorization header: `Bearer <secret>`
|
||||
}
|
||||
*/
|
||||
allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ]
|
||||
};
|
||||
|
|
|
|||
59
server/gen-config-docs.ts
Normal file
59
server/gen-config-docs.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { configDocs } from './lib/config';
|
||||
import { writeFile } from 'fs/promises';
|
||||
|
||||
function formatJson(data)
|
||||
{
|
||||
return `\`${data.replace(/\n/g, '')}\``;
|
||||
}
|
||||
|
||||
let data = `# Edumeet Server Configuration
|
||||
|
||||
The server configuration file can use one of the following formats:
|
||||
|
||||
- config/config.json
|
||||
- config/config.json5
|
||||
- config/config.yaml
|
||||
- config/config.yml
|
||||
- config/config.toml
|
||||
|
||||
Example \`config.yaml\`:
|
||||
|
||||
\`\`\`yaml
|
||||
redisOptions:
|
||||
host: redis
|
||||
port: 6379
|
||||
|
||||
listeningPort: 3443
|
||||
\`\`\`
|
||||
|
||||
Additionally, a \`config/config.js\` can be used to override specific properties
|
||||
with runtime generated values and to set additional configuration functions and classes.
|
||||
Look at the default \`config/config.example.js\` file for documentation.
|
||||
|
||||
## Configuration properties
|
||||
|
||||
| Name | Description | Format | Default value |
|
||||
| :--- | :---------- | :----- | :------------ |
|
||||
`;
|
||||
|
||||
Object.entries(configDocs).forEach((entry: [string, any]) =>
|
||||
{
|
||||
const [ name, value ] = entry;
|
||||
|
||||
data += `| ${name} | ${value.doc} | ${formatJson(value.format)} | \`${formatJson(value.default)}\` |\n`;
|
||||
});
|
||||
|
||||
data += `
|
||||
|
||||
---
|
||||
|
||||
*Document generated with:* \`yarn gen-config-docs\`
|
||||
`;
|
||||
|
||||
writeFile('README.md', data).then(() =>
|
||||
{
|
||||
console.log('done'); // eslint-disable-line
|
||||
}, (err) =>
|
||||
{
|
||||
console.error(`Error writing file: ${err.message}`); // eslint-disable-line
|
||||
});
|
||||
|
|
@ -29,7 +29,7 @@ const permissions = require('../permissions'), {
|
|||
MODERATE_ROOM
|
||||
} = permissions;
|
||||
|
||||
const config = require('../config/config');
|
||||
const { config } = require('./config');
|
||||
|
||||
const logger = new Logger('Room');
|
||||
|
||||
|
|
@ -263,7 +263,7 @@ class Room extends EventEmitter
|
|||
this._queue = new AwaitQueue();
|
||||
|
||||
// Locked flag.
|
||||
this._locked = config.roomsUnlocked && Array.isArray(config.roomsUnlocked)
|
||||
this._locked = config.roomsUnlocked.length
|
||||
&& !config.roomsUnlocked.includes(roomId);
|
||||
|
||||
// if true: accessCode is a possibility to open the room
|
||||
|
|
@ -395,7 +395,7 @@ class Room extends EventEmitter
|
|||
else if (this._hasAccess(peer, BYPASS_ROOM_LOCK))
|
||||
this._peerJoining(peer);
|
||||
else if (
|
||||
'maxUsersPerRoom' in config &&
|
||||
config.maxUsersPerRoom &&
|
||||
(
|
||||
Object.keys(this._peers).length +
|
||||
this._lobby.peerList().length
|
||||
|
|
@ -674,7 +674,7 @@ class Room extends EventEmitter
|
|||
|
||||
let turnServers;
|
||||
|
||||
if ('turnAPIURI' in config)
|
||||
if (config.turnAPIURI)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -697,13 +697,13 @@ class Room extends EventEmitter
|
|||
}
|
||||
catch (error)
|
||||
{
|
||||
if ('backupTurnServers' in config)
|
||||
if (config.backupTurnServers)
|
||||
turnServers = config.backupTurnServers;
|
||||
|
||||
logger.error('_peerJoining() | error on REST turn [error:"%o"]', error);
|
||||
}
|
||||
}
|
||||
else if ('backupTurnServers' in config)
|
||||
else if (config.backupTurnServers)
|
||||
{
|
||||
turnServers = config.backupTurnServers;
|
||||
}
|
||||
|
|
|
|||
546
server/lib/config.ts
Normal file
546
server/lib/config.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import * as fs from 'fs';
|
||||
import convict from 'convict';
|
||||
import { ipaddress, url } from 'convict-format-with-validator';
|
||||
import json5 from 'json5';
|
||||
import yaml from 'yaml';
|
||||
import toml from 'toml';
|
||||
import { cpus } from 'os';
|
||||
|
||||
import Logger from './Logger';
|
||||
|
||||
import * as userRoles from '../userRoles';
|
||||
import {
|
||||
BYPASS_ROOM_LOCK,
|
||||
BYPASS_LOBBY
|
||||
} from '../access';
|
||||
import {
|
||||
CHANGE_ROOM_LOCK,
|
||||
PROMOTE_PEER,
|
||||
MODIFY_ROLE,
|
||||
SEND_CHAT,
|
||||
MODERATE_CHAT,
|
||||
SHARE_AUDIO,
|
||||
SHARE_VIDEO,
|
||||
SHARE_SCREEN,
|
||||
EXTRA_VIDEO,
|
||||
SHARE_FILE,
|
||||
MODERATE_FILES,
|
||||
MODERATE_ROOM
|
||||
} from '../permissions';
|
||||
|
||||
const logger = new Logger('config');
|
||||
|
||||
// add parsers
|
||||
convict.addParser([
|
||||
{ extension: 'json', parse: JSON.parse },
|
||||
{ extension: 'json5', parse: json5.parse },
|
||||
{ extension: [ 'yml', 'yaml' ], parse: yaml.parse },
|
||||
{ extension: 'toml', parse: toml.parse }
|
||||
]);
|
||||
|
||||
// add formats
|
||||
function assert(assertion: Boolean, msg: string)
|
||||
{
|
||||
if (!assertion)
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const isFloat = {
|
||||
name : 'float',
|
||||
coerce : (v: string) => parseFloat(v),
|
||||
validate : (v: number) => assert(Number.isFinite(v), 'must be a number')
|
||||
};
|
||||
|
||||
convict.addFormats({ ipaddress, url, isFloat });
|
||||
|
||||
// config schema
|
||||
const configSchema = convict({
|
||||
turnAPIKey :
|
||||
{
|
||||
doc : 'TURN server key for requesting a geoip-based TURN server closest to the client.',
|
||||
format : String,
|
||||
default : ''
|
||||
},
|
||||
turnAPIURI :
|
||||
{
|
||||
doc : 'TURN server URL for requesting a geoip-based TURN server closest to the client.',
|
||||
format : String,
|
||||
default : ''
|
||||
},
|
||||
turnAPIparams :
|
||||
{
|
||||
'uri_schema' : {
|
||||
doc : 'TURN server URL schema.',
|
||||
format : String,
|
||||
default : 'turn'
|
||||
},
|
||||
'transport' : {
|
||||
doc : 'TURN server transport.',
|
||||
format : [ 'tcp', 'udp' ],
|
||||
default : 'tcp'
|
||||
},
|
||||
'ip_ver' : {
|
||||
doc : 'TURN server IP version.',
|
||||
format : [ 'ipv4', 'ipv6' ],
|
||||
default : 'ipv4'
|
||||
},
|
||||
'servercount' : {
|
||||
doc : 'TURN server count.',
|
||||
format : 'nat',
|
||||
default : 2
|
||||
}
|
||||
},
|
||||
turnAPITimeout : {
|
||||
doc : 'TURN server API timeout (seconds).',
|
||||
format : 'nat',
|
||||
default : 2 * 1000
|
||||
},
|
||||
backupTurnServers : {
|
||||
doc : 'Backup TURN servers if REST fails or is not configured',
|
||||
format : '*',
|
||||
default : [
|
||||
{
|
||||
urls : [
|
||||
'turn:turn.example.com:443?transport=tcp'
|
||||
],
|
||||
username : 'example',
|
||||
credential : 'example'
|
||||
}
|
||||
]
|
||||
},
|
||||
fileTracker : {
|
||||
doc : 'Bittorrent tracker.',
|
||||
format : String,
|
||||
default : 'wss://tracker.lab.vvc.niif.hu:443'
|
||||
},
|
||||
redisOptions : {
|
||||
host : {
|
||||
doc : 'Redis server host.',
|
||||
format : String,
|
||||
default : 'localhost'
|
||||
},
|
||||
port : {
|
||||
doc : 'Redis server port.',
|
||||
format : 'port',
|
||||
default : 6379
|
||||
},
|
||||
password : {
|
||||
doc : 'Redis server password.',
|
||||
format : String,
|
||||
default : ''
|
||||
}
|
||||
},
|
||||
cookieSecret : {
|
||||
doc : 'Session cookie secret.',
|
||||
format : String,
|
||||
default : 'T0P-S3cR3t_cook!e'
|
||||
},
|
||||
cookieName : {
|
||||
doc : 'Session cookie name.',
|
||||
format : String,
|
||||
default : 'edumeet.sid'
|
||||
},
|
||||
tls : {
|
||||
cert : {
|
||||
doc : 'SSL certificate path.',
|
||||
format : String,
|
||||
default : './certs/mediasoup-demo.localhost.cert.pem'
|
||||
},
|
||||
key : {
|
||||
doc : 'SSL key path.',
|
||||
format : String,
|
||||
default : './certs/mediasoup-demo.localhost.key.pem'
|
||||
}
|
||||
},
|
||||
listeningHost : {
|
||||
doc : 'The listening Host or IP address.',
|
||||
format : String,
|
||||
default : '0.0.0.0'
|
||||
},
|
||||
listeningPort : {
|
||||
doc : 'The HTTPS listening port.',
|
||||
format : 'port',
|
||||
default : 8443
|
||||
},
|
||||
listeningRedirectPort : {
|
||||
doc : 'The HTTP server listening port used for redirecting any HTTP request to HTTPS. If 0, the redirect server is disabled.',
|
||||
format : 'port',
|
||||
default : 8080
|
||||
},
|
||||
httpOnly : {
|
||||
doc : 'Listens only on HTTP on listeningPort; listeningRedirectPort disabled. Use case: load balancer backend.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
trustProxy : {
|
||||
doc : 'WebServer/Express trust proxy config for httpOnly mode. More infos: [expressjs](https://expressjs.com/en/guide/behind-proxies.html), [proxy-addr](https://www.npmjs.com/package/proxy-addr)',
|
||||
format : String,
|
||||
default : ''
|
||||
},
|
||||
staticFilesCachePeriod : {
|
||||
doc : 'The max-age in milliseconds for HTTP caching of static resources. This can also be a string accepted by the [ms module](https://www.npmjs.com/package/ms#readme).',
|
||||
format : '*',
|
||||
default : 0
|
||||
},
|
||||
activateOnHostJoin : {
|
||||
doc : 'When true, the room will be open to all users since there are users in the room.',
|
||||
format : 'Boolean',
|
||||
default : true
|
||||
},
|
||||
roomsUnlocked : {
|
||||
doc : 'An array of rooms users can enter without waiting in the lobby.',
|
||||
format : Array,
|
||||
default : []
|
||||
},
|
||||
maxUsersPerRoom : {
|
||||
doc : 'It defines how many users can join a single room. If not set, no limit is applied.',
|
||||
format : 'nat',
|
||||
default : 0
|
||||
},
|
||||
routerScaleSize : {
|
||||
doc : 'Room size before spreading to a new router.',
|
||||
format : 'nat',
|
||||
default : 40
|
||||
},
|
||||
requestTimeout : {
|
||||
doc : 'Socket timeout value (ms).',
|
||||
format : 'nat',
|
||||
default : 20000
|
||||
},
|
||||
requestRetries : {
|
||||
doc : 'Socket retries when a timeout occurs.',
|
||||
format : 'nat',
|
||||
default : 3
|
||||
},
|
||||
// Mediasoup settings
|
||||
mediasoup :
|
||||
{
|
||||
numWorkers : {
|
||||
doc : 'The number of Mediasoup workers to spawn. Defaults to the available CPUs count.',
|
||||
format : 'nat',
|
||||
default : Object.keys(cpus()).length
|
||||
},
|
||||
worker :
|
||||
{
|
||||
logLevel : {
|
||||
doc : 'The Mediasoup log level.',
|
||||
format : String,
|
||||
default : 'warn'
|
||||
},
|
||||
logTags : {
|
||||
doc : 'The Mediasoup log tags.',
|
||||
format : Array,
|
||||
default : [
|
||||
'info',
|
||||
'ice',
|
||||
'dtls',
|
||||
'rtp',
|
||||
'srtp',
|
||||
'rtcp'
|
||||
]
|
||||
},
|
||||
rtcMinPort : {
|
||||
doc : 'The Mediasoup start listening port number.',
|
||||
format : 'port',
|
||||
default : 40000
|
||||
},
|
||||
rtcMaxPort : {
|
||||
doc : 'The Mediasoup end listening port number.',
|
||||
format : 'port',
|
||||
default : 49999
|
||||
}
|
||||
},
|
||||
// mediasoup Router settings.
|
||||
router :
|
||||
{
|
||||
// Router media codecs.
|
||||
mediaCodecs : {
|
||||
doc : 'The Mediasoup codecs settings. [supportedRtpCapabilities](https://github.com/versatica/mediasoup/blob/v3/src/supportedRtpCapabilities.ts)',
|
||||
format : '*',
|
||||
default :
|
||||
[
|
||||
{
|
||||
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 : {
|
||||
doc : 'The Mediasoup listen IPs. [TransportListenIp](https://mediasoup.org/documentation/v3/mediasoup/api/#TransportListenIp)',
|
||||
format : Array,
|
||||
default : [
|
||||
{ ip: '0.0.0.0', announcedIp: null }
|
||||
]
|
||||
},
|
||||
initialAvailableOutgoingBitrate : {
|
||||
doc : 'The Mediasoup initial available outgoing bitrate (in bps). [WebRtcTransportOptions](https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions)',
|
||||
format : 'nat',
|
||||
default : 1000000
|
||||
},
|
||||
maxIncomingBitrate : {
|
||||
doc : 'The Mediasoup maximum incoming bitrate for each transport. (in bps). [setMaxIncomingBitrate](https://mediasoup.org/documentation/v3/mediasoup/api/#transport-setMaxIncomingBitrate)',
|
||||
format : 'nat',
|
||||
default : 1500000
|
||||
}
|
||||
}
|
||||
},
|
||||
// Prometheus exporter
|
||||
prometheus : {
|
||||
enabled : {
|
||||
doc : 'Enables the Prometheus metrics exporter.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
listen : {
|
||||
doc : 'Prometheus metrics exporter listening address.',
|
||||
format : 'String',
|
||||
default : 'localhost'
|
||||
},
|
||||
port : {
|
||||
doc : 'The Prometheus metrics exporter listening port.',
|
||||
format : 'port',
|
||||
default : 8889
|
||||
},
|
||||
// default metrics options
|
||||
deidentify : {
|
||||
doc : 'De-identify IP addresses in Prometheus logs.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
numeric : {
|
||||
doc : 'Show numeric IP addresses in Prometheus logs.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
quiet : {
|
||||
doc : 'Include fewer labels in Prometheus metrics.',
|
||||
format : 'Boolean',
|
||||
default : false
|
||||
},
|
||||
// aggregated metrics options
|
||||
period : {
|
||||
doc : 'The Prometheus metrics exporter update period (seconds).',
|
||||
format : 'nat',
|
||||
default : 15
|
||||
},
|
||||
secret : {
|
||||
doc : 'The Prometheus metrics exporter authorization header: `Bearer <secret>` required to allow scraping.',
|
||||
format : String,
|
||||
default : ''
|
||||
}
|
||||
},
|
||||
// User roles
|
||||
// All users have the role "NORMAL" by default. Other roles need to be
|
||||
// added in the "userMapping" function. The following accesses and
|
||||
// permissions are arrays of roles. Roles can be changed in userRoles.js
|
||||
//
|
||||
// Example:
|
||||
// [ userRoles.MODERATOR, userRoles.AUTHENTICATED ]
|
||||
accessFromRoles : {
|
||||
doc : 'User roles.',
|
||||
format : '*',
|
||||
default : {
|
||||
// The role(s) will gain access to the room
|
||||
// even if it is locked (!)
|
||||
[BYPASS_ROOM_LOCK] : [ userRoles.ADMIN ],
|
||||
// The role(s) will gain access to the room without
|
||||
// going into the lobby. If you want to restrict access to your
|
||||
// server to only directly allow authenticated users, you could
|
||||
// add the userRoles.AUTHENTICATED to the user in the userMapping
|
||||
// function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ]
|
||||
[BYPASS_LOBBY] : [ userRoles.NORMAL ]
|
||||
}
|
||||
},
|
||||
permissionsFromRoles : {
|
||||
doc : 'User permissions from roles.',
|
||||
format : '*',
|
||||
default : {
|
||||
// The role(s) have permission to lock/unlock a room
|
||||
[CHANGE_ROOM_LOCK] : [ userRoles.MODERATOR ],
|
||||
// The role(s) have permission to promote a peer from the lobby
|
||||
[PROMOTE_PEER] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to give/remove other peers roles
|
||||
[MODIFY_ROLE] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to send chat messages
|
||||
[SEND_CHAT] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to moderate chat
|
||||
[MODERATE_CHAT] : [ userRoles.MODERATOR ],
|
||||
// The role(s) have permission to share audio
|
||||
[SHARE_AUDIO] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to share video
|
||||
[SHARE_VIDEO] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to share screen
|
||||
[SHARE_SCREEN] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to produce extra video
|
||||
[EXTRA_VIDEO] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to share files
|
||||
[SHARE_FILE] : [ userRoles.NORMAL ],
|
||||
// The role(s) have permission to moderate files
|
||||
[MODERATE_FILES] : [ userRoles.MODERATOR ],
|
||||
// The role(s) have permission to moderate room (e.g. kick user)
|
||||
[MODERATE_ROOM] : [ userRoles.MODERATOR ]
|
||||
}
|
||||
},
|
||||
// Array of permissions. If no peer with the permission in question
|
||||
// is in the room, all peers are permitted to do the action. The peers
|
||||
// that are allowed because of this rule will not be able to do this
|
||||
// action as soon as a peer with the permission joins. In this example
|
||||
// everyone will be able to lock/unlock room until a MODERATOR joins.
|
||||
allowWhenRoleMissing : {
|
||||
doc : 'Allow when role missing.',
|
||||
format : Array,
|
||||
default : [ CHANGE_ROOM_LOCK ]
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Formats the schema documentation, calling the same function recursively.
|
||||
* @param docs the documentation object to extend
|
||||
* @param property the root property
|
||||
* @param schema the config schema fragment
|
||||
* @returns the documentation object
|
||||
*/
|
||||
function formatDocs(docs: any, property: string | null, schema: any)
|
||||
{
|
||||
if (schema._cvtProperties)
|
||||
{
|
||||
Object.entries(schema._cvtProperties).forEach(([ name, value ]) =>
|
||||
{
|
||||
formatDocs(docs, `${property ? `${property}.` : ''}${name}`, value);
|
||||
});
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
if (property)
|
||||
{
|
||||
docs[property] = // eslint-disable-line no-param-reassign
|
||||
{
|
||||
doc : schema.doc,
|
||||
format : JSON.stringify(schema.format, null, 2),
|
||||
default : JSON.stringify(schema.default, null, 2)
|
||||
};
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
// format docs
|
||||
const configDocs = formatDocs({}, null, configSchema.getSchema());
|
||||
|
||||
let config: any = {};
|
||||
let configError = '';
|
||||
let configLoaded = false;
|
||||
|
||||
// Load config from file
|
||||
for (const format of [ 'json', 'json5', 'yaml', 'yml', 'toml' ]) // eslint-disable-line no-restricted-syntax
|
||||
{
|
||||
const filepath = `./config/config.${format}`;
|
||||
|
||||
if (fs.existsSync(filepath))
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.debug(`Loading config from ${filepath}`);
|
||||
configSchema.loadFile(filepath);
|
||||
configLoaded = true;
|
||||
break;
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.debug(`Loading config from ${filepath} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configLoaded)
|
||||
{
|
||||
logger.warn('No config file found, using defaults.');
|
||||
configSchema.load({});
|
||||
}
|
||||
|
||||
// Perform validation
|
||||
try
|
||||
{
|
||||
configSchema.validate({ allowed: 'strict' });
|
||||
config = configSchema.getProperties();
|
||||
}
|
||||
catch (error: any)
|
||||
{
|
||||
configError = error.message;
|
||||
}
|
||||
|
||||
// load additional config module (no validation is performed)
|
||||
if (fs.existsSync(`${__dirname}/../config/config.js`))
|
||||
{
|
||||
try
|
||||
{
|
||||
const configModule = require('../config/config.js'); // eslint-disable-line @typescript-eslint/no-var-requires
|
||||
|
||||
Object.assign(config, configModule);
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.error(`Error loading ${config.configFile} module: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
// logger.debug('Using config:', config);
|
||||
|
||||
//
|
||||
export {
|
||||
configSchema,
|
||||
config,
|
||||
configError,
|
||||
configDocs
|
||||
};
|
||||
|
|
@ -9,83 +9,88 @@ const logger = new Logger('metrics:aggregated');
|
|||
//
|
||||
module.exports = function(workers, rooms_, peers_, config)
|
||||
{
|
||||
const register = new promClient.Registry();
|
||||
promClient.collectDefaultMetrics({ prefix: 'mediasoup_', register });
|
||||
|
||||
const mediasoupStats = {};
|
||||
let mediasoupStatsUpdate = 0;
|
||||
const register = new promClient.Registry();
|
||||
|
||||
const formatStats = (s) =>
|
||||
{
|
||||
return {
|
||||
length: s.length || 0,
|
||||
sum: s.sum || 0,
|
||||
mean: s.amean() || 0,
|
||||
stddev: s.stddev() || 0,
|
||||
p25: s.percentile(25) || 0,
|
||||
min: s.min || 0,
|
||||
max: s.max || 0,
|
||||
};
|
||||
};
|
||||
promClient.collectDefaultMetrics({ prefix: 'mediasoup_', register });
|
||||
|
||||
const collectStats = async () =>
|
||||
{
|
||||
const now = Date.now();
|
||||
const mediasoupStats = {};
|
||||
let mediasoupStatsUpdate = 0;
|
||||
|
||||
if (now - mediasoupStatsUpdate < config.period * 1000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mediasoupStatsUpdate = now;
|
||||
const formatStats = (s) =>
|
||||
{
|
||||
return {
|
||||
length : s.length || 0,
|
||||
sum : s.sum || 0,
|
||||
mean : s.amean() || 0,
|
||||
stddev : s.stddev() || 0,
|
||||
p25 : s.percentile(25) || 0,
|
||||
min : s.min || 0,
|
||||
max : s.max || 0
|
||||
};
|
||||
};
|
||||
|
||||
const start = process.hrtime();
|
||||
const collectStats = async () =>
|
||||
{
|
||||
const now = Date.now();
|
||||
|
||||
let workers_cpu = new Stats();
|
||||
let workers_memory = new Stats();
|
||||
if (now - mediasoupStatsUpdate < config.period * 1000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mediasoupStatsUpdate = now;
|
||||
|
||||
let rooms = new Stats();
|
||||
rooms.push(rooms_.size);
|
||||
const start = process.hrtime();
|
||||
|
||||
let peers = new Stats();
|
||||
peers.push(peers_.size);
|
||||
const workersCpu = new Stats();
|
||||
const workersMemory = new Stats();
|
||||
|
||||
// in
|
||||
let video_bitrates_in = new Stats();
|
||||
let audio_bitrates_in = new Stats();
|
||||
let video_scores_in = new Stats();
|
||||
let audio_scores_in = new Stats();
|
||||
const rooms = new Stats();
|
||||
|
||||
let packets_counts_in = new Stats();
|
||||
let packets_losts_in = new Stats();
|
||||
let packets_retransmitted_in = new Stats();
|
||||
rooms.push(rooms_.size);
|
||||
|
||||
// out
|
||||
let video_bitrates_out = new Stats();
|
||||
let audio_bitrates_out = new Stats();
|
||||
const peers = new Stats();
|
||||
|
||||
let round_trip_times_out = new Stats();
|
||||
let packets_counts_out = new Stats();
|
||||
let packets_losts_out = new Stats();
|
||||
peers.push(peers_.size);
|
||||
|
||||
let spatial_layers_out = new Stats();
|
||||
let temporal_layers_out = new Stats();
|
||||
// in
|
||||
const videoBitratesIn = new Stats();
|
||||
const audioBitratesIn = new Stats();
|
||||
const videoScoresIn = new Stats();
|
||||
const audioScoresIn = new Stats();
|
||||
|
||||
try {
|
||||
// iterate workers
|
||||
for (const worker of workers.values())
|
||||
{
|
||||
// worker process stats
|
||||
const workerStats = await pidusage(worker._pid);
|
||||
workers_cpu.push(workerStats.cpu / 100);
|
||||
workers_memory.push(workerStats.memory);
|
||||
|
||||
// iterate routers
|
||||
for (const router of worker._routers.values())
|
||||
{
|
||||
// iterate transports
|
||||
for (const transport of router._transports.values())
|
||||
{
|
||||
/* let stats = [];
|
||||
const packetsCountsIn = new Stats();
|
||||
const packetsLostsIn = new Stats();
|
||||
const packetsRetransmittedIn = new Stats();
|
||||
|
||||
// out
|
||||
const videoBitratesOut = new Stats();
|
||||
const audioBitratesOut = new Stats();
|
||||
|
||||
const roundTripTimesOut = new Stats();
|
||||
const packetsCountsOut = new Stats();
|
||||
const packetsLostsOut = new Stats();
|
||||
|
||||
const spatialLayersOut = new Stats();
|
||||
const temporalLayersOut = new Stats();
|
||||
|
||||
try
|
||||
{
|
||||
// iterate workers
|
||||
for (const worker of workers)
|
||||
{
|
||||
// worker process stats
|
||||
const workerStats = await pidusage(worker.pid);
|
||||
|
||||
workersCpu.push(workerStats.cpu / 100);
|
||||
workersMemory.push(workerStats.memory);
|
||||
|
||||
// iterate routers
|
||||
for (const router of worker._routers.values())
|
||||
{
|
||||
// iterate transports
|
||||
for (const transport of router._transports.values())
|
||||
{
|
||||
/* let stats = [];
|
||||
try
|
||||
{
|
||||
stats = await transport.getStats();
|
||||
|
|
@ -102,212 +107,225 @@ module.exports = function(workers, rooms_, peers_, config)
|
|||
}
|
||||
} */
|
||||
|
||||
// iterate producers
|
||||
for (const producer of transport._producers.values())
|
||||
{
|
||||
let stats = [];
|
||||
try
|
||||
{
|
||||
stats = await producer.getStats();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
logger.error('producer.getStats error:', err.message);
|
||||
continue;
|
||||
}
|
||||
for (const s of stats)
|
||||
{
|
||||
if (s.type !== 'inbound-rtp')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (s.kind === 'video')
|
||||
{
|
||||
video_bitrates_in.push(s.bitrate);
|
||||
video_scores_in.push(s.score);
|
||||
}
|
||||
else if (s.kind === 'audio')
|
||||
{
|
||||
audio_bitrates_in.push(s.bitrate);
|
||||
audio_scores_in.push(s.score);
|
||||
}
|
||||
packets_counts_in.push(s.packetCount || 0);
|
||||
packets_losts_in.push(s.packetsLost || 0);
|
||||
packets_retransmitted_in.push(s.packetsRetransmitted || 0);
|
||||
}
|
||||
}
|
||||
// iterate producers
|
||||
for (const producer of transport._producers.values())
|
||||
{
|
||||
let stats = [];
|
||||
|
||||
// iterate consumers
|
||||
for (const consumer of transport._consumers.values())
|
||||
{
|
||||
if (consumer.type === 'pipe')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let stats = [];
|
||||
try
|
||||
{
|
||||
stats = await consumer.getStats();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
logger.error('consumer.getStats error:', err.message);
|
||||
continue;
|
||||
}
|
||||
for (const s of stats)
|
||||
{
|
||||
if(s.type !== 'outbound-rtp'){
|
||||
continue;
|
||||
}
|
||||
if (s.kind === 'video')
|
||||
{
|
||||
video_bitrates_out.push(s.bitrate || 0);
|
||||
spatial_layers_out.push(consumer.currentLayers ? consumer.currentLayers.spatialLayer : 0);
|
||||
temporal_layers_out.push(consumer.currentLayers ? consumer.currentLayers.temporalLayer : 0);
|
||||
}
|
||||
else if(s.kind === 'audio')
|
||||
{
|
||||
audio_bitrates_out.push(s.bitrate || 0);
|
||||
}
|
||||
round_trip_times_out.push(s.roundTripTime || 0);
|
||||
packets_counts_out.push(s.packetCount || 0);
|
||||
packets_losts_out.push(s.packetsLost || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
logger.error('collectStats error:', err.message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.assign(mediasoupStats, {
|
||||
workers_cpu: formatStats(workers_cpu),
|
||||
workers_memory: formatStats(workers_memory),
|
||||
rooms: formatStats(rooms),
|
||||
peers: formatStats(peers),
|
||||
// in
|
||||
video_bitrates_in: formatStats(video_bitrates_in),
|
||||
video_scores_in: formatStats(video_scores_in),
|
||||
audio_bitrates_in: formatStats(audio_bitrates_in),
|
||||
audio_scores_in: formatStats(audio_scores_in),
|
||||
packets_counts_in: formatStats(packets_counts_in),
|
||||
packets_losts_in: formatStats(packets_losts_in),
|
||||
packets_retransmitted_in: formatStats(packets_retransmitted_in),
|
||||
// out
|
||||
video_bitrates_out: formatStats(video_bitrates_out),
|
||||
audio_bitrates_out: formatStats(audio_bitrates_out),
|
||||
round_trip_times_out: formatStats(round_trip_times_out),
|
||||
packets_counts_out: formatStats(packets_counts_out),
|
||||
packets_losts_out: formatStats(packets_losts_out),
|
||||
spatial_layers_out: formatStats(spatial_layers_out),
|
||||
temporal_layers_out: formatStats(temporal_layers_out),
|
||||
});
|
||||
}
|
||||
try
|
||||
{
|
||||
stats = await producer.getStats();
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.error('producer.getStats error:', err.message);
|
||||
continue;
|
||||
}
|
||||
for (const s of stats)
|
||||
{
|
||||
if (s.type !== 'inbound-rtp')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (s.kind === 'video')
|
||||
{
|
||||
videoBitratesIn.push(s.bitrate);
|
||||
videoScoresIn.push(s.score);
|
||||
}
|
||||
else if (s.kind === 'audio')
|
||||
{
|
||||
audioBitratesIn.push(s.bitrate);
|
||||
audioScoresIn.push(s.score);
|
||||
}
|
||||
packetsCountsIn.push(s.packetCount || 0);
|
||||
packetsLostsIn.push(s.packetsLost || 0);
|
||||
packetsRetransmittedIn.push(s.packetsRetransmitted || 0);
|
||||
}
|
||||
}
|
||||
|
||||
const end = process.hrtime(start);
|
||||
logger.info(`collectStats (elapsed: ${end[0] * 1e3 + end[1] * 1e-6} ms)`);
|
||||
}
|
||||
// iterate consumers
|
||||
for (const consumer of transport._consumers.values())
|
||||
{
|
||||
if (consumer.type === 'pipe')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let stats = [];
|
||||
|
||||
// mediasoup metrics
|
||||
[
|
||||
{ name: 'workers_count', statName: 'workers_cpu', statValue: 'length' },
|
||||
{ name: 'workers_cpu', statName: 'workers_cpu', statValue: 'sum' },
|
||||
{ name: 'workers_memory', statName: 'workers_memory', statValue: 'sum' },
|
||||
//
|
||||
{ name: 'rooms', statName: 'rooms', statValue: 'sum' },
|
||||
{ name: 'peers', statName: 'peers', statValue: 'sum' },
|
||||
// audio in
|
||||
{ name: 'audio_in_count', statName: 'audio_bitrates_in', statValue: 'length' },
|
||||
// audio in bitrates
|
||||
{ name: 'audio_bitrates_in_sum', statName: 'audio_bitrates_in', statValue: 'sum' },
|
||||
{ name: 'audio_bitrates_in_mean', statName: 'audio_bitrates_in', statValue: 'mean' },
|
||||
{ name: 'audio_bitrates_in_min', statName: 'audio_bitrates_in', statValue: 'min' },
|
||||
{ name: 'audio_bitrates_in_max', statName: 'audio_bitrates_in', statValue: 'max' },
|
||||
{ name: 'audio_bitrates_in_p25', statName: 'audio_bitrates_in', statValue: 'p25' },
|
||||
// audio in scores
|
||||
{ name: 'audio_scores_in_mean', statName: 'audio_scores_in', statValue: 'mean' },
|
||||
{ name: 'audio_scores_in_min', statName: 'audio_scores_in', statValue: 'min' },
|
||||
{ name: 'audio_scores_in_max', statName: 'audio_scores_in', statValue: 'max' },
|
||||
{ name: 'audio_scores_in_p25', statName: 'audio_scores_in', statValue: 'p25' },
|
||||
// video in
|
||||
{ name: 'video_in_count', statName: 'video_bitrates_in', statValue: 'length' },
|
||||
// video in bitrates
|
||||
{ name: 'video_bitrates_in_sum', statName: 'video_bitrates_in', statValue: 'sum' },
|
||||
{ name: 'video_bitrates_in_mean', statName: 'video_bitrates_in', statValue: 'mean' },
|
||||
{ name: 'video_bitrates_in_min', statName: 'video_bitrates_in', statValue: 'min' },
|
||||
{ name: 'video_bitrates_in_max', statName: 'video_bitrates_in', statValue: 'max' },
|
||||
{ name: 'video_bitrates_in_p25', statName: 'video_bitrates_in', statValue: 'p25' },
|
||||
// video in scores
|
||||
{ name: 'video_scores_in_mean', statName: 'video_scores_in', statValue: 'mean' },
|
||||
{ name: 'video_scores_in_min', statName: 'video_scores_in', statValue: 'min' },
|
||||
{ name: 'video_scores_in_max', statName: 'video_scores_in', statValue: 'max' },
|
||||
{ name: 'video_scores_in_p25', statName: 'video_scores_in', statValue: 'p25' },
|
||||
// packets in
|
||||
{ name: 'packets_counts_in_sum', statName: 'packets_counts_in', statValue: 'sum' },
|
||||
{ name: 'packets_counts_in_mean', statName: 'packets_counts_in', statValue: 'mean' },
|
||||
{ name: 'packets_counts_in_min', statName: 'packets_counts_in', statValue: 'min' },
|
||||
{ name: 'packets_counts_in_max', statName: 'packets_counts_in', statValue: 'max' },
|
||||
{ name: 'packets_counts_in_p25', statName: 'packets_counts_in', statValue: 'p25' },
|
||||
{ name: 'packets_losts_in_sum', statName: 'packets_losts_in', statValue: 'sum' },
|
||||
{ name: 'packets_losts_in_mean', statName: 'packets_losts_in', statValue: 'mean' },
|
||||
{ name: 'packets_losts_in_min', statName: 'packets_losts_in', statValue: 'min' },
|
||||
{ name: 'packets_losts_in_max', statName: 'packets_losts_in', statValue: 'max' },
|
||||
{ name: 'packets_losts_in_p25', statName: 'packets_losts_in', statValue: 'p25' },
|
||||
{ name: 'packets_retransmitted_in_sum', statName: 'packets_retransmitted_in', statValue: 'sum' },
|
||||
{ name: 'packets_retransmitted_in_mean', statName: 'packets_retransmitted_in', statValue: 'mean' },
|
||||
{ name: 'packets_retransmitted_in_min', statName: 'packets_retransmitted_in', statValue: 'min' },
|
||||
{ name: 'packets_retransmitted_in_max', statName: 'packets_retransmitted_in', statValue: 'max' },
|
||||
{ name: 'packets_retransmitted_in_p25', statName: 'packets_retransmitted_in', statValue: 'p25' },
|
||||
// audio out
|
||||
{ name: 'audio_out_count', statName: 'audio_bitrates_out', statValue: 'length' },
|
||||
{ name: 'audio_bitrates_out_sum', statName: 'audio_bitrates_out', statValue: 'sum' },
|
||||
{ name: 'audio_bitrates_out_mean', statName: 'audio_bitrates_out', statValue: 'mean' },
|
||||
{ name: 'audio_bitrates_out_min', statName: 'audio_bitrates_out', statValue: 'min' },
|
||||
{ name: 'audio_bitrates_out_max', statName: 'audio_bitrates_out', statValue: 'max' },
|
||||
{ name: 'audio_bitrates_out_p25', statName: 'audio_bitrates_out', statValue: 'p25' },
|
||||
// video out
|
||||
{ name: 'video_out_count', statName: 'video_bitrates_out', statValue: 'length' },
|
||||
{ name: 'video_bitrates_out_sum', statName: 'video_bitrates_out', statValue: 'sum' },
|
||||
{ name: 'video_bitrates_out_mean', statName: 'video_bitrates_out', statValue: 'mean' },
|
||||
{ name: 'video_bitrates_out_min', statName: 'video_bitrates_out', statValue: 'min' },
|
||||
{ name: 'video_bitrates_out_max', statName: 'video_bitrates_out', statValue: 'max' },
|
||||
{ name: 'video_bitrates_out_p25', statName: 'video_bitrates_out', statValue: 'p25' },
|
||||
// sl
|
||||
{ name: 'spatial_layers_out_mean', statName: 'spatial_layers_out', statValue: 'mean' },
|
||||
{ name: 'spatial_layers_out_min', statName: 'spatial_layers_out', statValue: 'min' },
|
||||
{ name: 'spatial_layers_out_max', statName: 'spatial_layers_out', statValue: 'max' },
|
||||
{ name: 'spatial_layers_out_p25', statName: 'spatial_layers_out', statValue: 'p25' },
|
||||
// tl
|
||||
{ name: 'temporal_layers_out_mean', statName: 'temporal_layers_out', statValue: 'mean' },
|
||||
{ name: 'temporal_layers_out_min', statName: 'temporal_layers_out', statValue: 'min' },
|
||||
{ name: 'temporal_layers_out_max', statName: 'temporal_layers_out', statValue: 'max' },
|
||||
{ name: 'temporal_layers_out_p25', statName: 'temporal_layers_out', statValue: 'p25' },
|
||||
// rtt out
|
||||
{ name: 'round_trip_times_out_mean', statName: 'round_trip_times_out', statValue: 'mean' },
|
||||
{ name: 'round_trip_times_out_min', statName: 'round_trip_times_out', statValue: 'min' },
|
||||
{ name: 'round_trip_times_out_max', statName: 'round_trip_times_out', statValue: 'max' },
|
||||
{ name: 'round_trip_times_out_p25', statName: 'round_trip_times_out', statValue: 'p25' },
|
||||
try
|
||||
{
|
||||
stats = await consumer.getStats();
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.error('consumer.getStats error:', err.message);
|
||||
continue;
|
||||
}
|
||||
for (const s of stats)
|
||||
{
|
||||
if (s.type !== 'outbound-rtp')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (s.kind === 'video')
|
||||
{
|
||||
videoBitratesOut.push(s.bitrate || 0);
|
||||
spatialLayersOut.push(consumer.currentLayers
|
||||
? consumer.currentLayers.spatialLayer : 0);
|
||||
temporalLayersOut.push(consumer.currentLayers
|
||||
? consumer.currentLayers.temporalLayer : 0);
|
||||
}
|
||||
else if (s.kind === 'audio')
|
||||
{
|
||||
audioBitratesOut.push(s.bitrate || 0);
|
||||
}
|
||||
roundTripTimesOut.push(s.roundTripTime || 0);
|
||||
packetsCountsOut.push(s.packetCount || 0);
|
||||
packetsLostsOut.push(s.packetsLost || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.error('collectStats error:', err.message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.assign(mediasoupStats, {
|
||||
workersCpu : formatStats(workersCpu),
|
||||
workersMemory : formatStats(workersMemory),
|
||||
rooms : formatStats(rooms),
|
||||
peers : formatStats(peers),
|
||||
// in
|
||||
videoBitratesIn : formatStats(videoBitratesIn),
|
||||
videoScoresIn : formatStats(videoScoresIn),
|
||||
audioBitratesIn : formatStats(audioBitratesIn),
|
||||
audioScoresIn : formatStats(audioScoresIn),
|
||||
packetsCountsIn : formatStats(packetsCountsIn),
|
||||
packetsLostsIn : formatStats(packetsLostsIn),
|
||||
packetsRetransmittedIn : formatStats(packetsRetransmittedIn),
|
||||
// out
|
||||
videoBitratesOut : formatStats(videoBitratesOut),
|
||||
audioBitratesOut : formatStats(audioBitratesOut),
|
||||
roundTripTimesOut : formatStats(roundTripTimesOut),
|
||||
packetsCountsOut : formatStats(packetsCountsOut),
|
||||
packetsLostsOut : formatStats(packetsLostsOut),
|
||||
spatialLayersOut : formatStats(spatialLayersOut),
|
||||
temporalLayersOut : formatStats(temporalLayersOut)
|
||||
});
|
||||
}
|
||||
|
||||
].forEach(({ name, statName, statValue }) => {
|
||||
new promClient.Gauge({
|
||||
name: `mediasoup_${name}`,
|
||||
help: `MediaSoup ${name}`,
|
||||
labelNames: [],
|
||||
registers: [ register ],
|
||||
async collect()
|
||||
{
|
||||
await collectStats();
|
||||
if (mediasoupStats[statName] !== undefined && mediasoupStats[statName][statValue] !== undefined)
|
||||
{
|
||||
this.set({}, mediasoupStats[statName][statValue]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const end = process.hrtime(start);
|
||||
|
||||
return register;
|
||||
logger.info(`collectStats (elapsed: ${(end[0] * 1e3) + (end[1] * 1e-6)} ms)`);
|
||||
};
|
||||
|
||||
// mediasoup metrics
|
||||
[
|
||||
{ name: 'workers_count', statName: 'workersCpu', statValue: 'length' },
|
||||
{ name: 'workers_cpu', statName: 'workersCpu', statValue: 'sum' },
|
||||
{ name: 'workers_memory', statName: 'workersMemory', statValue: 'sum' },
|
||||
//
|
||||
{ name: 'rooms', statName: 'rooms', statValue: 'sum' },
|
||||
{ name: 'peers', statName: 'peers', statValue: 'sum' },
|
||||
// audio in
|
||||
{ name: 'audio_in_count', statName: 'audioBitratesIn', statValue: 'length' },
|
||||
// audio in bitrates
|
||||
{ name: 'audio_bitrates_in_sum', statName: 'audioBitratesIn', statValue: 'sum' },
|
||||
{ name: 'audio_bitrates_in_mean', statName: 'audioBitratesIn', statValue: 'mean' },
|
||||
{ name: 'audio_bitrates_in_min', statName: 'audioBitratesIn', statValue: 'min' },
|
||||
{ name: 'audio_bitrates_in_max', statName: 'audioBitratesIn', statValue: 'max' },
|
||||
{ name: 'audio_bitrates_in_p25', statName: 'audioBitratesIn', statValue: 'p25' },
|
||||
// audio in scores
|
||||
{ name: 'audio_scores_in_mean', statName: 'audioScoresIn', statValue: 'mean' },
|
||||
{ name: 'audio_scores_in_min', statName: 'audioScoresIn', statValue: 'min' },
|
||||
{ name: 'audio_scores_in_max', statName: 'audioScoresIn', statValue: 'max' },
|
||||
{ name: 'audio_scores_in_p25', statName: 'audioScoresIn', statValue: 'p25' },
|
||||
// video in
|
||||
{ name: 'video_in_count', statName: 'videoBitratesIn', statValue: 'length' },
|
||||
// video in bitrates
|
||||
{ name: 'video_bitrates_in_sum', statName: 'videoBitratesIn', statValue: 'sum' },
|
||||
{ name: 'video_bitrates_in_mean', statName: 'videoBitratesIn', statValue: 'mean' },
|
||||
{ name: 'video_bitrates_in_min', statName: 'videoBitratesIn', statValue: 'min' },
|
||||
{ name: 'video_bitrates_in_max', statName: 'videoBitratesIn', statValue: 'max' },
|
||||
{ name: 'video_bitrates_in_p25', statName: 'videoBitratesIn', statValue: 'p25' },
|
||||
// video in scores
|
||||
{ name: 'video_scores_in_mean', statName: 'videoScoresIn', statValue: 'mean' },
|
||||
{ name: 'video_scores_in_min', statName: 'videoScoresIn', statValue: 'min' },
|
||||
{ name: 'video_scores_in_max', statName: 'videoScoresIn', statValue: 'max' },
|
||||
{ name: 'video_scores_in_p25', statName: 'videoScoresIn', statValue: 'p25' },
|
||||
// packets in
|
||||
{ name: 'packets_counts_in_sum', statName: 'packetsCountsIn', statValue: 'sum' },
|
||||
{ name: 'packets_counts_in_mean', statName: 'packetsCountsIn', statValue: 'mean' },
|
||||
{ name: 'packets_counts_in_min', statName: 'packetsCountsIn', statValue: 'min' },
|
||||
{ name: 'packets_counts_in_max', statName: 'packetsCountsIn', statValue: 'max' },
|
||||
{ name: 'packets_counts_in_p25', statName: 'packetsCountsIn', statValue: 'p25' },
|
||||
{ name: 'packets_losts_in_sum', statName: 'packetsLostsIn', statValue: 'sum' },
|
||||
{ name: 'packets_losts_in_mean', statName: 'packetsLostsIn', statValue: 'mean' },
|
||||
{ name: 'packets_losts_in_min', statName: 'packetsLostsIn', statValue: 'min' },
|
||||
{ name: 'packets_losts_in_max', statName: 'packetsLostsIn', statValue: 'max' },
|
||||
{ name: 'packets_losts_in_p25', statName: 'packetsLostsIn', statValue: 'p25' },
|
||||
{ name: 'packets_retransmitted_in_sum', statName: 'packetsRetransmittedIn', statValue: 'sum' },
|
||||
{ name: 'packets_retransmitted_in_mean', statName: 'packetsRetransmittedIn', statValue: 'mean' },
|
||||
{ name: 'packets_retransmitted_in_min', statName: 'packetsRetransmittedIn', statValue: 'min' },
|
||||
{ name: 'packets_retransmitted_in_max', statName: 'packetsRetransmittedIn', statValue: 'max' },
|
||||
{ name: 'packets_retransmitted_in_p25', statName: 'packetsRetransmittedIn', statValue: 'p25' },
|
||||
// audio out
|
||||
{ name: 'audio_out_count', statName: 'audioBitratesOut', statValue: 'length' },
|
||||
{ name: 'audio_bitrates_out_sum', statName: 'audioBitratesOut', statValue: 'sum' },
|
||||
{ name: 'audio_bitrates_out_mean', statName: 'audioBitratesOut', statValue: 'mean' },
|
||||
{ name: 'audio_bitrates_out_min', statName: 'audioBitratesOut', statValue: 'min' },
|
||||
{ name: 'audio_bitrates_out_max', statName: 'audioBitratesOut', statValue: 'max' },
|
||||
{ name: 'audio_bitrates_out_p25', statName: 'audioBitratesOut', statValue: 'p25' },
|
||||
// video out
|
||||
{ name: 'video_out_count', statName: 'videoBitratesOut', statValue: 'length' },
|
||||
{ name: 'video_bitrates_out_sum', statName: 'videoBitratesOut', statValue: 'sum' },
|
||||
{ name: 'video_bitrates_out_mean', statName: 'videoBitratesOut', statValue: 'mean' },
|
||||
{ name: 'video_bitrates_out_min', statName: 'videoBitratesOut', statValue: 'min' },
|
||||
{ name: 'video_bitrates_out_max', statName: 'videoBitratesOut', statValue: 'max' },
|
||||
{ name: 'video_bitrates_out_p25', statName: 'videoBitratesOut', statValue: 'p25' },
|
||||
// sl
|
||||
{ name: 'spatial_layers_out_mean', statName: 'spatialLayersOut', statValue: 'mean' },
|
||||
{ name: 'spatial_layers_out_min', statName: 'spatialLayersOut', statValue: 'min' },
|
||||
{ name: 'spatial_layers_out_max', statName: 'spatialLayersOut', statValue: 'max' },
|
||||
{ name: 'spatial_layers_out_p25', statName: 'spatialLayersOut', statValue: 'p25' },
|
||||
// tl
|
||||
{ name: 'temporal_layers_out_mean', statName: 'temporalLayersOut', statValue: 'mean' },
|
||||
{ name: 'temporal_layers_out_min', statName: 'temporalLayersOut', statValue: 'min' },
|
||||
{ name: 'temporal_layers_out_max', statName: 'temporalLayersOut', statValue: 'max' },
|
||||
{ name: 'temporal_layers_out_p25', statName: 'temporalLayersOut', statValue: 'p25' },
|
||||
// rtt out
|
||||
{ name: 'round_trip_times_out_mean', statName: 'roundTripTimesOut', statValue: 'mean' },
|
||||
{ name: 'round_trip_times_out_min', statName: 'roundTripTimesOut', statValue: 'min' },
|
||||
{ name: 'round_trip_times_out_max', statName: 'roundTripTimesOut', statValue: 'max' },
|
||||
{ name: 'round_trip_times_out_p25', statName: 'roundTripTimesOut', statValue: 'p25' }
|
||||
|
||||
].forEach(({ name, statName, statValue }) =>
|
||||
{
|
||||
// eslint-disable-next-line no-new
|
||||
new promClient.Gauge({
|
||||
name : `mediasoup_${name}`,
|
||||
help : `MediaSoup ${name}`,
|
||||
labelNames : [],
|
||||
registers : [ register ],
|
||||
async collect()
|
||||
{
|
||||
await collectStats();
|
||||
if (mediasoupStats[statName] !== undefined
|
||||
&& mediasoupStats[statName][statValue] !== undefined)
|
||||
{
|
||||
this.set({}, mediasoupStats[statName][statValue]);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.warn(`${statName}.${statValue} not found`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return register;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,218 +19,218 @@ const metadata = {
|
|||
|
||||
module.exports = async function(workers, rooms, peers, registry, config)
|
||||
{
|
||||
const newMetrics = function(subsystem)
|
||||
{
|
||||
const namespace = 'mediasoup';
|
||||
const metrics = new Map();
|
||||
const newMetrics = function(subsystem)
|
||||
{
|
||||
const namespace = 'mediasoup';
|
||||
const metrics = new Map();
|
||||
|
||||
for (const key in metadata)
|
||||
{
|
||||
if (Object.prototype.hasOwnProperty.call(metadata, key))
|
||||
{
|
||||
const value = metadata[key];
|
||||
const name = key.split(/(?=[A-Z])/).join('_')
|
||||
.toLowerCase();
|
||||
const unit = value.unit;
|
||||
const metricType = value.metricType;
|
||||
let s = `${namespace}_${subsystem}_${name}`;
|
||||
for (const key in metadata)
|
||||
{
|
||||
if (Object.prototype.hasOwnProperty.call(metadata, key))
|
||||
{
|
||||
const value = metadata[key];
|
||||
const name = key.split(/(?=[A-Z])/).join('_')
|
||||
.toLowerCase();
|
||||
const unit = value.unit;
|
||||
const metricType = value.metricType;
|
||||
let s = `${namespace}_${subsystem}_${name}`;
|
||||
|
||||
if (unit)
|
||||
{
|
||||
s += `_${unit}`;
|
||||
}
|
||||
const m = new metricType({
|
||||
name : s, help : `${subsystem}.${key}`, labelNames : labelNames, registers : [ registry ] });
|
||||
if (unit)
|
||||
{
|
||||
s += `_${unit}`;
|
||||
}
|
||||
const m = new metricType({
|
||||
name : s, help : `${subsystem}.${key}`, labelNames : labelNames, registers : [ registry ] });
|
||||
|
||||
metrics.set(key, m);
|
||||
}
|
||||
}
|
||||
metrics.set(key, m);
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
};
|
||||
return metrics;
|
||||
};
|
||||
|
||||
const commonLabels = function(both, fn)
|
||||
{
|
||||
for (const roomId of rooms.keys())
|
||||
{
|
||||
for (const [ peerId, peer ] of peers)
|
||||
{
|
||||
if (fn(peer))
|
||||
{
|
||||
const displayName = peer._displayName;
|
||||
const userAgent = peer._socket.client.request.headers['user-agent'];
|
||||
const kind = both.kind;
|
||||
const codec = both.rtpParameters.codecs[0].mimeType.split('/')[1];
|
||||
const commonLabels = function(both, fn)
|
||||
{
|
||||
for (const roomId of rooms.keys())
|
||||
{
|
||||
for (const [ peerId, peer ] of peers)
|
||||
{
|
||||
if (fn(peer))
|
||||
{
|
||||
const displayName = peer._displayName;
|
||||
const userAgent = peer._socket.client.request.headers['user-agent'];
|
||||
const kind = both.kind;
|
||||
const codec = both.rtpParameters.codecs[0].mimeType.split('/')[1];
|
||||
|
||||
return { roomId, peerId, displayName, userAgent, kind, codec };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('cannot find common labels');
|
||||
};
|
||||
return { roomId, peerId, displayName, userAgent, kind, codec };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('cannot find common labels');
|
||||
};
|
||||
|
||||
const addr = async function(ip, port)
|
||||
{
|
||||
if (config.deidentify)
|
||||
{
|
||||
const a = ip.split('.');
|
||||
const addr = async function(ip, port)
|
||||
{
|
||||
if (config.deidentify)
|
||||
{
|
||||
const a = ip.split('.');
|
||||
|
||||
for (let i = 0; i < a.length - 2; i++)
|
||||
{
|
||||
a[i] = 'xx';
|
||||
}
|
||||
for (let i = 0; i < a.length - 2; i++)
|
||||
{
|
||||
a[i] = 'xx';
|
||||
}
|
||||
|
||||
return `${a.join('.')}:${port}`;
|
||||
}
|
||||
else if (config.numeric)
|
||||
{
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
const a = await resolver.reverse(ip);
|
||||
return `${a.join('.')}:${port}`;
|
||||
}
|
||||
else if (config.numeric)
|
||||
{
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
const a = await resolver.reverse(ip);
|
||||
|
||||
ip = a[0];
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.error(`reverse DNS query failed: ${ip} ${err.code}`);
|
||||
}
|
||||
ip = a[0];
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
logger.error(`reverse DNS query failed: ${ip} ${err.code}`);
|
||||
}
|
||||
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
};
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
};
|
||||
|
||||
const quiet = function(s)
|
||||
{
|
||||
return config.quiet ? '' : s;
|
||||
};
|
||||
const quiet = function(s)
|
||||
{
|
||||
return config.quiet ? '' : s;
|
||||
};
|
||||
|
||||
const setValue = function(key, m, labels, v)
|
||||
{
|
||||
logger.debug(`setValue key=${key} v=${v}`);
|
||||
switch (metadata[key].metricType)
|
||||
{
|
||||
case prom.Counter:
|
||||
m.inc(labels, v);
|
||||
break;
|
||||
case prom.Gauge:
|
||||
m.set(labels, v);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected metric: ${m}`);
|
||||
}
|
||||
};
|
||||
const setValue = function(key, m, labels, v)
|
||||
{
|
||||
logger.debug(`setValue key=${key} v=${v}`);
|
||||
switch (metadata[key].metricType)
|
||||
{
|
||||
case prom.Counter:
|
||||
m.inc(labels, v);
|
||||
break;
|
||||
case prom.Gauge:
|
||||
m.set(labels, v);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected metric: ${m}`);
|
||||
}
|
||||
};
|
||||
|
||||
logger.debug('collect');
|
||||
const mRooms = new prom.Gauge({ name: 'edumeet_rooms', help: '#rooms', registers: [ registry ] });
|
||||
logger.debug('collect');
|
||||
const mRooms = new prom.Gauge({ name: 'edumeet_rooms', help: '#rooms', registers: [ registry ] });
|
||||
|
||||
mRooms.set(rooms.size);
|
||||
const mPeers = new prom.Gauge({ name: 'edumeet_peers', help: '#peers', labelNames: [ 'room_id' ], registers: [ registry ] });
|
||||
mRooms.set(rooms.size);
|
||||
const mPeers = new prom.Gauge({ name: 'edumeet_peers', help: '#peers', labelNames: [ 'room_id' ], registers: [ registry ] });
|
||||
|
||||
for (const [ roomId, room ] of rooms)
|
||||
{
|
||||
mPeers.labels(roomId).set(Object.keys(room._peers).length);
|
||||
}
|
||||
for (const [ roomId, room ] of rooms)
|
||||
{
|
||||
mPeers.labels(roomId).set(Object.keys(room._peers).length);
|
||||
}
|
||||
|
||||
const mConsumer = newMetrics('consumer');
|
||||
const mProducer = newMetrics('producer');
|
||||
const mConsumer = newMetrics('consumer');
|
||||
const mProducer = newMetrics('producer');
|
||||
|
||||
for (const [ pid, worker ] of workers)
|
||||
{
|
||||
logger.debug(`visiting worker ${pid}`);
|
||||
for (const router of worker._routers)
|
||||
{
|
||||
logger.debug(`visiting router ${router.id}`);
|
||||
for (const [ transportId, transport ] of router._transports)
|
||||
{
|
||||
logger.debug(`visiting transport ${transportId}`);
|
||||
const transportJson = await transport.dump();
|
||||
for (const worker of workers)
|
||||
{
|
||||
logger.debug(`visiting worker ${worker.pid}`);
|
||||
for (const router of worker._routers)
|
||||
{
|
||||
logger.debug(`visiting router ${router.id}`);
|
||||
for (const [ transportId, transport ] of router._transports)
|
||||
{
|
||||
logger.debug(`visiting transport ${transportId}`);
|
||||
const transportJson = await transport.dump();
|
||||
|
||||
if (transportJson.iceState != 'completed')
|
||||
{
|
||||
logger.debug(`skipping transport ${transportId}}: ${transportJson.iceState}`);
|
||||
continue;
|
||||
}
|
||||
const iceSelectedTuple = transportJson.iceSelectedTuple;
|
||||
const proto = iceSelectedTuple.protocol;
|
||||
const localAddr = await addr(iceSelectedTuple.localIp,
|
||||
iceSelectedTuple.localPort);
|
||||
const remoteAddr = await addr(iceSelectedTuple.remoteIp,
|
||||
iceSelectedTuple.remotePort);
|
||||
if (transportJson.iceState != 'completed')
|
||||
{
|
||||
logger.debug(`skipping transport ${transportId}}: ${transportJson.iceState}`);
|
||||
continue;
|
||||
}
|
||||
const iceSelectedTuple = transportJson.iceSelectedTuple;
|
||||
const proto = iceSelectedTuple.protocol;
|
||||
const localAddr = await addr(iceSelectedTuple.localIp,
|
||||
iceSelectedTuple.localPort);
|
||||
const remoteAddr = await addr(iceSelectedTuple.remoteIp,
|
||||
iceSelectedTuple.remotePort);
|
||||
|
||||
for (const [ producerId, producer ] of transport._producers)
|
||||
{
|
||||
logger.debug(`visiting producer ${producerId}`);
|
||||
const { roomId, peerId, displayName, userAgent, kind, codec } =
|
||||
for (const [ producerId, producer ] of transport._producers)
|
||||
{
|
||||
logger.debug(`visiting producer ${producerId}`);
|
||||
const { roomId, peerId, displayName, userAgent, kind, codec } =
|
||||
commonLabels(producer, (peer) => peer._producers.has(producerId));
|
||||
const a = await producer.getStats();
|
||||
const a = await producer.getStats();
|
||||
|
||||
for (const x of a)
|
||||
{
|
||||
const type = x.type;
|
||||
const labels = {
|
||||
'pid' : pid,
|
||||
'room_id' : roomId,
|
||||
'peer_id' : peerId,
|
||||
'display_name' : displayName,
|
||||
'user_agent' : userAgent,
|
||||
'transport_id' : quiet(transportId),
|
||||
'proto' : proto,
|
||||
'local_addr' : localAddr,
|
||||
'remote_addr' : remoteAddr,
|
||||
'id' : quiet(producerId),
|
||||
'kind' : kind,
|
||||
'codec' : codec,
|
||||
'type' : type
|
||||
};
|
||||
for (const x of a)
|
||||
{
|
||||
const type = x.type;
|
||||
const labels = {
|
||||
'pid' : worker.pid,
|
||||
'room_id' : roomId,
|
||||
'peer_id' : peerId,
|
||||
'display_name' : displayName,
|
||||
'user_agent' : userAgent,
|
||||
'transport_id' : quiet(transportId),
|
||||
'proto' : proto,
|
||||
'local_addr' : localAddr,
|
||||
'remote_addr' : remoteAddr,
|
||||
'id' : quiet(producerId),
|
||||
'kind' : kind,
|
||||
'codec' : codec,
|
||||
'type' : type
|
||||
};
|
||||
|
||||
for (const [ key, m ] of mProducer)
|
||||
{
|
||||
setValue(key, m, labels, x[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [ consumerId, consumer ] of transport._consumers)
|
||||
{
|
||||
logger.debug(`visiting consumer ${consumerId}`);
|
||||
const { roomId, peerId, displayName, userAgent, kind, codec } =
|
||||
for (const [ key, m ] of mProducer)
|
||||
{
|
||||
setValue(key, m, labels, x[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [ consumerId, consumer ] of transport._consumers)
|
||||
{
|
||||
logger.debug(`visiting consumer ${consumerId}`);
|
||||
const { roomId, peerId, displayName, userAgent, kind, codec } =
|
||||
commonLabels(consumer, (peer) => peer._consumers.has(consumerId));
|
||||
const a = await consumer.getStats();
|
||||
const a = await consumer.getStats();
|
||||
|
||||
for (const x of a)
|
||||
{
|
||||
if (x.type == 'inbound-rtp')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const type = x.type;
|
||||
const labels =
|
||||
{
|
||||
'pid' : pid,
|
||||
'room_id' : roomId,
|
||||
'peer_id' : peerId,
|
||||
'display_name' : displayName,
|
||||
'user_agent' : userAgent,
|
||||
'transport_id' : quiet(transportId),
|
||||
'proto' : proto,
|
||||
'local_addr' : localAddr,
|
||||
'remote_addr' : remoteAddr,
|
||||
'id' : quiet(consumerId),
|
||||
'kind' : kind,
|
||||
'codec' : codec,
|
||||
'type' : type
|
||||
};
|
||||
for (const x of a)
|
||||
{
|
||||
if (x.type == 'inbound-rtp')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const type = x.type;
|
||||
const labels =
|
||||
{
|
||||
'pid' : worker.pid,
|
||||
'room_id' : roomId,
|
||||
'peer_id' : peerId,
|
||||
'display_name' : displayName,
|
||||
'user_agent' : userAgent,
|
||||
'transport_id' : quiet(transportId),
|
||||
'proto' : proto,
|
||||
'local_addr' : localAddr,
|
||||
'remote_addr' : remoteAddr,
|
||||
'id' : quiet(consumerId),
|
||||
'kind' : kind,
|
||||
'codec' : codec,
|
||||
'type' : type
|
||||
};
|
||||
|
||||
for (const [ key, m ] of mConsumer)
|
||||
{
|
||||
setValue(key, m, labels, x[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [ key, m ] of mConsumer)
|
||||
{
|
||||
setValue(key, m, labels, x[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,35 +1,24 @@
|
|||
import Logger from './Logger';
|
||||
|
||||
const express = require('express');
|
||||
const mediasoup = require('mediasoup');
|
||||
const promClient = require('prom-client');
|
||||
|
||||
const collectDefaultMetrics = require('./metrics/default');
|
||||
const RegisterAggregated = require('./metrics/aggregated');
|
||||
|
||||
const logger = new Logger('promClient');
|
||||
const workers = new Map();
|
||||
|
||||
module.exports = async function(rooms, peers, config)
|
||||
import { config } from './config';
|
||||
|
||||
module.exports = async function(workers, rooms, peers)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.debug(`config.deidentify=${config.deidentify}`);
|
||||
logger.debug(`config.listen=${config.listen}`);
|
||||
logger.debug(`config.numeric=${config.numeric}`);
|
||||
logger.debug(`config.port=${config.port}`);
|
||||
logger.debug(`config.quiet=${config.quiet}`);
|
||||
|
||||
mediasoup.observer.on('newworker', (worker) =>
|
||||
{
|
||||
logger.debug(`observing newworker ${worker.pid} #${workers.size}`);
|
||||
workers.set(worker.pid, worker);
|
||||
worker.observer.on('close', () =>
|
||||
{
|
||||
logger.debug(`observing close worker ${worker.pid} #${workers.size - 1}`);
|
||||
workers.delete(worker.pid);
|
||||
});
|
||||
});
|
||||
logger.debug(`config.prometheus.deidentify=${config.prometheus.deidentify}`);
|
||||
logger.debug(`config.prometheus.listen=${config.prometheus.listen}`);
|
||||
logger.debug(`config.prometheus.numeric=${config.prometheus.numeric}`);
|
||||
logger.debug(`config.prometheus.port=${config.prometheus.port}`);
|
||||
logger.debug(`config.prometheus.quiet=${config.prometheus.quiet}`);
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -39,7 +28,8 @@ module.exports = async function(rooms, peers, config)
|
|||
logger.debug(`GET ${req.originalUrl}`);
|
||||
const registry = new promClient.Registry();
|
||||
|
||||
await collectDefaultMetrics(workers, rooms, peers, registry, config);
|
||||
await collectDefaultMetrics(
|
||||
workers, rooms, peers, registry, config.prometheus);
|
||||
res.set('Content-Type', registry.contentType);
|
||||
const data = await registry.metrics();
|
||||
|
||||
|
|
@ -47,31 +37,33 @@ module.exports = async function(rooms, peers, config)
|
|||
});
|
||||
|
||||
// aggregated register
|
||||
const registerAggregated = RegisterAggregated(workers, rooms, peers, config);
|
||||
const registerAggregated = RegisterAggregated(
|
||||
workers, rooms, peers, config.prometheus);
|
||||
|
||||
app.get('/metrics', async (req, res) =>
|
||||
{
|
||||
logger.debug(`GET ${req.originalUrl}`);
|
||||
|
||||
if (config.secret && req.headers.authorization !== 'Bearer ' + config.secret)
|
||||
if (config.prometheus.secret
|
||||
&& req.headers.authorization !== `Bearer ${ config.prometheus.secret}`)
|
||||
{
|
||||
logger.error(`Invalid authorization header`);
|
||||
logger.error('Invalid authorization header');
|
||||
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
|
||||
res.set('Content-Type', registerAggregated.contentType);
|
||||
const data = await registerAggregated.metrics();
|
||||
|
||||
res.end(data);
|
||||
});
|
||||
|
||||
const server = app.listen(config.port || 8889,
|
||||
config.listen || undefined, () =>
|
||||
{
|
||||
const address = server.address();
|
||||
const server = app.listen(config.prometheus.port, config.prometheus.listen, () =>
|
||||
{
|
||||
const address = server.address();
|
||||
|
||||
logger.info(`listening ${address.address}:${address.port}`);
|
||||
});
|
||||
logger.info(`listening ${address.address}:${address.port}`);
|
||||
});
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@
|
|||
"license": "MIT",
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
"start": "yarn build && node dist/server.js",
|
||||
"build": "rm -rf dist && tsc && ln -s ../certs dist/certs && ln -s ../public dist/public",
|
||||
"start": "node dist/server.js",
|
||||
"build": "rm -rf dist && tsc && ln -s ../certs dist/certs && ln -s ../public dist/public && chmod 755 dist/server.js",
|
||||
"dev": "nodemon --exec ts-node --ignore dist/ -e js,ts server.js",
|
||||
"connect": "ts-node connect.js",
|
||||
"lint": "eslint -c .eslintrc.json --ext .js,.ts *.js *.ts lib/",
|
||||
"format": "prettier --write '**/*.ts' && npm run lint --fix"
|
||||
"format": "prettier --write '**/*.ts' && npm run lint --fix",
|
||||
"gen-config-docs": "ts-node gen-config-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"awaitqueue": "^1.0.0",
|
||||
|
|
@ -30,6 +31,8 @@
|
|||
"colors": "^1.4.0",
|
||||
"compression": "^1.7.4",
|
||||
"connect-redis": "^4.0.3",
|
||||
"convict": "^6.1.0",
|
||||
"convict-format-with-validator": "^6.0.1",
|
||||
"cookie-parser": "^1.4.4",
|
||||
"debug": "^4.3.1",
|
||||
"express": "^4.17.1",
|
||||
|
|
@ -38,6 +41,7 @@
|
|||
"fast-stats": "^0.0.6",
|
||||
"helmet": "^3.21.2",
|
||||
"ims-lti": "^3.0.2",
|
||||
"json5": "^2.2.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mediasoup": "vpalmisano/mediasoup#3.7.1-build-with-system-openssl",
|
||||
"openid-client": "^3.7.3",
|
||||
|
|
@ -50,7 +54,9 @@
|
|||
"redis": "^2.8.0",
|
||||
"socket.io": "^2.4.0",
|
||||
"spdy": "^4.0.1",
|
||||
"uuid": "^7.0.2"
|
||||
"toml": "^3.0.0",
|
||||
"uuid": "^7.0.2",
|
||||
"yaml": "^1.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/base-64": "^0.1.3",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,18 @@
|
|||
process.title = 'edumeet-server';
|
||||
|
||||
import Logger from './lib/Logger';
|
||||
const Room = require('./lib/Room');
|
||||
const Peer = require('./lib/Peer');
|
||||
const userRoles = require('./userRoles');
|
||||
const {
|
||||
loginHelper,
|
||||
logoutHelper
|
||||
} = require('./httpHelper');
|
||||
const { config, configError } = require('./lib/config');
|
||||
const interactiveServer = require('./lib/interactiveServer');
|
||||
const promExporter = require('./lib/promExporter');
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const config = require('./config/config');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const spdy = require('spdy');
|
||||
|
|
@ -15,15 +24,8 @@ const cookieParser = require('cookie-parser');
|
|||
const compression = require('compression');
|
||||
const mediasoup = require('mediasoup');
|
||||
const AwaitQueue = require('awaitqueue');
|
||||
const Room = require('./lib/Room');
|
||||
const Peer = require('./lib/Peer');
|
||||
const base64 = require('base-64');
|
||||
const helmet = require('helmet');
|
||||
const userRoles = require('./userRoles');
|
||||
const {
|
||||
loginHelper,
|
||||
logoutHelper
|
||||
} = require('./httpHelper');
|
||||
// auth
|
||||
const passport = require('passport');
|
||||
const LTIStrategy = require('passport-lti');
|
||||
|
|
@ -31,15 +33,21 @@ const imsLti = require('ims-lti');
|
|||
const SAMLStrategy = require('passport-saml').Strategy;
|
||||
const LocalStrategy = require('passport-local').Strategy;
|
||||
const redis = require('redis');
|
||||
const redisClient = redis.createClient(config.redisOptions);
|
||||
const { Issuer, Strategy } = require('openid-client');
|
||||
const expressSession = require('express-session');
|
||||
const RedisStore = require('connect-redis')(expressSession);
|
||||
const sharedSession = require('express-socket.io-session');
|
||||
const interactiveServer = require('./lib/interactiveServer');
|
||||
const promExporter = require('./lib/promExporter');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
if (configError)
|
||||
{
|
||||
/* eslint-disable no-console */
|
||||
console.error(`Invalid config file: ${configError}`);
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const redisClient = redis.createClient(config.redisOptions);
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log('- process.env.DEBUG:', process.env.DEBUG);
|
||||
console.log('- config.mediasoup.worker.logLevel:', config.mediasoup.worker.logLevel);
|
||||
|
|
@ -138,12 +146,6 @@ async function run()
|
|||
// Open the interactive server.
|
||||
await interactiveServer(rooms, peers);
|
||||
|
||||
// start Prometheus exporter
|
||||
if (config.prometheus)
|
||||
{
|
||||
await promExporter(rooms, peers, config.prometheus);
|
||||
}
|
||||
|
||||
if (typeof (config.auth) === 'undefined')
|
||||
{
|
||||
logger.warn('Auth is not configured properly!');
|
||||
|
|
@ -159,6 +161,12 @@ async function run()
|
|||
// Run HTTPS server.
|
||||
await runHttpsServer();
|
||||
|
||||
// start Prometheus exporter
|
||||
if (config.prometheus.enabled)
|
||||
{
|
||||
await promExporter(mediasoupWorkers, rooms, peers);
|
||||
}
|
||||
|
||||
// Run WebSocketServer.
|
||||
await runWebSocketServer();
|
||||
|
||||
|
|
@ -630,11 +638,11 @@ async function runHttpsServer()
|
|||
|
||||
// Serve all files in the public folder as static files.
|
||||
app.use(express.static('public', {
|
||||
maxAge : (config.staticFilesCachePeriod || 0) * 1000
|
||||
maxAge : config.staticFilesCachePeriod
|
||||
}));
|
||||
|
||||
app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`, {
|
||||
maxAge : (config.staticFilesCachePeriod || 0) * 1000
|
||||
maxAge : config.staticFilesCachePeriod
|
||||
}));
|
||||
|
||||
if (config.httpOnly === true)
|
||||
|
|
@ -647,13 +655,16 @@ async function runHttpsServer()
|
|||
// https
|
||||
mainListener = spdy.createServer(tls, app);
|
||||
|
||||
// http
|
||||
const redirectListener = http.createServer(app);
|
||||
// http -> https redirect server
|
||||
if (config.listeningRedirectPort)
|
||||
{
|
||||
const redirectListener = http.createServer(app);
|
||||
|
||||
if (config.listeningHost)
|
||||
redirectListener.listen(config.listeningRedirectPort, config.listeningHost);
|
||||
else
|
||||
redirectListener.listen(config.listeningRedirectPort);
|
||||
if (config.listeningHost)
|
||||
redirectListener.listen(config.listeningRedirectPort, config.listeningHost);
|
||||
else
|
||||
redirectListener.listen(config.listeningRedirectPort);
|
||||
}
|
||||
}
|
||||
|
||||
// https or http
|
||||
|
|
|
|||
|
|
@ -801,6 +801,11 @@ camelcase@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
|
||||
integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
|
||||
|
||||
camelcase@^5.0.0:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||
|
||||
camelize@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
|
||||
|
|
@ -1011,6 +1016,21 @@ content-type@~1.0.4:
|
|||
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
|
||||
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
|
||||
|
||||
convict-format-with-validator@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/convict-format-with-validator/-/convict-format-with-validator-6.0.1.tgz#a0ff08b663c40beac480d1a0d39b40382b9e39a3"
|
||||
integrity sha512-6/O0W9/0MESdL+0fY7glQlWHs+wnP9xXlB1tjY/Wb9ujCCEBqMP8VCjtLvRlBdfwgONrpb+DTPSHQRhUO5aTlQ==
|
||||
dependencies:
|
||||
validator "^11.1.0"
|
||||
|
||||
convict@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/convict/-/convict-6.1.0.tgz#5b393bd675a0e743390abd0c5bf9e2b9a2edf4b5"
|
||||
integrity sha512-8dzppr6Z9URlm6P8N9NiydFRq2NWtQyf4RZOK5m0Q48fWWuKamHLXD7Qz/SiLvRXnjQcKCuHayIk9Fk51sax0w==
|
||||
dependencies:
|
||||
lodash.clonedeep "^4.5.0"
|
||||
yargs-parser "^18.1.3"
|
||||
|
||||
cookie-parser@^1.4.4:
|
||||
version "1.4.5"
|
||||
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49"
|
||||
|
|
@ -1138,7 +1158,7 @@ debug@~4.1.0:
|
|||
dependencies:
|
||||
ms "^2.1.1"
|
||||
|
||||
decamelize@^1.1.2:
|
||||
decamelize@^1.1.2, decamelize@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
|
||||
|
|
@ -2528,6 +2548,13 @@ json5@^1.0.1:
|
|||
dependencies:
|
||||
minimist "^1.2.0"
|
||||
|
||||
json5@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
|
||||
integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
jsonwebtoken@^8.5.1:
|
||||
version "8.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
|
||||
|
|
@ -4261,6 +4288,11 @@ toidentifier@1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||
|
||||
toml@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee"
|
||||
integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==
|
||||
|
||||
tough-cookie@~2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
|
||||
|
|
@ -4441,6 +4473,11 @@ validate-npm-package-license@^3.0.1:
|
|||
spdx-correct "^3.0.0"
|
||||
spdx-expression-parse "^3.0.0"
|
||||
|
||||
validator@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/validator/-/validator-11.1.0.tgz#ac18cac42e0aa5902b603d7a5d9b7827e2346ac4"
|
||||
integrity sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==
|
||||
|
||||
vary@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
|
|
@ -4608,6 +4645,19 @@ yallist@^4.0.0:
|
|||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
|
||||
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||
|
||||
yaml@^1.10.2:
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
|
||||
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
||||
|
||||
yargs-parser@^18.1.3:
|
||||
version "18.1.3"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
|
||||
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
|
||||
dependencies:
|
||||
camelcase "^5.0.0"
|
||||
decamelize "^1.2.0"
|
||||
|
||||
yeast@0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue