StaticFileOptions.OnPrepareResponse never called for ASP.NET Core SPA web app
Asked Answered
L

2

7

I've created a React-based SPA that runs on ASP.NET Core 5.0 but recently encountered an issue after pushing an update with breaking changes to production - users are often seeing errors when trying to access the site because their browser is loading a stale version of the SPA that's incompatible with the changes made to the backend.

Investigation

Digging into this, it seems like the problem is that index.html is being cached for longer than it should and that I should be configuring it to never be cached, based on what I read in these posts:

I came across a person having a similar issue to mine in the post here and followed that through to this solution. After a bit more digging, I also came across a more comprehensive version of that solution in this Github thread and I've based my current attempt on that.

Similar Questions

I found this post that's similar to mine: StaticFileOptions.OnPrepareResponse does not get called for index.html. The difference is that their callback was hit for everything except the the path they were interested in, whereas my callback is never hit at all.

Current Solution Attempt

In my application, I've changed my Startup.Configure method to this:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseSpaStaticFiles(new StaticFileOptions
    {
        OnPrepareResponse = ctx =>
        {
            if (ctx.Context.Request.Path.StartsWithSegments("/static"))
            {
                // Cache all static resources for 1 year (versioned filenames)
                var headers = ctx.Context.Response.GetTypedHeaders();
                headers.CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
                    MaxAge = TimeSpan.FromDays(365),
                };
            }
            else
            {
                // Do not cache explicit `/index.html` or any other files.
                // See also: `DefaultPageStaticFileOptions` below for implicit "/index.html"
                var headers = ctx.Context.Response.GetTypedHeaders();
                headers.CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
                    MaxAge = TimeSpan.FromDays(0),
                };
            }
        },
    });

    app.UseSpa(spa =>
    {
        spa.Options.SourcePath = "spa";
        spa.Options.DefaultPageStaticFileOptions = new StaticFileOptions
        {
            OnPrepareResponse = ctx => {
                // Do not cache implicit `/index.html`.  See also: `UseSpaStaticFiles` above
                var headers = ctx.Context.Response.GetTypedHeaders();
                headers.CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
                    MaxAge = TimeSpan.FromDays(0),
                };
            },
        };

        if (Environment.GetEnvironmentVariable("LOCAL_DEVELOPMENT") == "1")
        {
            spa.UseReactDevelopmentServer(npmScript: "start");
        }
    });
}

However, I find that neither OnPrepareResponse callback seems to be invoked when my SPA loads. If I put a breakpoint in either callback, neither of them get hit. In case it's just an issue with the debugger I made both callbacks throw exceptions but that had no impact on behaviour either. This user at the bottom of the GitHub thread seemed to have a similar problem to me, but he never got a response.

Minimal Code Reproduction

I've tried to minimally reproduce this by creating a new project from the Microsoft SPA templates and adding in the same code as above. However, I still don't see either callback getting invoked in this codebase either. These are the steps I took to set up this minimal codebase:

  • Open a PowerShell window
  • Run dotnet new --install Microsoft.AspNetCore.SpaTemplates::*
  • Run dotnet new reactredux --output temp-spa
  • Set the OnPrepareResponse properties within app.UseSpaStaticFiles() and app.UseSpa() to a simple callback that I could put a breakpoint or Debug.WriteLine() statement in.

I'm using dotnet v6.0.101 and have node v16.3.0 and npm v7.15.1 installed.

Interim Fix

What I have found works is to add the following middleware delegate somewhere before app.UseSpa:

app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        var requestPath = context.Request.Path.Value!;

        if (requestPath.Equals("/"))
        {
            context.Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
            context.Response.Headers.Add("Pragma", "no-cache");
            context.Response.Headers.Add("Expires", "0");
        }

        return Task.FromResult(0);
    });

    await next();
});

When the SPA tries to retrieve the index.html file (at path /), I can see this callback getting invoked and that the server's response contains these cache-control headers:

response cache control headers

However, I'd prefer to achieve this via SpaStaticFiles and I'd like to understand why those callbacks aren't being called.

Update

Reviewing the SPA Static Files Extension source code here, it seems that I'm observing this behaviour because no static file is provided when serving the files using the React Development Server.

I've confirmed that this is the case while stepping into that code with source link enabled: step-into SPA static files extension

I'll play around and see if there's a way to get the web server to serve the files using the SPA static file middleware while I'm debugging the app locally.

Lazos answered 24/1, 2022 at 10:58 Comment(0)
L
3

I've managed to find a way to get the OnPrepareResponse callbacks to work.

My understanding is that these weren't being invoked before because my application is configured to serve static files using a React development server when running in development mode, rather than serving them from the file system. The SPA Static Files Extension is programmed to detect this case and disable static file serving, as shown in the update section of my question.

To workaround this so that I could get these callbacks to fire while debugging locally, I disabled the code to run the React development server and built my SPA separately so that it generated the static files on disk.

Specifically, I updated my Startup.Configure method to the following:

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseSpaStaticFiles(new StaticFileOptions
    {
        OnPrepareResponse = ctx =>
        {
            // Cache all static resources for 1 year (versioned filenames)
            if (ctx.Context.Request.Path.StartsWithSegments("/static"))
            {
                var headers = ctx.Context.Response.GetTypedHeaders();
                headers.CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
                    MaxAge = TimeSpan.FromDays(365),
                };
            }
        },
    });

    app.UseRouting();
    
    app.UseSpa(spa =>
    {
        spa.Options.SourcePath = "spa";
        spa.Options.DefaultPageStaticFileOptions = new StaticFileOptions
        {
            OnPrepareResponse = ctx => {
                // Do not cache implicit `/index.html`.
                var headers = ctx.Context.Response.GetTypedHeaders();
                headers.CacheControl = new CacheControlHeaderValue
                {
                    NoCache = true,
                    NoStore = true,
                    MustRevalidate = true,
                };
            },
        };

        if (Environment.GetEnvironmentVariable("LOCAL_DEVELOPMENT") == "1")
        {
            spa.UseReactDevelopmentServer(npmScript: "start");
        }
    });
}

I then temporarily commented out the last three lines:

    //if (Environment.GetEnvironmentVariable("LOCAL_DEVELOPMENT") == "1")
    //{
    //    spa.UseReactDevelopmentServer(npmScript: "start");
    //}

I opened a PowerShell console in the ClientApp folder and ran yarn && yarn build.

Launching the ASP.NET Core web app, I was then able to see that the index page is loaded with cache-control headers that completely disable caching, and will cause the browser to request a fresh response from the server each time the page is reloaded: index.html cache-control header

Meanwhile, the versioned static assets are configured to be cached for a year, and the browser loads them from its internal cache on page reload: static asset cache-control header

It seems like this would have worked in production just fine, but at least this way I was able to find a way to confirm these callbacks would work properly while running the application locally, and now I have a better idea of why these callbacks weren't being called before.

Lazos answered 26/1, 2022 at 17:31 Comment(0)
P
0

In my case, OnPrepareResponse wasn't called because of the browser caching. Web server didn't serve any static files because they were cached and served by the browser. Clearing the site cache helped (in Chrome it's done by opening dev console (f12), right-clicking the reload icon and pressing Empty Cache and Hard Reload).

Perverted answered 22/4, 2023 at 15:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.