Welcome! I am Loïc Denuzière aka "Tarmil", and this is my technical blog. I'm a French developer who's quite passionate about functional programming, and I've been using F# for most of my work for over ten years.

I've been one of the main developers of WebSharper, the F# web framework with both server-side and JavaScript-compiled client-side tools. Recently I've been focusing more on Bolero, a library that runs F# applications in WebAssembly using Blazor, and other personal projects.

Follow me on Twitter, Mastodon and find me on GitHub.

FSharp.SystemTextJson 1.2 released!

I am happy to announce FSharp.SystemTextJson version 1.2!

FSharp.SystemTextJson is a library that provides support for F# types in .NET's standard System.Text.Json.

Here is a summary of the new features in v1.2.

Skippable Option fields (#154)

Version 1.1 introduced the method JsonFSharpOptions.WithSkippableOptionFields(?bool) that, when set to true, causes None and ValueNone to always be skipped when serialized as the value of a record or union field. However, even with this option left as false, None and ValueNone were still skipped if the JsonSerializerOptions have DefaultIgnoreCondition set to WhenWritingNull.

In version 1.2, a new overload of JsonFSharpOptions.WithSkippableOptionFields takes an enum as argument that brings more possibilities.

  • SkippableOptionFields.Always is equivalent to true: record and union fields equal to None and ValueNone are always skipped.

  • SkippableOptionFields.FromJsonSerializerOptions is equivalent to false: record and union fields equal to None and ValueNone are only skipped if JsonSerializerOptions have DefaultIgnoreCondition set to WhenWritingNull. Otherwise, they are serialized as JSON null.

  • SkippableOptionFields.Never is new: None and ValueNone are never skipped, and always serialized as JSON null.

Handling of dictionary and map keys (#161 and #162)

In version 1.2, FSharp.SystemTextJson now makes use of System.Text.Json's ReadAsPropertyName and WriteAsPropertyName features. This manifests in two ways:

  • Single-case unions can now be used as keys in a standard Dictionary (and related types).
    NOTE: This requires System.Text.Json 8.0.

    let options = JsonFSharpOptions().ToJsonSerializerOptions()
    
    type CountryCode = CountryCode of string
    
    let countries = dict [
        CountryCode "us", "United States"
        CountryCode "fr", "France"
        CountryCode "gb", "United Kingdom"
    ]
    
    JsonSerializer.Serialize(countries, options)
    // --> {"us":"United States","fr":"France","gb":"United Kingdom"}
    
  • The format for maps can now be customized using JsonFSharpOptions.WithMapFormat(MapFormat).

    • MapFormat.Object always serializes maps as objects. The key type must be supported as key for dictionaries.
      NOTE: This requires System.Text.Json 8.0.

      let options = JsonFSharpOptions().WithMapFormat(MapFormat.Object).ToJsonSerializerOptions()
      
      let countries = Map [
          Guid.NewGuid(), "United States"
          Guid.NewGuid(), "France"
          Guid.NewGuid(), "United Kingdom"
      ]
      
      JsonSerializer.Serialize(countries, options)
      // --> {"44e2a549-66c6-4515-970a-a1e85ce42624":"United States", ...
      
    • MapFormat.ArrayOfPairs always serializes maps as JSON arrays whose items are [key,value] pairs.

      let options = JsonFSharpOptions().WithMapFormat(MapFormat.ArrayOfPairs).ToJsonSerializerOptions()
      
      let countries = Map [
          "us", "United States"
          "fr", "France"
          "uk", "United Kingdom"
      ]
      
      JsonSerializer.Serialize(countries, options)
      // --> [["us","United States"],["fr","France"],["uk","United Kingdom"]]
      
    • MapFormat.ObjectOrArrayOfPairs is the default, and the same behavior as v1.1. Maps whose keys are string or single-case unions wrapping string are serialized as JSON objects, and other maps are serialized as JSON arrays whose items are [key,value] pairs.

Other improvements

  • #158: Throw an exception when trying to deserialize null into a record or union in any context, rather than only when they are in fields of records and unions.

  • #163: Add StructuralComparison to the type Skippable<_>. This allows using it with types that are themselves marked with StructuralComparison.

Bug fixes

  • #160: Fix WithSkippableOptionFields(false) not working for voption.

  • #164: When deserializing a record with JsonIgnoreCondition.WhenWritingNull, when a non-nullable field is missing, throw a proper JsonException that includes the name of the field, rather than a NullReferenceException.

Happy coding!

By Loïc "Tarmil" Denuzière on Monday, August 14, 2023

fsharp release library fsharp-systemtextjson Tweet Permalink

FSharp.SystemTextJson 1.1 released!

I am happy to announce FSharp.SystemTextJson version 1.1!

FSharp.SystemTextJson is a library that provides support for F# types in .NET's standard System.Text.Json.

Here is a summary of the new features in v1.1.

Fluent configuration

The library now comes with a new syntax for configuration. Instead of a constructor with a mix of optional arguments and enum flags, you can now use a more consistent fluent syntax.

  • The baseline options are declared using one of these static methods on the JsonFSharpOptions type: Default(), NewtonsoftLike(), ThothLike() or FSharpLuLike().
  • Then, fluent instance methods set various options and return a new instance of JsonFSharpOptions.
  • Finally, a converter with the given options can be either added to an existing JsonSerializerOptions with the method AddToJsonSerializerOptions(), or to a new one with ToJsonSerializerOptions().

For example:

let options =
    JsonFSharpOptions.Default()
        .WithUnionInternalTag()
        .WithUnionNamedFields()
        .WithUnionTagName("type")
        .ToJsonSerializerOptions()

type SomeUnion =
    | SomeUnionCase of x: int * y: string

JsonSerializer.Serialize(SomeUnionCase (1, "test"), options)
// --> {"type":"SomeUnionCase","x":1,"y":"test"}

Note that in the future, newly added options will be available via the fluent configuration, but they may not always be added to the constructor syntax; especially because this can break binary compatibility (see this issue).

Skippable option fields

In version 1.0, the default behavior for fields of type option and voption changed: they are no longer serialized as a present or missing field, and instead as a null field.

While the pre-1.0 behavior can be recovered by using the JsonSerializerOptions property DefaultIgnoreCondition, this has other side-effects and multiple users have asked for a cleaner way to use options for missing fields.

This is now possible with the option SkippableOptionFields. This is the first option that is only available via fluent configuration, and not as a JsonFSharpConverter constructor argument.

let options =
    JsonFSharpOptions.Default()
        .WithSkippableOptionFields()
        .ToJsonSerializerOptions()

JsonSerializer.Serialize({| x = Some 42; y = None |}, options)
// --> {"x":42}

Happy coding!

By Loïc "Tarmil" Denuzière on Saturday, January 21, 2023

fsharp release library fsharp-systemtextjson Tweet Permalink

FSharp.SystemTextJson 1.0 released!

More than three years after the first release, I am happy to announce FSharp.SystemTextJson version 1.0!

FSharp.SystemTextJson is a library that provides support for F# types in .NET's standard System.Text.Json.

Here is a summary of the new features in v1.0.

JsonName attribute

System.Text.Json provides an attribute JsonPropertyName to change the name of a property in JSON. In FSharp.SystemTextJson 1.0, the new attribute JsonName is equivalent but provides more functionality:

  • When used on a discriminated union case, JsonName can take a value of type int or bool instead of string.

    type MyUnion =
        | [<JsonName 1>] One of x: int
        | [<JsonName 2>] Two of y: string
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.Default ||| JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields))
    JsonSerializer.Serialize(Two "two", options)
    // => {"Case":2,"x":"two"}
    
  • JsonName can take multiple values. When deserializing, all these values are treated as equivalent. When serializing, the first one is used.

    type Name =
        { [<JsonName("firstName", "first")>]
          First: string
          [<JsonName("lastName", "last")>]
          Last: string }
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter())
    JsonSerializer.Deserialize<Name>("""{"first":"John","last":"Doe"}""", options)
    // => { First = "John"; Last = "Doe" }
    
    JsonSerializer.Serialize({ First = "John"; Last = "Doe" }, options)
    // => {"firstName":"John","lastName":"Doe"}
    
  • JsonName has a settable property Field: string. It is used to set the JSON name of a union case field with the given name.

    type Contact =
        | [<JsonName("email", Field = "address")>]
          Email of address: string
        | Phone of number: string
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.Default ||| JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields))
    JsonSerializer.Serialize(Email "john.doe@example.com")
    // => {"Case":"Email","email":"john.doe@example.com"}
    

Record properties

By default, FSharp.SystemTextJson only serializes the fields of a record. There are now two ways to also serialize their properties:

  • The option includeRecordProperties: bool enables serializing all record properties (except those that have the attribute JsonIgnore, just like fields).

    type User =
        { id: int
          name: string }
    
        member this.profileUrl = $"https://example.com/user/{this.id}/{this.name}"
    
        [<JsonIgnore>]
        member this.notIncluded = "This property is not included"
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter(includeRecordProperties = true))
    JsonSerializer.Serialize({ id = 1234; name = "john.doe" })
    // => {"id":1234,"name":"john.doe","profileUrl":"https://example.com/user/1234/john.doe"}
    
  • The attribute JsonInclude can be used on a specific property to serialize it.

    type User =
        { id: int
          name: string }
    
        [<JsonInclude>]
        member this.profileUrl = $"https://example.com/user/{this.id}/{this.name}"
    
        member this.notIncluded = "This property is not included"
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter())
    JsonSerializer.Serialize({ id = 1234; name = "john.doe" })
    // => {"id":1234,"name":"john.doe","profileUrl":"https://example.com/user/1234/john.doe"}
    

BREAKING CHANGE: Missing fields

In FSharp.SystemTextJson 0.x, using default options, missing fields of type option or voption would be deserialized into None or ValueNone. This was unintended behavior, which is corrected in version 1.0: these missing fields now throw an error. To restore the previous behavior, either enable the option IgnoreNullValues = true, or or use the type Skippable instead of option or voption.

