In this post we investigate how to use Razor pages on the host and Bolero WebAssembly on the client side. The final code can be found on My github - Tutorials - BoleroAppRazor.

Create Bolero Razor hosting

This is described in more detail in the Bolero hosting documentation. To create such a project, use:

dotnet new bolero-app --hostpage=razor -o BoleroAppRazor

This creates the following files:

.
├── BoleroAppRazor.sln
└── src
    ├── BoleroAppRazor.Client
    │   ├── BoleroAppRazor.Client.fsproj
    │   ├── Main.fs
    │   ├── Startup.fs
    │   ├── bin
    │   └── wwwroot
    │       ├── css
    │       │   └── index.css
    │       ├── favicon.ico
    │       └── main.html
    └── BoleroAppRazor.Server
        ├── BoleroAppRazor.Server.fsproj
        ├── BookService.fs
        ├── Pages
        │   └── _Host.cshtml
        ├── Startup.fs
        ├── bin
        ├── data
        │   └── books.json

The initally served file is _Host.

Unfortunately, when you try to run this program, it throws the following error:

dotnet run --project src/*Server
info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[62]
      User profile is available. Using '/Users/mmgreiner/.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest.
Hosting environment: Production
Content root path: .../src/BoleroAppRazor.Server
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://localhost:5000/ - -
fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HMPQBS3HOHR9", Request id "0HMPQBS3HOHR9:00000001": An unhandled exception was thrown by the application.
      Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.CompilationFailedException: One or more compilation failures occurred:
      /Users/mmgreiner/Projects/Scrap/BoleroAppRazor/src/BoleroAppRazor.Server/Pages/_Host.cshtml(3,21): error CS0234: The type or namespace name 'RazorHost' does not exist in the namespace 'Bolero.Server' (are you missing an assembly reference?)

You have to change @using Bolero.Server.RazorHost to Bolero.Server.

@page "/"
@namespace BoleroAppRazor.Server
@using Bolero.Server.RazorHost
@inject IBoleroHostConfig BoleroHostConfig
<!DOCTYPE html>
<html>
...

to

@page "/"
@namespace BoleroAppRazor.Server
@using Bolero.Server
@inject IBoleroHostConfig BoleroHostConfig
<!DOCTYPE html>
<html>
...

Now you will get the error:

Hosting environment: Production
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://localhost:5000/ - -
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint '/_Host'
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[103]
      Route matched with {page = "/_Host"}. Executing page /_Host
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[107]
      Executing an implicit handler method - ModelState is Valid
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[109]
      Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[104]
      Executed page /_Host in 235.5552ms
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint '/_Host'
fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HMPQBUHEQ0DL", Request id "0HMPQBUHEQ0DL:00000001": An unhandled exception was thrown by the application.
      System.MissingMethodException: Method not found: 'Elmish.Program`4<!!3,!!4,!!5,!!7> Elmish.ProgramModule.map .....
      

The simplest way to correct is is to change BoleroAppRazor.Client/Main.fs.

type MyApp() =
    inherit ProgramComponent<Model, Message>()

    override this.Program =
        let bookService = this.Remote<BookService>()
        let update = update bookService
        Program.mkProgram (fun _ -> initModel, Cmd.ofMsg GetSignedInAs) update view
        |> Program.withRouter router
#if DEBUG
        |> Program.withHotReload
#endif

Remove the withHotReload.

Now everything works fine. The server side looks like this:

type Startup() =

    // This method gets called by the runtime. Use this method to add services to the container.
    member this.ConfigureServices(services: IServiceCollection) =
        services.AddMvc().AddRazorRuntimeCompilation() |> ignore
        services.AddServerSideBlazor() |> ignore
        services
            .AddAuthorization()
            .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie()
                .Services
            .AddBoleroRemoting<BookService>()
            .AddBoleroHost()
#if DEBUG
            .AddHotReload(templateDir = __SOURCE_DIRECTORY__ + "/../BoleroAppRazor2.Client")
#endif
        |> ignore

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    member this.Configure(app: IApplicationBuilder, env: IWebHostEnvironment) =
        if env.IsDevelopment() then
            app.UseWebAssemblyDebugging()

        app
            .UseAuthentication()
            .UseStaticFiles()
            .UseRouting()
            .UseAuthorization()
            .UseBlazorFrameworkFiles()
            .UseEndpoints(fun endpoints ->
#if DEBUG
                endpoints.UseHotReload()
#endif
                endpoints.MapBoleroRemoting() |> ignore
                endpoints.MapBlazorHub() |> ignore
                endpoints.MapFallbackToPage("/_Host") |> ignore)
        |> ignore

module Program =

    [<EntryPoint>]
    let main args =
        WebHost
            .CreateDefaultBuilder(args)
            .UseStaticWebAssets()
            .UseStartup<Startup>()
            .Build()
            .Run()
        0

Change pages

The pages typically consist of a .cshtml file and the corresponding C# file .cshtml.cs. For our purpose, we have to change all these to F#. For instance:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace BoleroAppRazor.Server.Pages;

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    public void OnGet()
    {
    }
}

is changed to:

namespace BoleroAppRazor.Server.Pages

open Microsoft.AspNetCore.Mvc
open Microsoft.AspNetCore.Mvc.RazorPages

open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging

type IndexModel(_logger: ILogger<IndexModel>) =
    inherit PageModel()

    member this.OnGet() = ()

Don’t forget to add them to the project file .fsproj:

 <ItemGroup>
    <Compile Include="Pages/Index.cshtml.fs" />
    <Compile Include="Pages/Error.cshtml.fs" />
    <Compile Include="Pages/Privacy.cshtml.fs"/>
    <Compile Include="BookService.fs" />
    <Compile Include="Startup.fs" />
  </ItemGroup>

Change layout

Change the starting page:

            .UseEndpoints(fun endpoints ->
#if DEBUG
                endpoints.UseHotReload()
#endif
                endpoints.MapBoleroRemoting() |> ignore
                endpoints.MapBlazorHub() |> ignore
                //endpoints.MapFallbackToPage("/_Host") |> ignore
                endpoints.MapRazorPages() |> ignore // added
            )

The new _layout.cshtml will only call @RenderBody(). The index page Index.cshtml will be calling the Bolero Client.

@page
@model IndexModel
@namespace BoleroAppRazor.Server.Pages
@using Bolero.Server
@inject IBoleroHostConfig BoleroHostConfig
@{
    ViewData["Title"] = "Home page";
}

<div class="content">
    <div id="main1">
        @(await Html.RenderComponentAsync<BoleroAppRazor.Client.Main.MyApp>(BoleroHostConfig))
    </div>

</div>

@Html.RenderBoleroScript(BoleroHostConfig)

Aside: Bulma and navigation

On server, wwwroot/js/nav_bulma.js. Taken from Bulma Navbar.

document.addEventListener('DOMContentLoaded', () => {

    // Get all "navbar-burger" elements
    const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);

    // Add a click event on each of them
    $navbarBurgers.forEach(el => {
        el.addEventListener('click', () => {

            // Get the target from the "data-target" attribute
            const target = el.dataset.target;
            const $target = document.getElementById(target);

            // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
            el.classList.toggle('is-active');
            $target.classList.toggle('is-active');

        });
    });
});