[Blazor] Replace DevServer with BlazorGateway for standalone WASM apps#65982
[Blazor] Replace DevServer with BlazorGateway for standalone WASM apps#65982
Conversation
- Single Program.cs with top-level statements, pure config-driven
- Supports standalone (MSBuild targets) and Aspire (env vars/YARP)
- Gateway targets: _WebAssemblyUserRunParameters to skip WasmAppHost,
AfterTargets ComputeRunArguments with ResolveStaticWebAssetsConfiguration
to resolve manifest paths via Path.Combine(MSBuildProjectDirectory)
- Temporary SpaFallback.targets in testassets: pure MSBuild target that
clones identity index.html endpoint as {**path:nonfile} catch-all
(replaces inline C# task, will be removed when SDK ships
StaticWebAssetSpaFallbackEnabled)
- Update StandaloneApp.csproj to import Gateway + SpaFallback targets
- Extract BlazorGateway.BuildWebHost() into BlazorGateway.cs (shared source) - Update Components.TestServer and BlazorWasmTestAppFixture to use BlazorGateway - Add YARP packages (Yarp.ReverseProxy, ServiceDiscovery) via repo's <Reference> pattern - Add SpaFallback.targets to BasicTestApp, ThreadingApp, and Wasm.Performance.TestApp - Remove Program.BuildWebHost.cs Compile Include from E2ETests (gets it via TestServer ref)
Pass explicit --staticWebAssets, --ClientApps:app:EndpointsManifest, and --ClientApps:app:PathPrefix arguments from call sites instead of deriving manifest paths inside BlazorGateway. Add SpaFallback.targets to GlobalizationWasmApp.
There was a problem hiding this comment.
This is temporary until an SDK with the functionality flows here
There was a problem hiding this comment.
Temporary until the SDK update flows to asp.net core
There was a problem hiding this comment.
Pull request overview
This PR replaces the Blazor WebAssembly DevServer with a new Microsoft.AspNetCore.Components.Gateway host for serving standalone WASM apps in development/testing, and updates test/benchmark assets to use the new Gateway (including SPA fallback routing via static web assets endpoints).
Changes:
- Adds a new Gateway project (
Microsoft.AspNetCore.Components.Gateway) with aBlazorGateway.BuildWebHost(args)entrypoint anddotnet runintegration via MSBuild targets. - Updates Components test infrastructure (E2E + Components.TestServer) to start the Gateway instead of DevServer and passes static web assets manifests explicitly.
- Introduces a temporary
SpaFallback.targetsimported by multiple WASM test apps to add a{**path:nonfile}SPA fallback endpoint.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/WebAssembly/testassets/SpaFallback.targets | New MSBuild targets to inject SPA fallback static web asset endpoint. |
| src/Components/test/testassets/GlobalizationWasmApp/GlobalizationWasmApp.csproj | Imports SPA fallback targets for this test asset. |
| src/Components/test/testassets/Components.TestServer/Program.cs | Switches dev-host scenario to use BlazorGateway and passes manifests via args. |
| src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj | Uses shared-source BlazorGateway.cs, adds YARP refs, and removes transitive framework ref. |
| src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj | Imports SPA fallback targets. |
| src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj | Normalizes project file header/encoding. |
| src/Components/test/E2ETest/Infrastructure/ServerFixtures/BlazorWasmTestAppFixture.cs | Uses BlazorGateway and passes static web assets manifests to host. |
| src/Components/benchmarkapps/Wasm.Performance/TestApp/Wasm.Performance.TestApp.csproj | Imports SPA fallback targets for benchmark app. |
| src/Components/WebAssembly/testassets/ThreadingApp/ThreadingApp.csproj | Imports SPA fallback targets. |
| src/Components/WebAssembly/testassets/StandaloneApp/StandaloneApp.csproj | Imports Gateway run targets and SPA fallback; sets Gateway DLL location override. |
| src/Components/WebAssembly/testassets/SpaFallback.targets | New MSBuild targets to inject SPA fallback static web asset endpoint (Components copy). |
| src/Components/Gateway/src/build/Microsoft.AspNetCore.Components.Gateway.targets | Overrides dotnet run args for WASM projects to launch Gateway with manifest paths. |
| src/Components/Gateway/src/blazor-gateway.runtimeconfig.json.in | Adds runtimeconfig template for the packaged Gateway tool. |
| src/Components/Gateway/src/Program.cs | Gateway entrypoint calling BlazorGateway.BuildWebHost(args).Run(). |
| src/Components/Gateway/src/Microsoft.AspNetCore.Components.Gateway.nuspec | Packages Gateway tool + build targets into the shipping NuGet package. |
| src/Components/Gateway/src/Microsoft.AspNetCore.Components.Gateway.csproj | New shipping project (Web SDK) with YARP/service-discovery references and pack settings. |
| src/Components/Gateway/src/BlazorGateway.cs | Implements Gateway host setup: static web assets, optional YARP proxy, app config endpoints, and static assets mapping per client app. |
| eng/Versions.props | Adds version properties for YARP + service discovery packages. |
| eng/Dependencies.props | Adds YARP + service discovery packages to dependency tracking. |
| <PropertyGroup> | ||
| <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> | ||
| <!-- Point to the locally-built Gateway DLL --> | ||
| <_BlazorGatewayDll>$(RepoRoot)artifacts\bin\Microsoft.AspNetCore.Components.Gateway\Debug\$(DefaultNetCoreTargetFramework)\blazor-gateway.dll</_BlazorGatewayDll> |
There was a problem hiding this comment.
_BlazorGatewayDll is hard-coded to the Debug artifacts path. This will break if the test asset is built/run under a non-Debug configuration (e.g., Release/CI). Consider using
| <_BlazorGatewayDll>$(RepoRoot)artifacts\bin\Microsoft.AspNetCore.Components.Gateway\Debug\$(DefaultNetCoreTargetFramework)\blazor-gateway.dll</_BlazorGatewayDll> | |
| <_BlazorGatewayDll>$(RepoRoot)artifacts\bin\Microsoft.AspNetCore.Components.Gateway\$(Configuration)\$(DefaultNetCoreTargetFramework)\blazor-gateway.dll</_BlazorGatewayDll> |
| /// <summary> | ||
| /// Intended for framework test use only. | ||
| /// </summary> | ||
| public static class BlazorGateway | ||
| { |
There was a problem hiding this comment.
BlazorGateway is declared in the global namespace. For consistency with the rest of the repo (which uses file-scoped namespaces, including the previous DevServer entrypoint) and to avoid potential type name collisions when this file is included as shared source, consider moving it under an appropriate namespace (e.g., Microsoft.AspNetCore.Components.Gateway) and updating call sites accordingly.
| --> | ||
| <Project> | ||
| <PropertyGroup> | ||
| <GenerateStaticWebAssetsManifestDependsOn>$(GenerateStaticWebAssetsManifestDependsOn);_AddSpaFallbackEndpoint</GenerateStaticWebAssetsManifestDependsOn> | ||
| </PropertyGroup> | ||
|
|
||
| <Target Name="_AddSpaFallbackEndpoint"> | ||
| <ItemGroup> | ||
| <!-- Find the identity (uncompressed) index.html endpoint: Route == index.html, AssetFile not .gz/.br --> | ||
| <_SpaFallbackCandidate Include="@(StaticWebAssetEndpoint)" | ||
| Condition="'%(Identity)' == 'index.html' and !$([System.String]::Copy('%(AssetFile)').EndsWith('.gz')) and !$([System.String]::Copy('%(AssetFile)').EndsWith('.br'))" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup Condition="'@(_SpaFallbackCandidate)' != ''"> | ||
| <!-- Add a clone with route changed to the SPA catch-all pattern --> | ||
| <StaticWebAssetEndpoint Include="{**path:nonfile}" | ||
| AssetFile="%(_SpaFallbackCandidate.AssetFile)" | ||
| Selectors="%(_SpaFallbackCandidate.Selectors)" | ||
| EndpointProperties="%(_SpaFallbackCandidate.EndpointProperties)" | ||
| ResponseHeaders="%(_SpaFallbackCandidate.ResponseHeaders)" | ||
| Order="2147483647" /> | ||
| </ItemGroup> | ||
| </Target> |
There was a problem hiding this comment.
The SpaFallback.targets file is duplicated (there’s an identical copy under src/WebAssembly/testassets/SpaFallback.targets). Maintaining two copies risks them drifting and makes imports inconsistent. Consider keeping a single copy in one location and updating imports to reference that one file.
| --> | |
| <Project> | |
| <PropertyGroup> | |
| <GenerateStaticWebAssetsManifestDependsOn>$(GenerateStaticWebAssetsManifestDependsOn);_AddSpaFallbackEndpoint</GenerateStaticWebAssetsManifestDependsOn> | |
| </PropertyGroup> | |
| <Target Name="_AddSpaFallbackEndpoint"> | |
| <ItemGroup> | |
| <!-- Find the identity (uncompressed) index.html endpoint: Route == index.html, AssetFile not .gz/.br --> | |
| <_SpaFallbackCandidate Include="@(StaticWebAssetEndpoint)" | |
| Condition="'%(Identity)' == 'index.html' and !$([System.String]::Copy('%(AssetFile)').EndsWith('.gz')) and !$([System.String]::Copy('%(AssetFile)').EndsWith('.br'))" /> | |
| </ItemGroup> | |
| <ItemGroup Condition="'@(_SpaFallbackCandidate)' != ''"> | |
| <!-- Add a clone with route changed to the SPA catch-all pattern --> | |
| <StaticWebAssetEndpoint Include="{**path:nonfile}" | |
| AssetFile="%(_SpaFallbackCandidate.AssetFile)" | |
| Selectors="%(_SpaFallbackCandidate.Selectors)" | |
| EndpointProperties="%(_SpaFallbackCandidate.EndpointProperties)" | |
| ResponseHeaders="%(_SpaFallbackCandidate.ResponseHeaders)" | |
| Order="2147483647" /> | |
| </ItemGroup> | |
| </Target> | |
| This file now delegates to the shared SpaFallback.targets to avoid duplication. | |
| --> | |
| <Project> | |
| <Import Project="../../../WebAssembly/testassets/SpaFallback.targets" /> |
| Selectors="%(_SpaFallbackCandidate.Selectors)" | ||
| EndpointProperties="%(_SpaFallbackCandidate.EndpointProperties)" | ||
| ResponseHeaders="%(_SpaFallbackCandidate.ResponseHeaders)" | ||
| Order="2147483647" /> |
There was a problem hiding this comment.
This uses a hard-coded magic number for endpoint order. To make intent clearer (and avoid accidental typos), consider using an expression like $([System.Int32]::MaxValue) instead of 2147483647.
| Order="2147483647" /> | |
| Order="$([System.Int32]::MaxValue)" /> |
| endpoints manifest. This will be removed once the SDK ships StaticWebAssetSpaFallbackEnabled. | ||
| --> | ||
| <Project> | ||
| <PropertyGroup> | ||
| <GenerateStaticWebAssetsManifestDependsOn>$(GenerateStaticWebAssetsManifestDependsOn);_AddSpaFallbackEndpoint</GenerateStaticWebAssetsManifestDependsOn> | ||
| </PropertyGroup> | ||
|
|
||
| <Target Name="_AddSpaFallbackEndpoint"> | ||
| <ItemGroup> | ||
| <!-- Find the identity (uncompressed) index.html endpoint: Route == index.html, AssetFile not .gz/.br --> | ||
| <_SpaFallbackCandidate Include="@(StaticWebAssetEndpoint)" | ||
| Condition="'%(Identity)' == 'index.html' and !$([System.String]::Copy('%(AssetFile)').EndsWith('.gz')) and !$([System.String]::Copy('%(AssetFile)').EndsWith('.br'))" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup Condition="'@(_SpaFallbackCandidate)' != ''"> | ||
| <!-- Add a clone with route changed to the SPA catch-all pattern --> | ||
| <StaticWebAssetEndpoint Include="{**path:nonfile}" | ||
| AssetFile="%(_SpaFallbackCandidate.AssetFile)" | ||
| Selectors="%(_SpaFallbackCandidate.Selectors)" | ||
| EndpointProperties="%(_SpaFallbackCandidate.EndpointProperties)" | ||
| ResponseHeaders="%(_SpaFallbackCandidate.ResponseHeaders)" | ||
| Order="2147483647" /> | ||
| </ItemGroup> | ||
| </Target> |
There was a problem hiding this comment.
The SpaFallback.targets file is duplicated (there’s an identical copy under src/Components/WebAssembly/testassets/SpaFallback.targets). Having two identical targets files increases the chance of them diverging over time; consider consolidating to a single shared file and updating imports accordingly.
| endpoints manifest. This will be removed once the SDK ships StaticWebAssetSpaFallbackEnabled. | |
| --> | |
| <Project> | |
| <PropertyGroup> | |
| <GenerateStaticWebAssetsManifestDependsOn>$(GenerateStaticWebAssetsManifestDependsOn);_AddSpaFallbackEndpoint</GenerateStaticWebAssetsManifestDependsOn> | |
| </PropertyGroup> | |
| <Target Name="_AddSpaFallbackEndpoint"> | |
| <ItemGroup> | |
| <!-- Find the identity (uncompressed) index.html endpoint: Route == index.html, AssetFile not .gz/.br --> | |
| <_SpaFallbackCandidate Include="@(StaticWebAssetEndpoint)" | |
| Condition="'%(Identity)' == 'index.html' and !$([System.String]::Copy('%(AssetFile)').EndsWith('.gz')) and !$([System.String]::Copy('%(AssetFile)').EndsWith('.br'))" /> | |
| </ItemGroup> | |
| <ItemGroup Condition="'@(_SpaFallbackCandidate)' != ''"> | |
| <!-- Add a clone with route changed to the SPA catch-all pattern --> | |
| <StaticWebAssetEndpoint Include="{**path:nonfile}" | |
| AssetFile="%(_SpaFallbackCandidate.AssetFile)" | |
| Selectors="%(_SpaFallbackCandidate.Selectors)" | |
| EndpointProperties="%(_SpaFallbackCandidate.EndpointProperties)" | |
| ResponseHeaders="%(_SpaFallbackCandidate.ResponseHeaders)" | |
| Order="2147483647" /> | |
| </ItemGroup> | |
| </Target> | |
| endpoints manifest. This file now forwards to the shared implementation under | |
| src/Components/WebAssembly/testassets/SpaFallback.targets to avoid duplication. | |
| --> | |
| <Project> | |
| <Import Project="..\Components\WebAssembly\testassets\SpaFallback.targets" /> |
| <Reference Include="Microsoft.Extensions.Localization" /> | ||
| </ItemGroup> | ||
|
|
||
| <Import Project="..\..\..\..\WebAssembly\testassets\SpaFallback.targets" /> |
There was a problem hiding this comment.
This import points at the SpaFallback.targets copy under src/WebAssembly/testassets, while other WASM test assets in Components import src/Components/WebAssembly/testassets/SpaFallback.targets. Using a different copy makes it easy for the two files to drift; consider switching to the same shared path (e.g., via $(RepoRoot)) once the duplication is resolved.
| <Import Project="..\..\..\..\WebAssembly\testassets\SpaFallback.targets" /> | |
| <Import Project="$(RepoRoot)src\Components\WebAssembly\testassets\SpaFallback.targets" /> |
Summary
Introduces
Microsoft.AspNetCore.Components.Gateway— a lightweight ASP.NET Core host that replaces the DevServer for serving standalone Blazor WebAssembly applications during development and production.Changes
New: Gateway project (
src/Components/Gateway/src/)BlazorGateway.cs— StaticBuildWebHost(string[] args)method configurable in two modes:--applicationpathCLI arg (used bydotnet runand E2E tests)ClientAppsconfig section for multiple client apps, YARP reverse proxy, config endpointsProgram.cs— Entry point that callsBlazorGateway.BuildWebHost(args).Run()build/Microsoft.AspNetCore.Components.Gateway.targets) — Imported by WASM projects to overridedotnet runbehavior: sets_WebAssemblyUserRunParameters=true, resolves manifest paths viaComputeRunArguments{**path:nonfile}catch-all endpoint to the static web assets endpoints manifest (clones identityindex.htmlendpoint)Updated: E2E test infrastructure
BlazorWasmTestAppFixturenow callsBlazorGateway.BuildWebHost()instead of DevServerComponents.TestServerincludesBlazorGateway.csas shared source and uses it for the "Dev server client-side blazor" scenario<Reference>pattern (eng/Versions.props+eng/Dependencies.props)SpaFallback.targetsimported into BasicTestApp, ThreadingApp, and Wasm.Performance.TestAppKey design decisions
<Compile Include>—BlazorGateway.csis included in test projects as source rather than referenced as a project, avoiding assembly conflicts with the test host'sProgramclass_RemoveTransitiveAspNetCoreFrameworkReferencetarget — In TestServer, removes YARP's transitiveFrameworkReferencetoMicrosoft.AspNetCore.AppafterAddTransitiveFrameworkReferencesbut beforeResolveTargetingPackAssetsTesting
E2E test validation in progress across 5 waves:
StandaloneAppTest.HasTitle,HasHeading,BasicTestAppCanBeServed✅