Additionally, the option DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull is now treated as a synonym for IgnoreNullValues = true.

type Name =
    { firstName: string
      lastName: string option }

let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options)
// => JsonException

let options2 = JsonSerializerOptions(IgnoreNullValues = true)
options2.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options2)
// => { firstName = "John"; lastName = None }

let options3 = JsonSerializerOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)
options3.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options3)
// => { firstName = "John"; lastName = None }

type NameWithSkippable =
    { firstName: string
      lastName: Skippable<string> }

let options4 = JsonSerializerOptions()
options4.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options4)
// => { firstName = "John"; lastName = Skip }

Built-in support in .NET 6 and JsonFSharpTypes

In .NET 6, support has been added in System.Text.Json for a number of F# types. This support is different from FSharp.SystemTextJson in a number of ways:

  • Records, tuples, lists, sets, maps: null is accepted by the deserializer, and returns a null value.
  • Records: missing fields are deserialized to default value instead of throwing an error.
  • Maps: only primitive keys are supported. Numbers and booleans are converted to string and used as JSON objet keys.
  • Tuples: only supports up to 8 items, and serializes it as a JSON object with keys "Item1", "Item2", etc.
  • Discriminated unions, struct tuples: not supported.

FSharp.SystemTextJson takes over the serialization of these types by default; but the option types: JsonFSharpTypes allows customizing which types should be serialized by FSharp.SystemTextJson, and which types should be left to System.Text.Json.

let options = JsonSerializerOptions()
// Only use FSharp.SystemTextJson for records and unions:
options.Converters.Add(JsonFSharpOptions(types = (JsonFSharpTypes.Records ||| JsonFSharpTypes.Unions)))

JsonSerializer.Serialize(Map [(1, "one"); (2, "two")], options)
// => {"1":"one","2":"two"}
// whereas FSharp.SystemTextJson would have serialized as:
// => [[1,"one"],[2,"two"]]

Happy coding!

By Loïc "Tarmil" Denuzière on Sunday, September 25, 2022

fsharp release library fsharp-systemtextjson Tweet Permalink

FSharp.Data.LiteralProviders 1.0 is here!

I am happy to announce that the library FSharp.Data.LiteralProviders has reached version 1.0!

FSharp.Data.LiteralProviders is an F# type provider library that provides compile-time constants from various sources, such as environment variables or files:

open FSharp.Data.LiteralProviders

// Get a value from an environment variable, another one from a file,
// and pass them to another type provider.

let [<Literal>] ConnectionString = Env<"CONNECTION_STRING">.Value
let [<Literal>] GetUserDataQuery = TextFile<"GetUserData.sql">.Text

type GetUserData = FSharp.Data.SqlCommandProvider<GetUserDataQuery, ConnectionString>

let getUserData (userId: System.Guid) =
    GetUserData.Create().Execute(UserId = userId)

Here is a summary of the new features in v1.0.

Running an external command

The Exec provider runs an external command during compilation and provides its output.

open FSharp.Data.LiteralProviders

let [<Literal>] Branch = Exec<"git", "branch --show-current">.Output

More options are available to pass input, get the error output, the exit code, etc. See the documentation.

Conditionals

The sub-namespaces String, Int and Bool provide a collection of compile-time conditional operators for the corresponding types.

For example, you can compare two integer values with Int.LT; combine two booleans with Bool.OR; or choose between two strings with String.IF.

open FSharp.Data.LiteralProviders

// Compute the version: get the latest git tag, and add the branch if it's not master or main.

let [<Literal>] TagVersion = Exec<"git", "describe --tags">.Output

let [<Literal>] Branch = Exec<"git", "branch --show-current">.Output

// Note: the `const` keyword is an F# language quirk, necessary when nesting type providers.
let [<Literal>] IsMainBranch =
    Bool.OR<
        const(String.EQ<Branch, "master">.Value),
        const(String.EQ<Branch, "main">.Value)
    >.Value

let [<Literal>] Version =
    String.IF<IsMainBranch,
        Then = TagVersion,
        Else = const(TagVersion + "-" + Branch)
    >.Value

See the documentation for all the operators available.

Value as int and as bool

The providers try to parse string values as integer and as boolean. If any of these succeed, a value suffixed with AsInt or AsBool is provided.

open FSharp.Data.LiteralProviders

let [<Literal>] runNumberAsString = Env<"GITHUB_RUN_NUMBER">.Value // eg. "42"

let [<Literal>] runNumberAsInt = Env<"GITHUB_RUN_NUMBER">.ValueAsInt // eg. 42

By Loïc "Tarmil" Denuzière on Friday, May 27, 2022

fsharp release library literalproviders Tweet Permalink

Managing page-specific models in Elmish

This article is part of F# Advent Calendar 2019.

GUI applications (web or otherwise) often display their content in one of a number of pages. You can have a login page, a dashboard page, a details page for a type of item, and so on. Whichever page is currently displayed generally has some state (or model, in Elmish parlance) that only makes sense for this page, and can be dropped when switching to a different page. In this article we'll look into a few ways that such a page-specific model can be represented in an Elmish application. The code uses Bolero, but the ideas can apply to any Elmish-based framework, like Fable-Elmish or Fabulous.

Our running example will be a simple book collection app with two pages: a list of books with a text input to filter the books by title, and a book details page.

type Page =
    | List
    | Book of isbn: string

type Book =
    { isbn: string // We use the ISBN as unique identifier
      title: string
      publishDate: DateTime
      author: string }

Note that there can also be some state which, although only used by one page, should be stored in the main model anyway because it needs to persist between page switches. For example, in our application, we don't want to reload the list of all book summaries whenever we switch back to the List page, so we will always store it in the main model.

Page model in the main model

One way to go is to just store each page's model as a field of the main application model. However, we quickly encounter a problem: the state for all pages needs to be initialized from the beginning, not just the initial page.

type ListPageModel =
    { filter: string }
    
type BookPageModel = Book

type Model =
    { page: Page
      books: Book list
      list: ListPageModel
      book: BookPageModel }

let initialModel =
    { page = List
      books = []
      list = { filter = "" }
      book = ??? // What should we put here?
    }

We can of course use an option:

type Model =
    { // We don't need to store the page anymore,
      // since that is determined by which model is Some.
      // page: Page
      books: Book list
      list: ListPageModel option
      book: BookPageModel option }

let initialModel =
    { books = []
      list = Some { filter = "" }
      book = None }

But this violates a principle that I would rather keep true: illegal states should be unrepresentable. What this means is that it is possible to put the application in a nonsensical state, where the page is Book but the book is None. Our update and view functions will have to deal with this using partial functions (ie. functions that aren't correct for all possible input values, and throw exceptions otherwise) such as Option.get.

type BookMsg =
    | SetAuthor of string
    // ...

type Msg =
    | Goto of Page
    | ListMsg of ListMsg
    | BookMsg of BookMsg
    // ...

let update msg model =
    match msg with
    | BookMsg bookMsg ->
        let bookModel = Option.get model.book // !!!!
        let bookModel, cmd = updateBook bookMsg bookModel
        { model with book = Some bookModel }, Cmd.map BookMsg cmd
    // ...

let view model dispatch =
    match model.page with
    | Book isbn ->
        let bookModel = Option.get model.book // !!!!
        bookView bookModel (dispatch << BookMsg)
    // ...

Additionally, when switching pages, in addition to initializing the state of the new page, we may need to make sure that we set the model of other pages to None. In this particular example, each model is very light, so it doesn't really matter; but if there are many different pages and some of their models are large in memory, this can become a concern.

let update msg model =
    match msg with
    | Goto List ->
        { model with
            list = Some { filter = "" }
            book = None // Don't forget this!
        }, Cmd.none
    | Goto (Book isbn) ->
        match model.books |> List.tryFind (fun book -> book.isbn = isbn) with
        | Some book ->
            { model with
                list = None // Don't forget this!
                book = Some book
            }, Cmd.none
        | None ->
            model, Cmd.ofMsg (Error ("Unknown book: " + isbn))
    // ...

Despite these inconvenients, this style is a good choice for an application whose page are organized in a stack, where each page is only accessed directly from a parent page. Actually, the fact that the model can contain several page states becomes an advantage when doing page transition animations, since during the animation, two pages are in fact displayed on the screen. In particular, this is quite common for mobile applications. Because of this, it is a recommended style in Fabulous, as shown by the sample application FabulousContacts.

Page model in a union

Separate page union and page model union

An alternative is to store the page model as a union, with one case per page just like the Page union, but with models as arguments.

type PageModel =
    | List of ListPageModel
    | Book of BookPageModel

type Model =
    { page: PageModel
      books: Book list }

let initialModel =
    { page = PageModel.List { filter = "" }
      books = [] }

The model is now correct by construction: it is not possible to accidentally construct an inconsistent state.

Unfortunately the types still allow receiving eg. a BookMsg when the current page is not Book; but such messages can just be ignored. A nice way to do this is to match on the message and the page together:

let update msg model =
    match msg, model.page with
    | ListMsg listMsg, List listModel ->
        let listModel, cmd = updateList listMsg listModel
        { model with page = List listModel }, Cmd.map ListMsg cmd
    | ListMsg _, _ -> model, Cmd.none // Ignore irrelevant message
    | BookMsg bookMsg, Book bookModel ->
        let bookModel, cmd = updateBook bookMsg bookModel
        { model with page = Book bookModel }, Cmd.map BookMsg cmd
    | BookMsg _, _ -> model, Cmd.none // Ignore irrelevant message
    // ...

Note: we could handle all irrelevant messages at once in a final | _ -> model, Cmd.none, but then we would lose the exhaustiveness check on msg. So if later we add a message but forget to handle it, the compiler wouldn't warn us.

As before, when switching to a page, the initial model is decided in the update handler for the Goto message.

let update msg model =
    match msg with
    | Goto Page.List ->
        let pageModel = PageModel.List { filter = "" }
        { model with page = pageModel }, Cmd.none
    | Goto (Page.Book isbn) ->
        match model.books |> List.tryFind (fun book -> book.isbn = isbn) with
        | Some book ->
            { model with page = PageModel.Book book }, Cmd.none
        | None ->
            model, Cmd.ofMsg (Error ("Unknown book: " + isbn))
    // ...

Bolero's PageModel<'T>

Bolero contains a facility to handle such a page model style. It is essentially the same as the previous style, with some internal magic to avoid the need for a separate union type while still playing nice with Bolero's automatic URL routing system.

Separate Elmish program

Finally, I have recently been experimenting with a way to sidestep the whole question of how to embed the messages and models of pages into the main message and model entirely: make each page a separate Elmish program.

This is a style that I haven't seen used in Fable or Fabulous, and in fact I have no idea whether it is possible to use it in those frameworks. In Bolero, while it is still buggy and requires changes to the library itself, I hope to be able to make it available soon.

The idea is that we will have a root Program that will contain the common model (here, the list of books) and dispatch page switches to a nested ProgramComponent. Each page is a different ProgramComponent, with its own model and message types.

Of course, each page still needs to be able to receive a model from the parent program (the list of books for List, and the book as initial model for Book), and to dispatch messages to the main update. These two values can be passed to the component as Blazor parameters. This is the base type that will be implemented by our page components:

[<AbstractClass>]
type NestedProgramComponent<'inModel, 'rootMsg, 'model, 'msg>() =
    inherit ProgramComponent<'model, 'msg>()

    let mutable oldInModel = Unchecked.defaultof<'inModel>

    [<Parameter>]
    member val InModel = oldInModel with get, set
    [<Parameter>]
    member val RootDispatch = Unchecked.defaultof<Dispatch<'rootMsg>> with get, set

    override this.OnParametersSet() =
        if not <| obj.ReferenceEquals (oldInModel, this.InModel) then
            oldInModel <- this.InModel
            this.Rerender()

For example, the Book component is implemented as follows:

type BookComponent() as this =
    inherit NestedProgramComponent<BookModel, Msg, BookModel, BookMsg>()
    
    let update message model =
        // Use this.RootDispatch to send messages to the root program
        // ...
        
    let view model dispatch =
        // ...

    override this.Program =
        Program.mkProgram (fun _ -> this.InModel, Cmd.none) update view

and with a convenience function to instantiate nested program components:

module Html =
    open Bolero.Html

    let ncomp<'T, 'inModel, 'rootMsg, 'model, 'msg
                when 'T :> NestedProgramComponent<'inModel, 'rootMsg, 'model, 'msg>>
            (inModel: 'inModel) (rootDispatch: Dispatch<'rootMsg>) =
        comp<'T> ["InModel" => inModel; "RootDispatch" => rootDispatch] []

we can include the appropriate page component inside the main view:

let view model dispatch =
    cond model.page <| function
    | List ->
        ncomp<ListComponent,_,_,_,_> model.books dispatch
    | Book isbn ->
        cond (model.books |> List.tryFind (fun book -> book.isbn = isbn)) <| function
        | Some book ->
            ncomp<BookComponent,_,_,_,_> book dispatch
        | None ->
            textf "Unknown book: %s" isbn

Conclusion

The above approaches each have their advantages and inconvenients. They can even be mixed and matched, depending on how persistent different pages' models needs to be across page switches. Don't be afraid to experiment!

By Loïc "Tarmil" Denuzière on Tuesday, December 17, 2019

fsharp fsbolero elmish Tweet Permalink

Desktop applications with Bolero and WebWindow

Steve Sanderson recently published WebWindow: a library that runs a web page in a desktop window, piloted from .NET. In particular, it can run Blazor applications natively on the desktop with minimal changes to their code. Unlike client-side Blazor, this doesn't involve any WebAssembly: the Blazor code runs in .NET and interacts directly with the web page.

The Blazor sample app running on WebWindows

This is pretty cool. Although it is contained in a web window like an Electron application, it runs with the speed of a native .NET application and comes in a much smaller package.

Obviously, as soon as I saw it, I had to try to use it with Bolero, my own F# layer for Blazor. As it turns out, it runs quite well! Here's a simple working application; let's see how to create it from scratch.

Creating a Bolero app on WebWindow, step by step

First, if you don't have it yet, install the .NET Core 3.0 SDK and the Bolero project template:

dotnet new -i Bolero.Templates

We can now create a Bolero application.

dotnet new bolero-app --minimal --server=false -o MyBoleroWebWindowApp
cd MyBoleroWebWindowApp

The full template contains a few pages and uses things like remoting that we would need to untangle for this example, so we'll go for the --minimal template instead. Also, we don't want to create an ASP.NET Core host application, so we use --server=false.

We now have a solution with a single project, src/MyBoleroWebWindowApp.Client, which will directly be our executable. Let's fixup the project file MyBoleroWebWindowApp.Client.fsproj.

  • First, this is not a web project:

     <?xml version="1.0" encoding="utf-8"?>
    -<Project Sdk="Microsoft.NET.Sdk.Web">
    +<Project Sdk="Microsoft.NET.Sdk">
    
  • Second, we need to target .NET Core 3.0 and create an executable:

       <PropertyGroup>
    -    <TargetFramework>netstandard2.0</TargetFramework>
    +    <TargetFramework>netcoreapp3.0</TargetFramework>
    +    <OutputType>WinExe</OutputType>
       </PropertyGroup>
    
  • Now that we removed the Web SDK, the wwwroot will not automatically included in the published application anymore. But we still need our assets!

       <ItemGroup>
    +    <Content Include="wwwroot\**">
    +      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    +    </Content>
         <Compile Include="Main.fs" />
         <Compile Include="Startup.fs" />
       </ItemGroup>
    
  • Finally, the NuGet references. We need to remove the Blazor build packages that compile our project into a WebAssembly application, and instead add WebWindow.

       <ItemGroup>
         <PackageReference Include="Bolero" Version="0.10.1-preview9" />
    -    <PackageReference Include="Bolero.Build" Version="0.10.1-preview9" />
    -    <PackageReference Include="Microsoft.AspNetCore.Blazor.Build" Version="3.0-preview9.*" />
    -    <PackageReference Include="Microsoft.AspNetCore.Blazor.DevServer" Version="3.0-preview9.*" />
    +    <PackageReference Include="WebWindow.Blazor" Version="0.1.0-20191120.6" />
       </ItemGroup>
     </Project>
    

The main program, Startup.fs, needs a bit of change to start as a WebWindow application rather than a WebAssembly one. Luckily, Steve made this very easy:

 module Program =
+    open WebWindows.Blazor
 
     [<EntryPoint>]
     let Main args =
-        BlazorWebAssemblyHost.CreateDefaultBuilder()
-            .UseBlazorStartup<Startup>()
-            .Build()
-            .Run()
+        ComponentsDesktop.Run<Startup>("My Bolero app", "wwwroot/index.html")
         0

And finally, the small JavaScript script that boots Bolero is in a different location, so we need to touch wwwroot/index.html:

-    <script src="_framework/blazor.webassembly.js"></script>
+    <script src="framework://blazor.desktop.js"></script>

And with this, we're all set! Run the application using your IDE or from the command line:

dotnet run -p src/MyBoleroWebWindowApp.Client

Note: if you're using Visual Studio, make sure to remove the file Properties/launchSettings.json it may have created while the SDK was still Web; otherwise, it will try (and fail) to run your project with IIS Express.

The Bolero minimal app running on WebWindow

We're on our way! Although since we created a project using the --minimal template, this is pretty empty. Quite literally, if you look at Main.fs:

let view model dispatch =
    empty

Because of this empty view, we're only seeing the banner that is present statically in wwwroot/index.html. Let's make sure that Bolero is indeed running by implementing the "hello world" of the Elmish world, the Counter app, in Main.fs:

module MyBoleroWebWindowApp.Client.Main

open Elmish
open Bolero
open Bolero.Html

type Model = { counter: int }

let initModel = { counter = 0 }

type Message =
    | Increment
    | Decrement

let update message model =
    match message with
    | Increment -> { model with counter = model.counter + 1 }
    | Decrement -> { model with counter = model.counter - 1 }

let view model dispatch =
    concat [
        button [on.click (fun _ -> dispatch Decrement)] [text "-"]
        textf " %i " model.counter
        button [on.click (fun _ -> dispatch Increment)] [text "+"]
    ]

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

    override this.Program =
        Program.mkSimple (fun _ -> initModel) update view

And now, if we run again:

The Bolero counter app running on WebWindow

Hurray!

What next?

This is just an experiment to see if Bolero would "just work" with WebWindow and, well, it pretty much does. As Steve said in his blog article, WebWindow itself is an experiment with no promises of developing it into a proper product. But it is still pretty cool, and I want to see how far we can combine it with Bolero. What about remoting with an ASP.NET Core server? Or HTML template hot reloading? These will probably need some adjustments to work nicely with WebWindow, and I think I'll experiment some more with these.

By Loïc "Tarmil" Denuzière on Sunday, November 24, 2019

fsharp fsbolero Tweet Permalink