Skip to content

Instantly share code, notes, and snippets.

@javiercn

javiercn/Spec.md Secret

Last active March 26, 2021 17:53
Show Gist options
  • Save javiercn/b28cb0ea0912de8313e0fd0cff9021d9 to your computer and use it in GitHub Desktop.
Save javiercn/b28cb0ea0912de8313e0fd0cff9021d9 to your computer and use it in GitHub Desktop.
SPA proxy reversal design notes
{
"name": "latestangular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"prestart": "node aspnetcore-https",
"start": "run-script-os",
"start:windows": "ng serve --port 5002 --ssl --ssl-cert %APPDATA%\\ASP.NET\\https\\latestangular.pem --ssl-key %APPDATA%\\ASP.NET\\https\\latestangular.key",
"start:default": "ng serve --port 5002 --ssl --ssl-cert $HOME/.aspnet/https/latestangular.pem --ssl-key $HOME/.aspnet/https/latestangular.key",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "~11.2.7",
"@angular/common": "~11.2.7",
"@angular/compiler": "~11.2.7",
"@angular/core": "~11.2.7",
"@angular/forms": "~11.2.7",
"@angular/platform-browser": "~11.2.7",
"@angular/platform-browser-dynamic": "~11.2.7",
"@angular/router": "~11.2.7",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.11.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1102.6",
"@angular/cli": "~11.2.6",
"@angular/compiler-cli": "~11.2.7",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.1.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"protractor": "~7.0.0",
"run-script-os": "^1.1.6",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~4.1.5"
}
}
// This script sets up HTTPS for the application using the ASP.NET Core HTTPS certificate
const fs = require('fs');
const spawn = require('child_process').spawn;
const path = require('path');
const baseFolder =
process.env.APPDATA !== ''
? `${process.env.APPDATA}/ASP.NET/https`
: `${process.env.HOME}/.aspnet/https`;
const certificateName = process.argv.map(arg => arg.match(/name=(?<value>.+)/i))
.filter(Boolean)
.reduce((previous, current) => previous || current.groups.value, undefined) ||
process.env.npm_package_name;
if(!certificateName){
console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass name=<<app>> explicitly.')
process.exit(-1);
}
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
spawn(
'dotnet',
[
'dev-certs',
'https',
'--export-path',
certFilePath,
'--format',
'Pem',
'--no-password',
],
{
stdio: 'inherit',
}
).on('exit', (code) => process.exit(code));
}
// This script sets up HTTPS for the application using the ASP.NET Core HTTPS certificate
const fs = require('fs');
const path = require('path');
const baseFolder =
process.env.APPDATA !== ''
? `${process.env.APPDATA}/ASP.NET/https`
: `${process.env.HOME}/.aspnet/https`;
const certificateName = process.argv.map(arg => arg.match(/name=(?<value>.+)/i))
.filter(Boolean)
.reduce((previous, current) => previous || current.groups.value, undefined) ||
process.env.npm_package_name;
if(!certificateName){
console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass name=<<app>> explicitly.')
process.exit(-1);
}
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
if (!fs.existsSync('.env.development.local')) {
fs.writeFileSync(
'.env.development.local',
`HTTPS=true
SSL_CRT_FILE=${certFilePath}
SSL_KEY_FILE=${keyFilePath}
PORT=5002`
);
} else {
let lines = fs.readFileSync('.env.development.local')
.toString()
.split('\n');
var hasPort,
hasHttps,
hasCert,
hasCertKey = false;
for (const line of lines) {
if (/HTTPS=true/i.test(line)) {
hasHttps = true;
}
if (/SSL_CRT_FILE=.*/i.test(line)) {
hasCert = true;
}
if (/SSL_KEY_FILE=.*/i.test(line)) {
hasCertKey = true;
}
if (/PORT=\d+/.test(line)) {
hasPort = true;
}
}
if (!hasHttps) {
fs.appendFileSync('.env.development.local', '\nHTTPS=true');
}
if (!hasCert) {
fs.appendFileSync(
'.env.development.local',
`\nSSL_CRT_FILE=${certFilePath}`
);
}
if (!hasCertKey) {
fs.appendFileSync(
'.env.development.local',
`\nSSL_KEY_FILE=${keyFilePath}`
);
}
if (!hasPort) {
fs.appendFileSync('.env.development.local', '\nPORT=5002');
}
}
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:23360",
"sslPort": 44326
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "support"
}
},
"angularfrontend": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "https://localhost:5002",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "support"
}
}
}
}
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable>
<SpaRoot>ClientApp\</SpaRoot>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
</PropertyGroup>
<!-- These are the props the user configure to handle the launching -->
<PropertyGroup>
<ClientAppPath>$(MSBuildThisFileDirectory)ClientApp</ClientAppPath>
<SpaLaunchCommand>npm start</SpaLaunchCommand>
<SpaProxyServerUrl>https://localhost:5002</SpaProxyServerUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="5.0.4" />
</ItemGroup>
<ItemGroup>
<!-- Don't publish the SPA source files, but do show them in the project files list -->
<Content Remove="$(SpaRoot)**" />
<None Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="<<SpaSupportPackage>>" />
</ItemGroup>
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<!-- Ensure Node.js is installed -->
<Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build -- --prod" />
<MakeDir Directories="$(SpaRoot)dist\something" />
<WriteLinesToFile File="$(SpaRoot)dist\something\test.txt" Lines="Hello world" />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<_DistFiles Include="$(SpaRoot)dist\**" />
<DistFiles Include="@(_DistFiles)">
<TargetPath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</TargetPath>
</DistFiles>
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.TargetPath)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<!-- This goes inside <<SpaSupportPackage>> -->
<Target Name="WriteCertificateConfigToDisk" BeforeTargets="AssignTargetPaths">
<PropertyGroup>
<DevServerLaunchConfig>$(IntermediateOutputPath)spa.proxy.json</DevServerLaunchConfig>
</PropertyGroup>
<ItemGroup>
<DevServerLaunchConfigLines Include="{" />
<DevServerLaunchConfigLines Include=" &quot;SpaDevelopmentServer&quot;: {" />
<DevServerLaunchConfigLines Include=" &quot;ServerUrl&quot;: &quot;$(SpaProxyServerUrl)&quot;," />
<DevServerLaunchConfigLines Include=" &quot;LaunchCommand&quot;: &quot;$(SpaLaunchCommand.Replace('\','\\'))&quot;," />
<DevServerLaunchConfigLines Include=" &quot;WorkingDirectory&quot;: &quot;$(ClientAppPath.Replace('\','\\'))&quot;," />
<DevServerLaunchConfigLines Include=" &quot;MaxTimeoutInSeconds&quot;: &quot;30&quot;" />
<DevServerLaunchConfigLines Include=" }" />
<DevServerLaunchConfigLines Include="}" />
</ItemGroup>
<WriteLinesToFile File="$(DevServerLaunchConfig)" Lines="@(DevServerLaunchConfigLines)" WriteOnlyWhenDifferent="true" Overwrite="true" />
<ItemGroup>
<ContentWithTargetPath Include="$(DevServerLaunchConfig)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" TargetPath="spa.proxy.json" />
</ItemGroup>
</Target>
</Project>
{
"/weatherforecast": {
"target": "https://localhost:5001",
"secure": false
}
}
{
"name": "latestreactapp",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"run-script-os": "^1.1.6",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"prestart": "node aspnetcore-https && node aspnetcore-react",
"start": "yarn react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Summary

In .NET 6.0 we are changing the way the SPA templates work to leverage the built-in development server proxy these frameworks ship instead of proxying requests from ASP.NET Core to the proxy backends.

Motivation

The current proxy approach is non-standard, requires us to have runtime support and is the cause of a high number of issues that we don't have the resources to tackle and that lead to a bad experience for our customers.

Goals

  • Use a standard approach for working with SPA applications in ASP.NET Core.
  • Leverage tooling and elements from the existing SPA frameworks.
  • Maintain as close as an experience to the current development experience:
    • Support HTTPs on the different SPA frameworks leveraging the ASP.NET Core HTTPS certificate.
    • Support a single gesture launch experience with dotnet run.
  • Remove runtime support code at development and production time to make sure we are never a blocking factor for our customers.
    • All aspects of how the SPA proxy runs and is launched can be customized via the project settings or the template files.

Non-goals

Scenarios

  • As a developer I want to launch my ASP.NET Core and SPA front-end via dotnet run.
    • HTTPS for the given framework (react/angular) is configured automatically.
    • HTTP requests are routed to the backend API.
  • As a developer I want to launch my SPA front-end and have it use the same port and certificate it uses when launched from VS or via dotnet run.
  • As a developer I want to launch my ASP.NET Core and SPA front-end with F5 in Visual Studio.
  • As a developer I want to launch my ASP.NET Core and SPA front-end with F5 in Visual Studio for Mac.

Risks

  • Configuring API urls for the proxy is a manual process (although we can try and smooth it to be a "one time thing").
  • Switching between kestrel/iis requires changing the target url in proxy.conf.json
  • Process management: If we are not careful, some processes can be left behind.
    • For users running manually or via CLI, we can make them aware of this fact and have them cleanup if necessary.
      • The fact that the proxy might already be running doesn't generally lead to errors.
    • For users running via Visual Studio we will bypass this problem all-together when the proxy is launched by VS instead of our support.

Interaction with other parts of the framework

  • Integration with IIS: When the app is launched via IIS, it opens the proxy launch url. However the app is not actually launched until you hit a url in the backend, which only ever happens, which normally only happens when the SPA triggers a request to the backend. Given that in development the backend is responsible for starting the proxy, this causes an issue since the backend in not started and can't start the proxy.

Detailed design

We tried to maintain the current experience as much as possible and making the new experience as friendly as possible to existing customers and to future improvements where we leverage create-react-app, ng new, and similar CLIs to create the application.

Changes to the SPA files

In addition to the existing files on the template there are a few more files that we are adding:

  • proxy.conf.json -> Takes care of configuring the SPA dev server to proxy requests to the backend. It's standard across all/most front-end frameworks since they leverage webpack-dev-server under the hood.
  • aspnetcore-https.js -> This script takes care of exporting the ASP.NET Core HTTPS development certificate in a format that the SPA framework can consume.
    • It follows the same approach as docker, where it puts the certificate in a special location outside of the scope of the project to avoid accidentally checking it in.
  • aspnetcore-react -> This script is specific to react and takes care of setting up .env.development.local to use the ASP.NET Core certificate, the port and the proxy.config.json files that make the integration with the backend and dotnet run experience possible.
  • package.json:
    • For react, we add a "prestart" script that invokes aspnetcore-https and aspnetcore-react to make sure everything is setup.
    • For angular, we add a "prestart" script to invoke aspnetcore-https and we use run-script-os (new dependency) to launch the app with the correct parameters across different OSs.

Changes to the API project

  • We remove all the SPA specific code from startup since it is no longer needed.
  • We switch publish from dist to wwwroot for the SPA files and they get served via the static files middleware.
  • We add endpoint.MapFallbackToFile("index.html") to map unknown requests to the index.html.

How does dotnet run launch the proxy

  • We have a support library that is plugged in during development via a hosting startup assembly we define in launchSettings.json `"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "<>".
  • This assembly checks for the existence of a spa.proxy.json file and adds a hosted service that is responsible for managing the proxy when found.
  • At build time, we generate the spa.proxy.json file that gets copied to the build output and that contains the following information:
    • ProbeUrl: We use this URL to determine the front-end proxy is ready running and avoid launching it in that case.
    • LaunchCommand: We use this command to launch the proxy from the process if we determine the proxy is not running.
    • MaxTimeoutInSeconds: The amount of time to wait before giving up and producing an error.
  • Our hosted service tries to determine if the proxy is already running and does nothing in that case.
    • Otherwise, it tries to start a new instance of the proxy and after it does so, it probes the ProbeUrl until it receives a successful response.
  • The command used to launch the proxy is configurable via MSBuild parameters, by default they do the following:
    • cmd /c npm/yarn start on Windows.
    • /bin/bash -c npm/yarn start on Mac/Linux.
  • If this doesn't work for a given user they can adjust it for their environment.
  • We aim for this to work 99% of the time and for the majority of scenarios, but for the cases where it doesn't. Our answer is two-fold:
    • Disable it by removing the package/value from launchSettings.json
    • Adjust the command to suit your needs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment