With ASP.NET Core Preview 4, the ASP.NET Core team introduced a new experience for SPA templates. The main benefit of this new experience is that it’s possible to start and stop the backend and client projects independently. This is a very welcome change and speeds up the development process. But it also includes another more controversial change. The old templates served the client application as part of the ASP.NET Core host and forwarded the requests to the SPA. With the new templates, the URL of the SPA is used to run the application, and requests to the backend get forwarded by a built-in proxy of the SPA dev server.

This blog post describes an alternative approach using YARP. It works similar to the old templates, but with the advantage of the new templates to start and stop the backend and client projects independently.

The following graphic shows the differences:

guid.new, softwareentwicklung, angular, ASPNET Core, YARP

The full source code can be found on GitHub and the package is available on NuGet.

The new experience for SPA templates in .NET 6

Let’s take a closer look first at how this new experience works behind the scenes:

  • A reference to the AspNetCore.SpaProxy NuGet package gets added to the ASP.NET Core project that contains the SPA project.
  • The proxy can be configured with additional settings in the project file (SpaRoot, SpaProxyServerUrl, SpaProxyLaunchCommand).
  • The SpaProxy package contains a target file that generates a spa.proxy.json file based on the settings in the project file during the build.
  • An additional environment variable gets set in the launchSettings.json file ("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy").
  • When the ASP.NET Core project gets started using this launchSettings.json file and a spa.proxy.json file exists, the proxy services and middleware get added via a HostingStartup attribute and an IStartupFilter.
  • The middleware checks if the SPA application is already running at the configured URL and starts it if it's not running.
  • As soon as the SPA application is running the user gets redirected to the URL of the client application. During development, we run the application using the URL of the SPA dev server.
  • Requests to the backend get handled by the SPA dev server and forwarded to the backend using a proxy of the SPA dev server.

As mentioned by others in the GitHub issue, this has some drawbacks:

  • It is different from production where the ASP.NET Core app serves the client application and no proxy is used for backend calls.
  • The SPA frameworks must provide a dev server that supports proxying.
  • It requires a different configuration for different SPA frameworks and dev servers.
  • We are limited by the features the dev server offers. New HTTP/3 features that are available as a preview in .NET 6, gRPC, WebSockets, or things like Windows authentication may not work.

This doesn’t mean that the new templates are a bad thing. They are a big improvement compared to the old ones and it’s absolutely fine to use them. One advantage for frontend developers is that they must start only the SPA on their devices and can use a backend hosted somewhere else.

Using YARP

This approach works in the opposite direction. We use the URL of the ASP.NET Core host to run the application and requests that are not handled by the host get forwarded to the SPA dev server using YARP.

Even though it works differently, internally it’s very similar to the new SPA templates and I was able to reuse a lot of code from the SpaProxy project. This is how it works:

  • Use the AspNetCore.SpaYarp package.
  • It uses similar settings in the project file (SpaRoot, SpaClientUrl, SpaLaunchCommand)
  • The SpaYarp package generates a spa.proxy.json file too.
  • Unlike the SpaProxy that adds the services and middleware automagically, the SpaYarp services and middleware must be added explicitly. In my opinion, it’s easier to understand and easier to customize.
    Use services.AddSpaYarp() and app.UseSpaYarp()
  • When the ASP.NET Core project gets started, the middleware checks if the SPA application is already running at the configured URL and starts it if it's not running.
  • As soon as the SPA dev server is available, the proxy gets activated and forwards the requests.

Using this approach works differently from production too, but it’s less of a problem to proxy the requests to the SPA dev server because it usually serves only static files. A drawback of this approach is that a frontend developer must start the ASP.NET Core host project too to run the SPA locally.

As mentioned before, I am reusing some code of the SpaProxy project. This includes some classes (SpaProxyLaunchManager and SpaProxyMiddleware) and the targets file. The main change I made was to rename the options to better fit this approach.

The SpaProxyMiddleware uses the SpaProxyLaunchManager to check if the SPA dev server is already running. If it’s not running, the SPA dev server gets started in a new process using the SpaLaunchCommand. While the dev server is starting, the host shows a temporary page that refreshes every few seconds. And when it’s ready, YARP is used to forward all unhandled requests.

YARP is a very powerful and flexible reverse proxy library. The setup with all the possible configurations can be scary at first glance. But fortunately, it supports a very simple setup for direct forwarding without using any advanced proxy features.

Let’s take a look at the code to set up everything. The first important file is the AspNetCore.SpaYarp.targets file. It takes the settings from the project file and generates the spa.proxy.json file. The generated file can be found in the build output folder. But it’s not included in the publish output (using dotnet publish). When the AspNetCore.SpaYarp package gets installed via NuGet the target file gets referenced automatically.

<Project>

  <Target Name="WriteSpaConfigurationToDisk" BeforeTargets="AssignTargetPaths">
    <PropertyGroup>
      <_SpaProxyServerLaunchConfig>$(IntermediateOutputPath)spa.proxy.json</_SpaProxyServerLaunchConfig>
      <_SpaRootFullPath>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(SpaRoot)').Replace('\','\\'))</_SpaRootFullPath>
      <SpaProxyTimeoutInSeconds Condition="'$(SpaProxyTimeoutInSeconds)' == ''" >120</SpaProxyTimeoutInSeconds>
    </PropertyGroup>
    <ItemGroup>
      <_SpaProxyServerLaunchConfigLines Include="{" />
      <_SpaProxyServerLaunchConfigLines Include="  &quot;SpaProxyServer&quot;: {" />
      <_SpaProxyServerLaunchConfigLines Include="    &quot;ClientUrl&quot;: &quot;$(SpaClientUrl)&quot;," />
      <_SpaProxyServerLaunchConfigLines Include="    &quot;LaunchCommand&quot;: &quot;$(SpaLaunchCommand)&quot;," />
      <_SpaProxyServerLaunchConfigLines Include="    &quot;WorkingDirectory&quot;: &quot;$(_SpaRootFullPath)&quot;," />
      <_SpaProxyServerLaunchConfigLines Include="    &quot;MaxTimeoutInSeconds&quot;: &quot;$(SpaProxyTimeoutInSeconds)&quot;" />
      <_SpaProxyServerLaunchConfigLines Include="  }" />
      <_SpaProxyServerLaunchConfigLines Include="}" />
    </ItemGroup>
    <WriteLinesToFile File="$(_SpaProxyServerLaunchConfig)" Lines="@(_SpaProxyServerLaunchConfigLines)" WriteOnlyWhenDifferent="true" Overwrite="true" />
    <ItemGroup>
      <ContentWithTargetPath Include="$(_SpaProxyServerLaunchConfig)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" TargetPath="spa.proxy.json" />
      <FileWrites Include="$(_SpaProxyServerLaunchConfig)" />
    </ItemGroup>
  </Target>

</Project>

The second step is to add the services via the AddSpaYarp extension method. It checks first if the spa.proxy.json file exists and adds the services only if the file is present. As mentioned before, the generated file is not included in the publish output (using dotnet publish) and therefore the proxy will not be used in that case.

/// <summary>
/// Adds required services and configuration to use the SPA proxy.
/// The services get only added if a "spa.proxy.json" file exists.
/// </summary>
/// <param name="services">The service collection.</param>
public static void AddSpaYarp(this IServiceCollection services)
{
    var spaProxyConfigFile = Path.Combine(AppContext.BaseDirectory, "spa.proxy.json");
    if (File.Exists(spaProxyConfigFile))
    {
        var configuration = new ConfigurationBuilder()
            .AddJsonFile(spaProxyConfigFile)
            .Build();

        services.AddHttpForwarder();
        services.AddSingleton<SpaProxyLaunchManager>();
        services.Configure<SpaDevelopmentServerOptions>(configuration.GetSection("SpaProxyServer"));
    }
}

And the last step is to add the middlewares to the pipeline using the UseSpaYarp extension method. The middlewares get only added if the services were added before. This means if the spa.proxy.json does not exist and the services get not added, the proxy middlewares are not used.

