.NET Core 3.1 with ReactJs 16.9.0 - Run in IIS sub-folder

1.8k Views Asked by At

I have a .NET Core 3.1 with ReactJs frontend (I am using the switch bionic framework) that I am trying to run in a sub folder (subapp) on IIS. Installing the app on IIS as a website works fine. The application runs without problems. But, when installing the application as a subapp under the IIS default page, will always give a 404 on any resources that react needs. This happens since the baseUrl used by React defaults to the root url.

So far I have not found a way to change this behavior, short of adding the sub-folder name to the baseurl itself. This is not a solution for me, as this one application will be installed in at least 43 sub-folders under IIS on 1 server. I can't build and deploy 43+ apps just because the sub-folder has to be updated!

Does anybody have ANY idea how I can get the react frontend to recognize the current url (which includes the sub-folder) in use and not just default to the root url?

I have tried setting "homepage": "." and "homepage": "./" in package.json, according to the post from 'gaearon' (https://github.com/facebook/create-react-app/issues/527), but it had no affect.

My config file (default.tsx) looks like this (edited on 2021/04/09):

import Project from '../src/globals/interfaces/Project';

const isDevelopment = Object.is(process.env.NODE_ENV, 'development');

const baseUrl = isDevelopment ? 'http://localhost:5105' : '.';

const config: IConfigData<Project, {}> = {
  core: {
    i18n: {
      defaultLocale: 'en',
    },
  },
  project: {
    baseUrl,
  },
  runtime: {},
};

export default config;

My package.json:

{
  "name": "MyApp",
  "version": "3.4.2",
  "description": "My Application",
  "license": "Whatever",
  "author": {
    "name": "Werner"
  },
  "config": {
    "AppIcon": "./src/assets/appIcon/logo.png",
    "title": "My App Title",
    "devServer": {
      "host": "0.0.0.0",
      "port": "5170",
      "https": false,
      "publicPath": "/"
    },
    "publicPath": "",
    "homepage": ".",
    "functionalTestBrowsers": [
      "chrome",
      "firefox",
      "internet explorer",
      "edge"
    ]
  },
  "scripts": {
    "test": "jest --no-cache",
    "test:update": "jest --updateSnapshot",
    "start": "webpack-dev-server --env.build=dev",
    "start:4110": "webpack-dev-server --env.build=dev --env.config=4110",
    "start:4120": "webpack-dev-server --env.build=dev --env.config=4120",
    "start:4130": "webpack-dev-server --env.build=dev --env.config=4130",
    "start:4140": "webpack-dev-server --env.build=dev --env.config=4140",
    "start:4150": "webpack-dev-server --env.build=dev --env.config=4150",
    "start:legacy": "webpack-dev-server --env.build=dev --env.legacy=true --env.REDUX_TOOLS=logger",
    "start:swidget": "webpack-dev-server --port 7070 --env.build=dev --env.swidget=true --env.legacy=true --env.REDUX_TOOLS=logger",
    "start:host": "webpack-dev-server --env.build=dev --env.legacy=true --env.exposed=true --env.REDUX_TOOLS=logger",
    "build": "webpack --env.build=prod --env.verbose=false",
    "build:ci": "webpack --env.build=prod --env.verbose=true --env.release=true",
    "build:legacy": "webpack --env.build=prod --env.legacy=true",
    "build:host": "webpack --env.build=prod --env.legacy=true --env.exposed=true",
    "build:swidget": "webpack --env.build=prod --env.swidget=true --env.legacy=true",
    "build:multi": "webpack --env.build=multi",
    "build:test:functional": "tsc -p test/functional/tsconfig.json",
    "lint": "nyr lint:eslint && nyr lint:stylelint && nyr lint:prettier",
    "lint:staged": "lint-staged",
    "lint:eslint": "eslint --ext ts,tsx src",
    "lint:prettier": "prettier --check \"./**/*\"",
    "lint:stylelint": "stylelint \"src/**/*.(css|scss)\" --syntax scss",
    "lint:fix": "nyr lint:fix:eslint && nyr lint:fix:stylelint && nyr lint:fix:postcss && nyr lint:fix:prettier",
    "lint:fix:prettier": "prettier --write \"./**/*\"",
    "lint:fix:postcss": "postcss --config postcss.config.js --env sort-only --no-map --replace \"src/**/*.(css|scss)\"",
    "lint:fix:stylelint": "stylelint \"src/**/*.(css|scss)\" --syntax scss --fix",
    "lint:fix:eslint": "eslint --fix --ext ts,tsx src",
    "clean": "rimraf dist && rimraf coverage",
    "storybook": "cross-env build=dev start-storybook -p 9001 -c .build/storybook",
    "storybook:static": "cross-env build=prod build-storybook -c .build/storybook -o dist/storybook",
    "test:functional": "run-p build:test:functional test:functional:selenium && run-p --race start wdio",
    "test:functional:headless": "run-p build:test:functional test:functional:selenium && run-p --race start wdio:headless",
    "test:functional:selenium": "selenium-standalone install --silent",
    "wdio": "wdio .build/wdio.conf.js",
    "wdio:headless": "cross-env WDIO_HEADLESS=true wdio .build/wdio.conf.js"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "post-commit": "git update-index --again"
    }
  },
  "repository": {
    "type": "git",
    "url": "Don't worry! Taken out for security!"
  },
  "engines": {
    "node": ">=8.11.3",
    "npm": ">=5.6.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "Firefox ESR",
    "ie >= 11"
  ],
  "dependencies": {
    "@daimler/material-ui-comps": "0.0.4-release-84.0",
    "@daimler/material-ui-theme": "0.0.9",
    "@daimler/typeface-daimler-cs-web": "^1.0.0",
    "@material-ui/core": "^4.11.0",
    "@material-ui/icons": "^4.9.1",
    "@material-ui/lab": "^4.0.0-alpha.53",
    "@switch/core": "2.0.0-beta.2",
    "@types/react-csv": "^1.1.1",
    "@types/react-router-dom": "^5.1.3",
    "@types/redux-logger": "^3.0.7",
    "axios": "^0.19.0",
    "core-js": "~3.2.1",
    "domtokenlist-shim": "~1.2.0",
    "file-saver": "^2.0.2",
    "inversify": "~4.3.0",
    "joi-browser": "~13.4.0",
    "jwt-decode": "^2.2.0",
    "material-table": "^1.69.1",
    "material-ui-dropzone": "^3.2.1",
    "react": "~16.9.0",
    "react-csv": "^2.0.3",
    "react-dom": "~16.9.0",
    "react-hot-loader": "~4.12.11",
    "react-promise-tracker": "^2.0.5",
    "react-redux": "^7.2.0",
    "react-router-dom": "^5.1.2",
    "react-switch": "^5.0.1",
    "react-table": "^7.5.1",
    "react-transition-group-v2": "^4.3.0",
    "redux": "^4.0.5",
    "redux-devtools-extension": "^2.13.8",
    "redux-logger": "^3.0.6",
    "redux-thunk": "^2.3.0",
    "regenerator-runtime": "~0.13.3",
    "tslib": "~1.10.0",
    "typesafe-actions": "^5.1.0",
    "universal-cookie": "^4.0.3",
    "whatwg-fetch": "~3.0.0"
  },
  "devDependencies": {
    "@babel/core": "~7.5.5",
    "@babel/preset-env": "~7.5.5",
    "@babel/runtime": "~7.5.5",
    "@hot-loader/react-dom": "~16.9.0",
    "@storybook/addon-actions": "~5.1.11",
    "@storybook/addon-info": "~5.1.11",
    "@storybook/addon-knobs": "~5.1.11",
    "@storybook/addon-links": "~5.1.11",
    "@storybook/addons": "~5.1.11",
    "@storybook/cli": "~5.1.11",
    "@storybook/react": "~5.1.11",
    "@types/enzyme": "~3.10.3",
    "@types/jasmine": "~3.4.0",
    "@types/jest": "~24.0.18",
    "@types/jwt-decode": "^2.2.1",
    "@types/node": "~12.0.0",
    "@types/prop-types": "~15.7.1",
    "@types/react": "~16.9.2",
    "@types/react-dom": "~16.9.0",
    "@types/react-redux": "^7.1.7",
    "@types/react-test-renderer": "~16.9.0",
    "@types/storybook__addon-actions": "~3.4.3",
    "@types/storybook__addon-info": "~4.1.2",
    "@types/storybook__addon-knobs": "~5.0.3",
    "@types/storybook__addon-links": "~3.3.5",
    "@types/storybook__react": "~4.0.2",
    "@typescript-eslint/eslint-plugin": "~2.0.0",
    "@typescript-eslint/parser": "~2.0.0",
    "@wdio/cli": "~5.12.4",
    "@wdio/dot-reporter": "~5.12.1",
    "@wdio/jasmine-framework": "~5.12.1",
    "@wdio/local-runner": "~5.12.4",
    "@wdio/selenium-standalone-service": "~5.12.1",
    "@wdio/spec-reporter": "~5.12.1",
    "@wdio/sync": "~5.12.3",
    "babel-loader": "~8.0.6",
    "clean-webpack-plugin": "~3.0.0",
    "copy-webpack-plugin": "~5.0.4",
    "cross-env": "~5.2.0",
    "css-loader": "~3.2.0",
    "css-modules-typescript-loader": "~3.0.0",
    "cssjson": "~2.1.3",
    "duplicate-package-checker-webpack-plugin": "~3.0.0",
    "enzyme": "~3.10.0",
    "enzyme-adapter-react-16": "~1.14.0",
    "enzyme-to-json": "~3.4.0",
    "eslint": "~6.2.1",
    "eslint-config-prettier": "~6.1.0",
    "eslint-plugin-prettier": "~3.1.0",
    "eslint-plugin-react": "~7.14.3",
    "expose-loader": "~0.7.5",
    "fork-ts-checker-webpack-plugin": "~1.5.0",
    "html-webpack-multi-build-plugin": "~1.0.0",
    "html-webpack-plugin": "~3.2.0",
    "husky": "~3.0.4",
    "identity-obj-proxy": "~3.0.0",
    "imagemin-lint-staged": "~0.4.0",
    "jasmine": "~3.4.0",
    "jest": "~24.9.0",
    "lint-staged": "~9.2.3",
    "mini-css-extract-plugin": "~0.8.0",
    "mock-local-storage": "~1.1.8",
    "npm-run-all": "~4.1.5",
    "nyr": "1.1.0",
    "optimize-css-assets-webpack-plugin": "~5.0.3",
    "postcss": "~7.0.17",
    "postcss-cli": "~6.1.3",
    "postcss-extend": "~1.0.5",
    "postcss-import": "~12.0.1",
    "postcss-import-sync": "~7.1.4",
    "postcss-loader": "~3.0.0",
    "postcss-nested": "~4.1.2",
    "postcss-preset-env": "~6.7.0",
    "postcss-remove-prefixes": "~1.2.0",
    "postcss-sorting": "~5.0.1",
    "postcss-unprefix": "~2.1.4",
    "prettier": "~1.18.2",
    "react-ace": "^7.0.4",
    "react-docgen-typescript-loader": "~3.1.1",
    "react-test-renderer": "~16.9.0",
    "rimraf": "~3.0.0",
    "simple-progress-webpack-plugin": "~1.1.2",
    "style-loader": "~1.0.0",
    "stylelint": "~10.1.0",
    "stylelint-config-css-modules": "~1.4.0",
    "stylelint-config-recommended": "~2.2.0",
    "terser-webpack-plugin": "~1.4.1",
    "ts-jest": "~24.0.2",
    "ts-loader": "~6.0.4",
    "typescript": "~3.5.3",
    "url-loader": "~2.1.0",
    "webapp-webpack-plugin": "~2.7.1",
    "webpack": "~4.39.2",
    "webpack-cli": "~3.3.7",
    "webpack-dev-server": "~3.8.0",
    "webpack-merge": "~4.2.1"
  }
}

