Pieces of the puzzle:
- F#
- Fable
- Webpack
- Suave / Freya / the .NET web framework of your choice
The general picture of how things fit together without live development:
- Fable compiles our F# to Javascript modules.
- Webpack bundles the Javascript together.
- We run a F# web server which includes the Javascript bundle as a static asset.
To enable live development—automatic reloading of the server and hot module replacement of the Javascript in the browser—we need to switch things up a little bit:
- Fable compiling our F# to Javascript modules in “watch” mode.
- A F# server script that wraps our real server in an
fsi
session. webpack-dev-server
providing our compiled assets—with hot module replacement scaffolding in place—and proxying other requests to the F# server.
So, we’re going to have three processes running:
- The Fable compiler.
- An F# web server/
fsi
session. webpack-dev-server
… and requests are similarly going through three layers:
webpack → F# wrapper → our F# app server
… with webpack intercepting requests for our “static” assets and serving them from it’s in-memory compilation cache.
A two step process using Fable to transpile F# to Javascript and then webpack to bundle up the Javascript, optimize it, and include support code for hot module replacement.
Nothing special, just be sure to use a module
format compatible with
webpack and (as a nicety) include source maps:
fableconfig.json
{
"module": "commonjs",
"sourceMaps": true,
"projFile": "path/to/fsproj-or-fsx",
"outDir": "path/to/build/dir"
}
We want this to be as simple and straightforward a translation from F# source to Javascript as possible (so avoid doing anything fancy with Fables Babel and Rollup integrations.)
webpack.config.json
var path = require("path");
var webpack = require("webpack");
var BUILD_DIR = path.resolve(__dirname, 'path/to/fable/build/dir');
var OUTPUT_DIR = path.resolve(__dirname, 'path/to/output/dir');
var cfg = {
devtool: "source-map",
entry: BUILD_DIR + "/EntryPoint.js",
output: {
path: OUTPUT_DIR,
filename: "bundle.js"
},
module: {
preLoaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "source-map-loader"
}
]
}
};
module.exports = cfg;
Again, this is as simple as it can possible be—add to it as desired.
It’s assumed that we have an EntryPoint.fsx
F# script that has been
compiled to EntryPoint.js
and which imports the other modules and
performs any required bootstrapping of the frontend.
We need two additional configuration settings for webpack:
- A
publicPath
for our assets. - A
devServer
proxy declaration.
These are required so that the webpack-dev-server
knows which URI to
serve the compiled assets from and where to forward all other requests
to.
So, in our config map ensure we have:
output: {
publicPath: '/base/uri/for/assets/'
}
… and
devServer: {
host: "0.0.0.0",
port: 8080,
hot: true,
inline: true,
proxy: {
"*": "http://localhost:8081"
}
}
(Replace localhost:8081
with the address to which the F# server has
been bound.)
In order for webpacks hot module replacement to actually work you need to provide handlers to accept/decline and dispose of modules as they get updated.
The simplest way to do this is to include the following in your entrypoint:
#if HMR
open Fable.Core.JsInterop
type IModule =
abstract hot: obj with get, set
let [<Global>] [<Emit("module")>] Module : IModule = failwith "JS only"
if not (isNull Module.hot) then
Module.hot?accept() |> ignore
Module.hot?dispose(ignore) |> ignore
#endif
(If these handlers are missing then webpack will force a page refresh instead of hot loading the modules.)
This will simply accept everything and do nothing on disposal. Note that
it is wrapped in a HMR
symbol which you will need to define when
running the Fable compiler (add --symbols HMR
to the command line.)
In general, whilst accepting everything is fine you’ll likely want/need to add a disposition handler to provide a “clean slate” for the refreshed code to run against.
For example, fable-arch
uses the dispose handler to
remove the root DOM node it mounts components at.
It’s possible to pass data from the dispose
call into the replacement
module (e.g. to transfer any state across reloads.) See the HMR
API for details.
The development server script needs to do two things:
- Run our application server and forward requests to it.
- Monitor for changes to the source files and re
#load
them.
To do the later we need to utilise the FSharp.Compiler.Service
and
maintain an interactive session:
#r @"../packages/build/FSharp.Compiler.Service/lib/net45/FSharp.Compiler.Service.dll"
open Microsoft.FSharp.Compiler.Interactive.Shell
open System
open System.IO
let projectRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))
let appPath = Path.Combine(projectRoot, "path/to/app.fsx")
(* App Reloading *)
let outBuilder = new Text.StringBuilder()
let errBuilder = new Text.StringBuilder()
let fsiSession =
let inStream = new StringReader("")
let outStream = new StringWriter(outBuilder)
let errStream = new StringWriter(errBuilder)
let fsiConfig = FsiEvaluationSession.GetDefaultConfiguration()
let argv =
[| "/fake/fsi.exe"
; "--quiet"
; "--noninteractive"
; "-d:DO_NOT_START_SERVER"
|]
FsiEvaluationSession.Create(fsiConfig, argv, inStream, outStream, errStream)
let reportFsiError (e : exn) =
traceError "reloading app failed"
traceError (sprintf "Message: %s\nError: %s" e.Message (errBuilder.ToString().Trim()))
errBuilder.Clear() |> ignore
let reloadApp () =
try
traceImportant "restarting app"
fsiSession.EvalInteraction(sprintf "#load @\"%s\"" appPath)
fsiSession.EvalInteraction("open App")
match fsiSession.EvalExpression("app") with
| Some app -> Some(app.ReflectionValue :?> WebPart)
| None -> failwith "Couldn't get 'app' value"
with e -> reportFsiError e; None
The important parts of this code are the appPath
,
EvalInteraction("open App")
, and EvalExpression("app")
lines which
dictate where our “real” server will be loaded from. (The appPath
is
the source file containing the App
module which contains an app
var
that has been set to an instance of our application server.)
We then need to put this interactive session/reloader behind a simple proxy and watch for source file changes:
#r @"../packages/Suave/lib/net40/Suave.dll"
open Suave
let hostAddr = "0.0.0.0"
let defaultPort = 8081
let currentApp = ref (fun _ -> async { return None })
let setCurrentApp app =
currentApp.Value <- app
traceImportant "refreshed app"
let rec findPort port =
try
let host = System.Net.IPAddress.Parse(hostAddr)
let tcpListener = System.Net.Sockets.TcpListener(host, port)
tcpListener.Start()
tcpListener.Stop()
port
with :? System.Net.Sockets.SocketException as ex ->
findPort (port + 1)
let getLocalServerConfig port =
{ defaultConfig
with homeFolder = Some projectRoot
logger = Logging.Loggers.saneDefaultsFor Logging.LogLevel.Debug
bindings = [ HttpBinding.mkSimple HTTP hostAddr port ]
}
let reloadAppServer (changedFiles : string seq) =
traceImportant <| sprintf "changes made to %s" (String.Join(",", changedFiles))
reloadApp() |> Option.iter setCurrentApp
let main () =
let app ctx = currentApp.Value ctx
let port = findPort defaultPort
let _, server = startWebServerAsync (getLocalServerConfig port) app
reloadAppServer [appPath]
Async.Start server
let sources =
{ BaseDirectory = projectRoot
Includes = [ "**/*.fs" ]
Excludes = []
}
let reload = Seq.map (fun x -> x.FullPath) >> reloadAppServer
use watcher = WatchChanges reload sources
traceImportant "waiting for source file changes, press any key to stop"
Console.ReadLine() |> ignore
main()
(Note that the above uses Suave to serve the application and would likely need to be adapted to suit other frameworks.)
-
Start Fable compilation:
node node_modules/fable-compiler -w --symbols HMR
-
Start the F# server:
fsharpi dev/server.fsx
-
Start
webpack-dev-server
:node node_modules/webpack-dev-server/bin/webpack-dev-server.js
- Using a webpack loader to compile our F# to Javascript and replace the separate Fable invocation. (See the elm loader, hot loader, and webpack starter for inspiration.)
- Switching to ES2015 modules when webpack 2 is released.