/// <summary>
/// Adds the middlewares for the SPA proxy to the pipeline.
/// The middlewares get only added if the 'spa.proxy.json' file exists and the SpaYarp services were added (there is a check for the SpaProxyLaunchManager).
/// </summary>
/// <param name="app">The web application used to configure the HTTP pipeline, and routes.</param>
/// <returns>The web application.</returns>
public static WebApplication UseSpaYarp(this WebApplication app)
{
    var spaProxyLaunchManager = app.Services.GetService<SpaProxyLaunchManager>();

    if (spaProxyLaunchManager == null)
    {
        return app;
    }

    app.UseMiddleware<SpaProxyMiddleware>();

    // configure the proxy
    var forwarder = app.Services.GetRequiredService<IHttpForwarder>();
    var spaOptions = app.Services.GetRequiredService<IOptions<SpaDevelopmentServerOptions>>().Value;

    // Configure our own HttpMessageInvoker for outbound calls for proxy operations
    var httpClient = new HttpMessageInvoker(new SocketsHttpHandler()
    {
        UseProxy = false,
        AllowAutoRedirect = false,
        AutomaticDecompression = DecompressionMethods.None,
        UseCookies = false
    });

    var transformer = new CustomTransformer();
    var requestOptions = new ForwarderRequestConfig { Timeout = TimeSpan.FromSeconds(100) };

    app.Map("/{**catch-all}", async httpContext =>
    {
        var error = await forwarder.SendAsync(httpContext, spaOptions.ClientUrl, httpClient, requestOptions, transformer);
        // Check if the proxy operation was successful
        if (error != ForwarderError.None)
        {
            var errorFeature = httpContext.Features.Get<IForwarderErrorFeature>();
            var exception = errorFeature?.Exception;
        }
    });

    return app;
}

/// <summary>
/// Custom request transformation
/// </summary>
private class CustomTransformer : HttpTransformer
{
    /// <summary>
    /// A callback that is invoked prior to sending the proxied request. All HttpRequestMessage
    /// fields are initialized except RequestUri, which will be initialized after the
    /// callback if no value is provided. The string parameter represents the destination
    /// URI prefix that should be used when constructing the RequestUri. The headers
    /// are copied by the base implementation, excluding some protocol headers like HTTP/2
    /// pseudo headers (":authority").
    /// </summary>
    /// <param name="httpContext">The incoming request.</param>
    /// <param name="proxyRequest">The outgoing proxy request.</param>
    /// <param name="destinationPrefix">The uri prefix for the selected destination server which can be used to create
    /// the RequestUri.</param>
    public override async ValueTask TransformRequestAsync(HttpContext httpContext, HttpRequestMessage proxyRequest, string destinationPrefix)
    {
        // Copy all request headers
        await base.TransformRequestAsync(httpContext, proxyRequest, destinationPrefix);

        // Suppress the original request header, use the one from the destination Uri.
        proxyRequest.Headers.Host = null;
    }
}

This is the final Program.cs file that sets up everything:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// Like with Microsoft.AspNetCore.SpaProxy, a 'spa.proxy.json' file gets generated based on the values in the project file (SpaRoot, SpaProxyClientUrl, SpaProxyLaunchCommand).
// This file gets not published when using "dotnet publish".
// The services get not added and the proxy is not used when the file does not exist.
builder.Services.AddSpaYarp();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller}/{action=Index}/{id?}");

// The middlewares get only added if the 'spa.proxy.json' file exists and the SpaYarp services were added.
app.UseSpaYarp();

// If the SPA proxy is used, this will never be reached.
app.MapFallbackToFile("index.html");

app.Run();

Recap

We took a closer look at how the new ASP.NET Core SPA templates are working, and I mentioned some drawbacks. But this doesn’t mean that the new templates are a bad thing. They are a big improvement compared to the old ones and it’s absolutely fine to use them. But if you hit one of the limitations, I showed an alternative approach using YARP. This approach works differently from production too, but it’s less of a problem to proxy the requests to the SPA dev server because it usually serves only static files. A drawback of this approach is that a frontend developer must start the ASP.NET Core host project too to run the SPA locally. Both options have their pros and cons, so choose what works best for you.