Any help will be greatly appreciated.

1

There are 1 best solutions below

0
On

There are 100s of posts on the internet on how to get the ReactJS front-end, as part of an asp.net core back-end, running without problems under an IIS sub-folder. Most of them are the same. Some of them do work properly while others are just plain rubbish.

Should you want to run an application (back-end and front-end) in a sub-folder, then the only way to do it is to hard-code the base url and public path in React.

Since I am using React with typescript and the switch framework I can do it like this: default.tsx:

const baseUrl = isDevelopment ? 'http://localhost:5105' : 'http://localhost/MySubFolderName';

Then in package.json:

"publicPath": "MySubFolderName",

After building for production the app will run without problems in an IIS sub-folder.

But my goal is to have this one app running in 40+ IIS sub-folders, many of them on the same server. And it is definitely NOT an option to build the front-end separately for each installation. Think of the maintenance nightmare!!

I therefor decided to write a script that will change the required files for me. Automatically!

The first option I tried was to set the base tag, <base href="/">, in index.html, as given as a solution by many posts on the internet. My script opened this index.html and replaced the tag to included the sub-folder name: <base href="/MySubFolderName/">.

And 'lo-and-behold', the site runs! However, great was my disappointment when 2 days later I finally figured out that this is not enough. The site runs, but the routing is broken! Irrespective of which router package you use.

