Oqtane Blog

The Oqtane Blog is the official communication channel for Oqtane.  Keep up to date with the latest project information. Share your feedback and insights with the community. Guest bloggers are welcome!

Text/HTML

Dual Mode Blazor: Supporting Client-Side And Server-Side In Same Solution

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..

About the Author

Shaun Walker is the original creator of DotNetNuke, a Web Application Framework for ASP.NET which spawned the largest and most successful Open Source community project native to the Microsoft platform. He has 25+ years professional experience in architecting and implementing enterprise software solutions for private and public organizations. Based on his significant community contributions he has been recognized as a Microsoft Most Valuable Professional (MVP) as well as an ASPInsider for over 10 consecutive years. He was recognized by Business In Vancouver in 2011 as a leading entrepreneur in their Forty Under 40 business awards, and is currently the Chairman of the Advisory Council for Microsoft's .NET Foundation. Shaun is currently a Technical Director and Enterprise Guildmaster at Softvision.

What Do You Think?



Comments are closed.