A Real-World PureScript FE Build Setup
In my previous article, I introduced my favorite functional programming language — PureScript. I demonstrated a very basic build system built on npm scripts, and ran through some basic tasks like running a unit tests or sending an Ajax request.
The simple build scripts in my previous article work just fine, but when you want to use PureScript for a full-featured front end application this method proves lacking. What I want for a serious setup is easy FFI with support for Babel, bundling with Webpack for the client, and easy interop with Node for the server. My solution isn’t perfect yet, but I’ve eliminated enough friction that I feel like it’s suitable for day-to-day use.
A Better Build Tool
To start out with, I wanted to switch to a build tool that would be more maintainable than npm scripts but not as heavy as something like Gulp. I especially wanted to find good concurrency support. I found all that in Ygor, a refreshingly minimal task runner that uses async/await.
$ npm install --save-dev ygor
To use it, I create a quick script in my top-level project directory called bin/build
and make it executable.
$ touch bin/build
$ chmod +x bin/build
You can see the full contents of the file here, but we’ll go over the important bits below.
To use the script, I add the following to my npm scripts:
"scripts": {
"build": "bin/build",
}
Now I can use npm run build
to run the “default” task, or npm run build javascript
to run the “javascript” task. I can also use Ygor’s “subtask” feature with this, so I can use npm run build javascript watch
to run the “watch” subtask of the “javascript” task.
Setting paths and defining utilities
The first thing I do in the build file is set up some paths and define a utility to extend the NODE_PATH
:
const PROJECT_DIR = path.dirname(__dirname)
const SRC = `${PROJECT_DIR}/src`
const nodePath = extend => `NODE_PATH=${extend}:${process.env.NODE_PATH || ''}`
Next I create a few utilities that I’ll use in the tasks:
function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function babel (options = '') {
return `${nodePath(SRC)} babel src --source-maps --out-dir ${SRC} ${options}`
}
function pulp (cmd) {
return `${nodePath(SRC)} pulp ${cmd}`
}
The “sleep” function allows me to await sleep(ms)
, which becomes important when we want to coordinate processes in the background.
The “babel” and “pulp” commands run their respective utilities with my preferred options. I create some basic tasks, like “lint” and “clean”, and then move on the the real work.
Babel for FFI
For various reasons, the PureScript compiler parses the JavaScript for FFI modules directly. The library it relies on for this has not been updated to support ES-next features. I tried several different ways to work around this, and the method I’ve found the easiest is to name my FFI modules with the “.es” extension (which stands for EcmaScript). Babel compiles them to the same “src” directory, but with the “.js” extension.
I don’t want these files saved to source control, however, since they are automatically generated. I add the following lines to my “.gitignore” file to make sure they aren’t committed:
# Transpiled FFI modules
/src/**/*.js
/src/**/*.map
I create the following tasks for Babel:
async function javascript () {
await lint()
ygor.shell(babel())
}
async function javascriptWatch () {
await lint()
return exec(babel('-w')).then(result => console.log(result.stdout))
}
In both cases, I’m waiting for my “Standard JS” lint process to finish before proceeding. When I want to launch a JavaScript watcher in the background, I use these two lines:
javascriptWatch()
await sleep(1000) // Give the watch command time to start up
This spawns the Babel process in the background, and gives it a second to start watching before moving to any blocking operations.
Now, I’m ready to hook this into the rest of the Ygor build. I add a basic command for “purescript”:
async function purescript () {
await javascript()
ygor.shell(pulp('build -- --censor-codes=UnusedFFIImplementations'))
}
I’ve given a passthrough option to psc
through pulp build
— --censor-codes=UnusedFFIImplementations
. When Babel transpiles a file, it exports a small identifier property called __esModule
. The compiler will complain about these, saying “The following definitions in the foreign module for module My.Module are unused: __esModule”. This option suppresses that.
You’ll also notice that this is an “async” function, and I’m using the “await” keyword with javascript()
. This is because the javascript()
function returns a promise (via the child-process-promise module). Using the “await” keyword allows me to wait until the promise is resolved before continuing. This ensures that your transpiled FFI modules will be ready when the “pulp” build runs.
If you run this task, you should first see the Babel output, and then a successful Pulp build!
Running tests
Now that I’ve got Babel for FFI working, let’s get my tests running!
function test () {
ygor.shell(pulp('test -- --censor-codes=UnusedFFIImplementations'))
}
That’s all I need! My tests should run as usual now.
Hooking in Webpack
To bundle my application for the client with Webpack, I add the following task:
async function client () {
await javascript()
ygor.shell(`${nodePath(SRC)} webpack --config=webpack.${process.env.NODE_ENV}.js --color=always --progress`)
}
This waits for a Babel build, then launches a webpack process. I have my NODE_ENV
environment variable set to development
by default in my dotfiles, so I use that to differentiate between my webpack.development.js
and webpack.production.js
. I force color on because I like the pretty, and I turn on the progress meter.
Here’s what my Webpack config looks like.
I’m using purs-loader to handle building PureScript for the browser. Here’s how I have it configured for development:
loaders: [{
test: /\.purs$/,
loader: 'purs',
exclude: /node_modules/,
query: {
psc: 'psa',
src: ['bower_components/purescript-*/src/**/*.purs', 'src/**/*.purs'],
warnings: false
}
}, {
My entry point is set to a “*.js” shim, which simply imports my desired PureScript module and executes a function from it.
entry: './src/SelectFrom/Init.js',
Here’s what the shim looks like (written as “Init.es”, and transpiled to “Init.js”):
import {init} from './Client.purs'
init()
My particular Client.purs
file will look different than yours, but here’s how mine creates a div to contain a React application, and renders it.
In a later article, I’ll talk about how I’m using React directly and importing JSX into my PureScript application.
Launching a local dev server
My final challenge was to launch a local dev server with the Webpack dev middleware included for every day development. As you can see from my Webpack config, I’m using html-webpack-plugin
to generate an index.html
for a fully client-side single-page application. In a later article, I’ll talk about a universal setup for server rendering. For now, however, we’ll just focus on the browser.
I’m going to re-use a solution from an earlier post here, and use a custom Express server that uses the dev middleware, rather than using the Webpack dev server directly. This allows me to setup the special routing I need for single page apps.
Here’s my dev server source. I’m skipping the hot reloading middleware from my previous post because I haven’t tested that with PureScript yet.
I’d like to run this with the convenience of “pulp run”, so I create the following task in my bin/build
:
async function dev () {
javascriptWatch()
await sleep(1000) // Give the watch command time to start up
ygor.shell(pulp('run --main SelectFrom.Server.Development'))
}
I launch the watcher in the background, and then invoke “pulp run” with the “—main” option pointing to a small PureScript shim I created to import my dev server, called SelectFrom/Server/Development.purs
. It sits alongside SelectFrom/Server/Development.es
, so that it can use my dev server as an FFI module.
module SelectFrom.Server.Development (main) where
import Node.Express.Types (ExpressM)
import Node.HTTP (Server)
foreign import main :: forall eff. ExpressM eff Server
I’m borrowing my Express server type from prescript-express
. Now, with the “--main" option pointed at this module, “pulp run” launches my dev server and kicks off a Webpack watcher! I can now visit http://localhost:3000
and see whatever it is I rendered in my Client.init
!
Bonus points — compile & test watching
To close out my whirlwind post, I’ll share my command for launching pscid
in this setup so that it auto-runs the tests each time a PureScript module is changed:
// Intended to be run with "dev" in another terminal window
function watch () {
ygor.shell(`${nodePath(SRC)} pscid --test --censor-codes=UnusedFFIImplementations`)
}
And with that, you’re ready to embark on your own serious project with PureScript!