Skip to content

Commit a571de5

Browse files
committed
Fix standalone Blazor WASM hot reload in Gateway
Use the full web application builder when dynamic code is available so dotnet watch and IDE hosting startup middleware can run. Keep the slim builder for NativeAOT, and prevent conditional SPA fallback responses from bypassing browser-refresh script injection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 25710030-aafc-4ad2-9163-82e077b402a7
1 parent 8c1a406 commit a571de5

5 files changed

Lines changed: 188 additions & 2 deletions

File tree

src/Components/Gateway/src/BlazorGateway.cs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Runtime.CompilerServices;
45
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
6+
using Microsoft.AspNetCore.StaticAssets;
57
using Microsoft.Extensions.Diagnostics.HealthChecks;
8+
using Microsoft.Net.Http.Headers;
69
using OpenTelemetry;
710
using OpenTelemetry.Metrics;
811
using OpenTelemetry.Trace;
@@ -19,7 +22,9 @@ public static class BlazorGateway
1922
/// Reads ClientApps config section for endpoint manifests and YARP reverse proxy configuration.
2023
/// </summary>
2124
public static WebApplication BuildWebHost(string[] args) =>
22-
BuildWebHost(WebApplication.CreateSlimBuilder(args));
25+
BuildWebHost(RuntimeFeature.IsDynamicCodeSupported
26+
? WebApplication.CreateBuilder(args)
27+
: WebApplication.CreateSlimBuilder(args));
2328

2429
internal static WebApplication BuildWebHost(WebApplicationBuilder builder)
2530
{
@@ -113,13 +118,57 @@ internal static WebApplication BuildWebHost(WebApplicationBuilder builder)
113118

114119
if (!string.IsNullOrEmpty(appConfig.EndpointsManifest))
115120
{
116-
app.MapGroup(appConfig.PathPrefix ?? "").MapStaticAssets(appConfig.EndpointsManifest);
121+
var staticAssets = app.MapGroup(appConfig.PathPrefix ?? "").MapStaticAssets(appConfig.EndpointsManifest);
122+
if (app.Environment.IsDevelopment())
123+
{
124+
staticAssets.Add(DisableSpaFallbackCaching);
125+
}
117126
}
118127
}
119128

120129
return app;
121130
}
122131

132+
private static void DisableSpaFallbackCaching(EndpointBuilder endpointBuilder)
133+
{
134+
if (endpointBuilder is not RouteEndpointBuilder { Order: int.MaxValue, RequestDelegate: { } next } ||
135+
GetStaticAssetDescriptor(endpointBuilder) is not { Route: var route } ||
136+
!route.StartsWith("{**", StringComparison.Ordinal))
137+
{
138+
return;
139+
}
140+
141+
endpointBuilder.RequestDelegate = context =>
142+
{
143+
// The dotnet-watch middleware injects its browser refresh script into the response body.
144+
// A conditional response has no body, so preserve the old DevServer's no-store behavior.
145+
context.Request.Headers.Remove(HeaderNames.IfNoneMatch);
146+
context.Request.Headers.Remove(HeaderNames.IfModifiedSince);
147+
context.Response.OnStarting(static state =>
148+
{
149+
var response = (HttpResponse)state;
150+
response.Headers[HeaderNames.CacheControl] = "no-store";
151+
152+
return Task.CompletedTask;
153+
}, context.Response);
154+
155+
return next(context);
156+
};
157+
}
158+
159+
private static StaticAssetDescriptor? GetStaticAssetDescriptor(EndpointBuilder endpointBuilder)
160+
{
161+
foreach (var metadata in endpointBuilder.Metadata)
162+
{
163+
if (metadata is StaticAssetDescriptor descriptor)
164+
{
165+
return descriptor;
166+
}
167+
}
168+
169+
return null;
170+
}
171+
123172
private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder, BlazorGatewayOptions.TelemetryOptions telemetry)
124173
{
125174
builder.Logging.AddOpenTelemetry(logging =>

src/Components/Gateway/test/BlazorGatewayTests.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
using Microsoft.Extensions.DependencyInjection;
1111
using Microsoft.Extensions.Hosting;
1212

13+
[assembly: HostingStartup(typeof(Microsoft.AspNetCore.Components.Gateway.BlazorGatewayTests.TestHostingStartup))]
14+
1315
namespace Microsoft.AspNetCore.Components.Gateway;
1416

1517
public class BlazorGatewayTests
1618
{
19+
private const string HostingStartupHeaderName = "X-Test-Hosting-Startup";
20+
1721
[Fact]
1822
public async Task HealthChecks_ReturnsOk_InDevelopment_WithDefaultOptions()
1923
{
@@ -216,6 +220,51 @@ public async Task BuildWebHost_StartsWithHttpsUrl_WhenKestrelCertificateConfigur
216220
Assert.Contains(app.Urls, address => address.StartsWith("https://127.0.0.1:", StringComparison.Ordinal));
217221
}
218222

223+
[Fact]
224+
public async Task BuildWebHost_RunsConfiguredHostingStartup()
225+
{
226+
var hostingStartupAssembly = typeof(TestHostingStartup).Assembly.GetName().Name!;
227+
await using var app = BlazorGateway.BuildWebHost(
228+
[
229+
"--environment", Environments.Development,
230+
"--hostingStartupAssemblies", hostingStartupAssembly,
231+
"--urls", "http://127.0.0.1:0",
232+
]);
233+
234+
await app.StartAsync();
235+
236+
using var client = new HttpClient
237+
{
238+
BaseAddress = new Uri(app.Urls.Single()),
239+
};
240+
var response = await client.GetAsync("/health");
241+
242+
Assert.Equal("true", response.Headers.GetValues(HostingStartupHeaderName).Single());
243+
}
244+
245+
[Fact]
246+
public async Task SpaFallback_DisablesDocumentCachingInDevelopment()
247+
{
248+
await using var gateway = await StartSpaFallbackGatewayAsync(Environments.Development);
249+
250+
var response = await gateway.Client.SendAsync(CreateConditionalDocumentRequest());
251+
252+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
253+
Assert.True(response.Headers.CacheControl?.NoStore);
254+
Assert.Contains("Gateway", await response.Content.ReadAsStringAsync());
255+
}
256+
257+
[Fact]
258+
public async Task SpaFallback_PreservesDocumentCachingInProduction()
259+
{
260+
await using var gateway = await StartSpaFallbackGatewayAsync(Environments.Production);
261+
262+
var response = await gateway.Client.SendAsync(CreateConditionalDocumentRequest());
263+
264+
Assert.Equal(HttpStatusCode.NotModified, response.StatusCode);
265+
Assert.False(response.Headers.CacheControl?.NoStore);
266+
}
267+
219268
private static bool IsRedirect(HttpStatusCode status) =>
220269
status is HttpStatusCode.MovedPermanently
221270
or HttpStatusCode.Found
@@ -229,4 +278,55 @@ private static Task<GatewayUnderTest> StartGatewayAsync(
229278
string environment,
230279
Dictionary<string, string?> configuration) =>
231280
GatewayTestHelpers.StartGatewayAsync(environment, configuration);
281+
282+
private static async Task<GatewayUnderTest> StartSpaFallbackGatewayAsync(string environment)
283+
{
284+
var webRoot = Path.Combine(AppContext.BaseDirectory, "TestAssets");
285+
var manifest = Path.Combine(webRoot, "test.staticwebassets.endpoints.json");
286+
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
287+
{
288+
EnvironmentName = environment,
289+
WebRootPath = webRoot,
290+
});
291+
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
292+
{
293+
["ClientApps:app:EndpointsManifest"] = manifest,
294+
});
295+
builder.WebHost.UseTestServer();
296+
297+
var app = BlazorGateway.BuildWebHost(builder);
298+
await app.StartAsync();
299+
300+
return new GatewayUnderTest(app);
301+
}
302+
303+
private static HttpRequestMessage CreateConditionalDocumentRequest()
304+
{
305+
var request = new HttpRequestMessage(HttpMethod.Get, "/");
306+
request.Headers.Accept.ParseAdd("text/html");
307+
request.Headers.Add("Sec-Fetch-Dest", "document");
308+
request.Headers.TryAddWithoutValidation("If-None-Match", "\"test-etag\"");
309+
310+
return request;
311+
}
312+
313+
public sealed class TestHostingStartup : IHostingStartup
314+
{
315+
public void Configure(IWebHostBuilder builder) =>
316+
builder.ConfigureServices(services => services.AddSingleton<IStartupFilter, TestStartupFilter>());
317+
}
318+
319+
public sealed class TestStartupFilter : IStartupFilter
320+
{
321+
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) =>
322+
app =>
323+
{
324+
app.Use(async (context, next) =>
325+
{
326+
context.Response.Headers[HostingStartupHeaderName] = "true";
327+
await next(context);
328+
});
329+
next(app);
330+
};
331+
}
232332
}

src/Components/Gateway/test/Microsoft.AspNetCore.Components.Gateway.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<Reference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" />
2323
<Reference Include="Yarp.ReverseProxy" />
2424
<Content Include="$(SharedSourceRoot)TestCertificates\*.pfx" LinkBase="shared\TestCertificates" CopyToOutputDirectory="PreserveNewest" />
25+
<Content Include="TestAssets\**" CopyToOutputDirectory="PreserveNewest" />
2526
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="$(OpenTelemetryExporterOpenTelemetryProtocolVersion)" />
2627
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="$(OpenTelemetryExtensionsHostingVersion)" />
2728
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="$(OpenTelemetryInstrumentationAspNetCoreVersion)" />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<!DOCTYPE html><body>Gateway</body>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"Version": 1,
3+
"ManifestType": "Build",
4+
"Endpoints": [
5+
{
6+
"Route": "{**fallback:nonfile}",
7+
"AssetFile": "index.html",
8+
"Order": "2147483647",
9+
"Selectors": [],
10+
"EndpointProperties": [],
11+
"ResponseHeaders": [
12+
{
13+
"Name": "Cache-Control",
14+
"Value": "no-cache"
15+
},
16+
{
17+
"Name": "Content-Length",
18+
"Value": "36"
19+
},
20+
{
21+
"Name": "Content-Type",
22+
"Value": "text/html"
23+
},
24+
{
25+
"Name": "ETag",
26+
"Value": "\"test-etag\""
27+
},
28+
{
29+
"Name": "Last-Modified",
30+
"Value": "Fri, 28 Aug 2026 00:00:00 GMT"
31+
}
32+
]
33+
}
34+
]
35+
}

0 commit comments

Comments
 (0)