Skip to content

Instantly share code, notes, and snippets.

@ah45
Created December 2, 2016 12:44
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ah45/eeba9d47d133690b51c644b1de80da28 to your computer and use it in GitHub Desktop.
Save ah45/eeba9d47d133690b51c644b1de80da28 to your computer and use it in GitHub Desktop.

F# SPA Live Development

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:

  1. Fable compiles our F# to Javascript modules.
  2. Webpack bundles the Javascript together.
  3. 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:

  1. Fable compiling our F# to Javascript modules in “watch” mode.
  2. A F# server script that wraps our real server in an fsi session.
  3. 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:

  1. The Fable compiler.
  2. An F# web server/fsi session.
  3. 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.

Javascript Compilation

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.

Fable Configuration

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 Configuration

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.

Hot Module Replacement

Webpack Configuration

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

Handling the Updates in F#

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.

F# “Live” Server Script

The development server script needs to do two things:

  1. Run our application server and forward requests to it.
  2. 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.)

Starting Our Live Development Environment

  1. Start Fable compilation:

    node node_modules/fable-compiler -w --symbols HMR
    
  2. Start the F# server:

    fsharpi dev/server.fsx
    
  3. Start webpack-dev-server:

    node node_modules/webpack-dev-server/bin/webpack-dev-server.js
    

Future Enhancements

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment