Skip to content

[Blazor] Replace DevServer with BlazorGateway for standalone WASM apps#65982

Open
javiercn wants to merge 3 commits intomainfrom
javiercn/blazor-gateway
Open

[Blazor] Replace DevServer with BlazorGateway for standalone WASM apps#65982
javiercn wants to merge 3 commits intomainfrom
javiercn/blazor-gateway

Conversation

@javiercn
Copy link
Member

@javiercn javiercn commented Mar 25, 2026

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 — Static BuildWebHost(string[] args) method configurable in two modes:
    • Standalone mode: Derives static web asset manifest paths from --applicationpath CLI arg (used by dotnet run and E2E tests)
    • Aspire mode: Reads ClientApps config section for multiple client apps, YARP reverse proxy, config endpoints
  • Program.cs — Entry point that calls BlazorGateway.BuildWebHost(args).Run()
  • MSBuild targets (build/Microsoft.AspNetCore.Components.Gateway.targets) — Imported by WASM projects to override dotnet run behavior: sets _WebAssemblyUserRunParameters=true, resolves manifest paths via ComputeRunArguments
  • SpaFallback.targets — Temporary target that adds {**path:nonfile} catch-all endpoint to the static web assets endpoints manifest (clones identity index.html endpoint)

Updated: E2E test infrastructure

  • BlazorWasmTestAppFixture now calls BlazorGateway.BuildWebHost() instead of DevServer
  • Components.TestServer includes BlazorGateway.cs as shared source and uses it for the "Dev server client-side blazor" scenario
  • YARP packages added via the repo's <Reference> pattern (eng/Versions.props + eng/Dependencies.props)
  • SpaFallback.targets imported into BasicTestApp, ThreadingApp, and Wasm.Performance.TestApp

Key design decisions

  • YARP baked in — No conditional compilation; the Gateway always supports reverse proxy for future Aspire integration
  • Shared source via <Compile Include>BlazorGateway.cs is included in test projects as source rather than referenced as a project, avoiding assembly conflicts with the test host's Program class
  • _RemoveTransitiveAspNetCoreFrameworkReference target — In TestServer, removes YARP's transitive FrameworkReference to Microsoft.AspNetCore.App after AddTransitiveFrameworkReferences but before ResolveTargetingPackAssets

Testing

E2E test validation in progress across 5 waves:

  • Wave 1 (smoke): StandaloneAppTest.HasTitle, HasHeading, BasicTestAppCanBeServed
  • Wave 2 (BlazorWasmTestAppFixture): All 20 tests ✅
  • Wave 3-5: Core rendering, events, forms, remaining — in progress

- 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)
@github-actions github-actions bot added the area-blazor Includes: Blazor, Razor Components label Mar 25, 2026
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.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is temporary until an SDK with the functionality flows here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary until the SDK update flows to asp.net core

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a BlazorGateway.BuildWebHost(args) entrypoint and dotnet run integration 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.targets imported 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>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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 $(Configuration) (and/or $(ArtifactsBinDir)) instead of the literal "Debug" so the path matches the current build.

Suggested change
<_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>

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +8
/// <summary>
/// Intended for framework test use only.
/// </summary>
public static class BlazorGateway
{
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +26
-->
<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>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
-->
<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" />

Copilot uses AI. Check for mistakes.
Selectors="%(_SpaFallbackCandidate.Selectors)"
EndpointProperties="%(_SpaFallbackCandidate.EndpointProperties)"
ResponseHeaders="%(_SpaFallbackCandidate.ResponseHeaders)"
Order="2147483647" />
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Order="2147483647" />
Order="$([System.Int32]::MaxValue)" />

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +26
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>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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" />

Copilot uses AI. Check for mistakes.
<Reference Include="Microsoft.Extensions.Localization" />
</ItemGroup>

<Import Project="..\..\..\..\WebAssembly\testassets\SpaFallback.targets" />
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<Import Project="..\..\..\..\WebAssembly\testassets\SpaFallback.targets" />
<Import Project="$(RepoRoot)src\Components\WebAssembly\testassets\SpaFallback.targets" />

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants