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