So, BEWARE, just setting href in a base tag is not enough.

My final solution: EDITED: My first solution was to hard-code the base url and public path, but with my special name: XYXYX.

After some further thinking I decided to not hard-code anything, but leave the front-end as is. The back-end now updates the required files as needed, making sure that they are correct for either a 'port' installation or for a 'subfolder' installation.

In the back-end, in Programs.cs, create a function that will change the index.html and *app.js files depending on whether the app is installed under IIS to run on a port (ex. http://domain:port) or in a subfolder (ex. http://domain/subfolder).

appsettings.json:

"AppAddress": "http://my.domain.name/MySubFolderName",

Program.cs:

...
            IConfiguration Configuration = new ConfigurationBuilder()
                .SetBasePath(pathToContentRoot)
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .AddJsonFile($"appsettings.{env}.json", optional: true, reloadOnChange: true)
                .AddEnvironmentVariables()
                .Build();

            try
            {
                var host = CreateHostBuilder(args).Build();
...
...
                try
                {
                    UpdateFilesForSubfolderOrPortUse(Configuration["AppAddress"], pathToContentRoot);
                }
                catch (Exception e)
                {
                    Log.Fatal(e, $"Host terminated. Could not rewrite files! Error: {e.Message}");
                    return 1;
                }

                host.Run();
...
...
        private static void UpdateFilesForSubfolderOrPortUse(string serverAddress, string pathToContentRoot)
        {
            string subFolder = ExtractSubfolderName(serverAddress);

            // First extract the search-string from the *app.js file
            string searchString = ExtractSearchStringFromJsFile(pathToContentRoot);
            string oldSubFolder = "";

            if (searchString != ".")
            {
                oldSubFolder = searchString.Substring(searchString.IndexOf("/", searchString.IndexOf("://") + 3) + 1);
            }

            Dictionary<string, string> searchAndReplaceDict = new Dictionary<string, string>
            {
                { "HtmlSearchString1", "" },
                { "HtmlReplaceString1", "" },
                { "HtmlSearchString2", "" },
                { "HtmlReplaceString2", "" },
                { "JsSearchString1", "" },
                { "JsReplaceString1", "" },
                { "JsSearchString2", "" },
                { "JsReplaceString2", "" },
                { "JsSearchString3", "" },
                { "JsReplaceString3", "" }
            };

            if ((string.IsNullOrEmpty(subFolder) && searchString == ".") || (!string.IsNullOrEmpty(subFolder) && oldSubFolder == subFolder))
            {
                // No conversion is needed
                return;
            }
            else if ((!string.IsNullOrEmpty(subFolder) && searchString == "."))
            {
                // Convert from Port to SubFolder
                searchAndReplaceDict["HtmlSearchString1"] = "link href=\"";
                searchAndReplaceDict["HtmlReplaceString1"] = $"link href=\"{subFolder}/";
                searchAndReplaceDict["HtmlSearchString2"] = "src=\"";
                searchAndReplaceDict["HtmlReplaceString2"] = $"src=\"{subFolder}/";
                searchAndReplaceDict["JsSearchString1"] = "+\"fonts/";
                searchAndReplaceDict["JsReplaceString1"] = "+\"/fonts/";
                searchAndReplaceDict["JsSearchString2"] = "},i.p=\"";
                searchAndReplaceDict["JsReplaceString2"] = "},i.p=\"" + subFolder;
                searchAndReplaceDict["JsSearchString3"] = "\"http://localhost:5105\":\".";
                searchAndReplaceDict["JsReplaceString3"] = $"\"http://localhost:5105\":\"{(serverAddress[serverAddress.Length - 1] == '/' ? serverAddress.Substring(0, serverAddress.Length - 1) : serverAddress)}";
            }
            else if ((!string.IsNullOrEmpty(subFolder) && searchString != "."))
            {
                // Convert from SubFolder to SubFolder
                searchAndReplaceDict["HtmlSearchString1"] = oldSubFolder;
                searchAndReplaceDict["HtmlReplaceString1"] = subFolder;
                searchAndReplaceDict["JsSearchString1"] = "+\"fonts/";
                searchAndReplaceDict["JsReplaceString1"] = "+\"/fonts/";
                searchAndReplaceDict["JsSearchString2"] = $"\"{oldSubFolder}\"";
                searchAndReplaceDict["JsReplaceString2"] = $"\"{subFolder}\"";
                searchAndReplaceDict["JsSearchString3"] = $"/{oldSubFolder}\"";
                searchAndReplaceDict["JsReplaceString3"] = $"/{subFolder}\"";
            }
            else
            {
                // Convert from SubFolder to Port
                searchAndReplaceDict["HtmlSearchString1"] = $"{oldSubFolder}/";
                searchAndReplaceDict["JsSearchString1"] = "+\"/fonts/";
                searchAndReplaceDict["JsReplaceString1"] = "+\"fonts/";
                searchAndReplaceDict["JsSearchString2"] = $"\"{oldSubFolder}\"";
                searchAndReplaceDict["JsReplaceString2"] = "\"\"";
                searchAndReplaceDict["JsSearchString3"] = searchString;
                searchAndReplaceDict["JsReplaceString3"] = $".";
            }

            DoUpdateFiles(searchAndReplaceDict, pathToContentRoot);
        }

        private static string ExtractSubfolderName(string serverAddress)
        {
            string baseAddr = serverAddress.Substring(serverAddress.IndexOf("://") + 3);
            int idx = baseAddr.IndexOf('/');
            string subaddr = "";

            if (idx > 0)
            {
                subaddr = baseAddr.Substring(idx + 1);

                if (subaddr.Length > 0 && subaddr[subaddr.Length - 1] == '/')
                {
                    subaddr = subaddr.Substring(0, subaddr.Length - 1);
                }
            }

            return subaddr;
        }

        private static string ExtractSearchStringFromJsFile(string pathToContentRoot)
        {
            string rootfolder = $"{pathToContentRoot}\\App";
            var exts = new[] { "app.js" };
            IEnumerable<string> files = Directory
                .EnumerateFiles(@rootfolder, "*.*", SearchOption.TopDirectoryOnly)
                .Where(file => exts.Any(x => file.EndsWith(x, StringComparison.OrdinalIgnoreCase)));

            string jsContents = File.ReadAllText(files.First());
            string searchFor = "project:{baseUrl:Object.is(\"production\",\"development\")?\"http://localhost:5105\":\"";

            string tmpSearchStr = jsContents.Substring(jsContents.IndexOf(searchFor) + searchFor.Length);

            if (tmpSearchStr.StartsWith(".\""))
            {
                // This is a Port installation
                tmpSearchStr = ".";
            }
            else
            {
                // This is a SubFolder installation
                tmpSearchStr = tmpSearchStr.Substring(0, tmpSearchStr.IndexOf("\"}"));
            }

            return tmpSearchStr;
        }

        private static void DoUpdateFiles(Dictionary<string, string> searchAndReplaceDict, string pathToContentRoot)
        {
            string rootfolder = $"{pathToContentRoot}\\App";
            var exts = new[] { ".html", "app.js" };
            IEnumerable<string> files = Directory
                .EnumerateFiles(@rootfolder, "*.*", SearchOption.TopDirectoryOnly)
                .Where(file => exts.Any(x => file.EndsWith(x, StringComparison.OrdinalIgnoreCase)));

            foreach (string file in files)
            {
                string contents = File.ReadAllText(file);

                if (Path.GetExtension(@file) == ".html")
                {
                    contents = contents.Replace(searchAndReplaceDict["HtmlSearchString1"], searchAndReplaceDict["HtmlReplaceString1"]);

                    if (!string.IsNullOrEmpty(searchAndReplaceDict["HtmlSearchString2"]))
                    {
                        contents = contents.Replace(searchAndReplaceDict["HtmlSearchString2"], searchAndReplaceDict["HtmlReplaceString2"]);
                    }
                }
                else if (Path.GetExtension(@file) == ".js")
                {
                    contents = contents.Replace(searchAndReplaceDict["JsSearchString1"], searchAndReplaceDict["JsReplaceString1"]);
                    contents = contents.Replace(searchAndReplaceDict["JsSearchString2"], searchAndReplaceDict["JsReplaceString2"]);
                    contents = contents.Replace(searchAndReplaceDict["JsSearchString3"], searchAndReplaceDict["JsReplaceString3"]);
                }

                // Make file writable
                File.SetAttributes(file, FileAttributes.Normal);

                File.WriteAllText(file, contents);
            }
        }

Now an admin can download the release of the application and install it in an IIS sub-folder. This admin can also just copy all files from one sub-folder/port installation to another. In both instances the admin must update the config file with the correct address.

The application will now, during startup, right before it runs the application, read the 2 files and replace the required strings.

The application now runs with a 'standard' front-end for a 'port' installation and with a 'hard-coded' front-end for a 'sub-folder' installation, but one that is dynamic and updated automatically depending on the 'sub-folder name'! And I can just keep on adding new applications in sub-folders or on ports without having to rebuild every time and without having to manually update the required strings to get it running ...

Yes, some people will say this is a hack, but in SW even a hack is a great solution in the absence of anything else!