Regardless of the deployment approach, Blazor leverages a common component model for developing applications. In theory, this offers a high level of flexibility and reusability as it allows you to develop a single code base which can then be deployed in multiple ways. The ability to write once and run anywhere is a very desirable capability if you want your application to be available in as many forms as possible ( ie. web, mobile, desktop ). This capability is also especially helpful at the present time to workaround some of the limitations in the client-side development model. Specifically it allows you to develop a client-side application using the more productive server-side development approach ( which offers full debugging support in Visual Studio 2019 or VS Code ) and focus your efforts solely on testing your application in the client-side model.
The Problem
Up until Blazor Preview 5 ( which became available in May 2019 ) you could easily support both deployment models in a Blazor application simply by swapping a single JavaScript reference. With a bit of script and a cookie it was trivial to enable a run-time switch which could alternate between hosting models. However, in Preview 5 Microsoft introduced some changes which broke this capability. An issue was logged on Github to bring it back and although it was marked as resolved at the time, subsequent Blazor modifications to support endpoint routing broke this capability once again and it has never been addressed since.
A Solution
From the very beginning, one of the fundamental goals of Oqtane was to support both client-side and server-side models in the same application. As a result we needed a practical solution to this problem. Based on the current Blazor requirements the only viable approach we were able to identify was to utilize the conditional compilation and build configuration capabilities of Visual Studio.
Conditional compilation has been a long-time feature of Visual Studio. It allows you to have a single code base and identify different areas of the code to build based on configurations you define in your project file. The two default build configurations which developers are most familiar with are Debug and Release; however, it is possible to define as many additional build configurations as you need. In our case we configured the Debug and Release configurations to support server-side Blazor ( which makes sense as server-side Blazor is currently the only hosting model which supports an interactive debugging experience ). We created an additional build configuration called "Wasm" which allows us to build and run the project in client-side Blazor mode.
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Wasm|AnyCPU'">
<DefineConstants>TRACE;WASM</DefineConstants>
</PropertyGroup>
The solution is separated into a Client project which contains all of our Razor components, a Server project which contains our back-end controllers and repository logic, and a Shared project which contains shared models and utilities which are common to the Client and Server. The conditional compilation code is centralized in the Program.cs and Startup.cs classes in both the Client and Server projects.
In the Client project our Program.cs contains the following logic:
namespace Oqtane.Client
{
public class Program
{
#if DEBUG || RELEASE
public static void Main(string[] args)
{
}
#endif
#if WASM
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
BlazorWebAssemblyHost.CreateDefaultBuilder()
.UseBlazorStartup<Startup>();
#endif
}
}
You can see how the conditional compilation constants defined in our project file are used in the code above to include different logic during and builds. Server-side Blazor does not need to perform any logic.
Again in the Client project, our Startup.cs contains the following logic:
namespace Oqtane.Client
{
public class Startup
{
#if DEBUG || RELEASE
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IComponentsApplicationBuilder app)
{
}
#endif
#if WASM
public void ConfigureServices(IServiceCollection services)
{
// register any services
}
public void Configure(IComponentsApplicationBuilder app)
{
app.AddComponent<App>("app");
}
#endif
}
}
The code above adds the default App component for client-side Blazor.
Now if we look at the Server project and again focus on Program.cs:
namespace Oqtane.Server
{
public class Program
{
#if DEBUG || RELEASE
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
#endif
#if WASM
public static void Main(string[] args)
{
BuildWebHost(args).Run();
}
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseConfiguration(new ConfigurationBuilder()
.AddCommandLine(args)
.Build())
.UseStartup<Startup>()
.Build();
#endif
}
}
You will notice that server-side Blazor and client-side Blazor use completely different mechanisms for creating the host.
And the Server project Startup.cs:
namespace Oqtane.Server
{
public class Startup
{
public Startup(IWebHostEnvironment env)
{
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
Configuration = builder.Build();
}
#if DEBUG || RELEASE
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
});
}
#endif
#if WASM
public void ConfigureServices(IServiceCollection services)
{
services.AddResponseCompression(opts =>
{
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
new[] { "application/octet-stream" });
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseResponseCompression();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseBlazorDebugging();
}
app.UseClientSideBlazorFiles<Client.Startup>();
app.UseStaticFiles();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapFallbackToClientSideBlazor<Client.Startup>("index.html");
});
}
#endif
}
}
You also need to recognize that there is a need for a wwwroot folder in both the Client and Server projects. These folders contain duplicates of any static assets which are core to your application. The Client project wwwroot folder must contain an index.html file which is the default document for your website. In the Server project the default document is named _Host.cshtml and it is located in the /Pages folder.
Other Considerations
Aside from the project setup above it is important to observe the client/server nature of the application. In order for your application to function in the client-side hosting model you need to isolate the client from the server and use HTTP services to access any external resources such as the database, file system, etc..