How I set up PureScript projects with ESBuild and Spago
I've been working on a small research project for about two weeks.
Today, it reached an all-too-familiar phase when implementing research papers – the part where I realise I've gone so far off the rails that my code no longer resembles anything within 5 citations of the paper that inspired it.
Fortunately for me, this particular project is tiny - standing at a mere ~2k lines of TypeScript. After about two years of wrangling TypeScript professionally – and ~four years taming it with pet projects – I've mastered a handful of tricks to combat tech-debt and bug prone code. My go-to move? Time Travel. AKA Go back in time and choose a better language.
Anyway, I'm now porting the project to PureScript, and couldn't find a decent reference that explains how I can setup a dead simple web project in it with ESBuild.
I decided to document the (fairly easy) process for myself,
and anyone else who also likes Haskell but is a soyweb-dev.
So here we are, I guess?
Tools
I’m using the following tools as dev-dependencies:
- Node v18
- ESBuild v0.18.1
- PureScript v0.15.9
- purs-tidy v0.10.0
- Spago v0.21.0 (installed globally)
mkdir ps-project && cd ps-project
pnpm init
pnpm add -D purs-tidy purescript esbuild
While not necessary, I also recommend adding purescript-backend-optimizer for generating faster JavaScript.
Spago
As of me writing this, JavaScript and PureScript dependencies have to be managed separately.
All JS dependencies are managed by a JavaScript package manager (pnpm
/npm
/yarn
),
while all PureScript packages are managed by Spago (or pulp, if you prefer that).
Consequently, you’ll need to initialize your directory as a spago project:
spago init
You should now see a spago.dhall
file in your project root.
This is the PureScript equivalent of a package.json
.
There will also be a hello world program in src/Main.purs
.
You can either build your project using spago build
, or run directly it using spago run
:
$ spago run
Hello, world
ESBuild
PureScript v0.15 dropped support for CommonJS modules and the purs bundle
command.
All generated JavaScript in the output
directory now needs to be sewn together by an external bundler.
According to the migration guide
— and to nobody’s surprise — ESBuild outperforms all others for this task.
I use ESBuild for 3 things:
- Importing PureScript functions in JavaScript files.
- Bundling all files generated in #1 into a single [1] JavaScript file.
- Serving the built project on localhost.
# Import purescript functions in `.js/.ts` files, and transpile `.purs` files.
pnpm add -D esbuild-plugin-purescript
# Copy static files to the build directory.
pnpm add -D esbuild-copy-static-files
ESBuild does not transpile PureScript. To do that, you still use Spago.
For most projects, you’ll want to have a build.mjs
file in your project root to save yourself
the trouble of passing a quintillion command line flags to esbuild
every time.
Mine looks like this:
import esbuild from "esbuild"
import pursPlugin from "esbuild-plugin-purescript"
import copyStaticFiles from "esbuild-copy-static-files"
const ctx = await esbuild
.context({
entryPoints: ["src/index.js"],
bundle: true,
outdir: "dist",
plugins: [
// allow importing Purescript modules in JavaScript files.
pursPlugin(),
// copy everything under `static` to `dist`.
copyStaticFiles({ src: "./static", dest: "./dist" })
,
]logLevel: "debug"
}).catch((e) => {
console.error(e)
process.exit(1)
;
})
// you can use a CLI flag for this,
// instead of unconditionally calling `watch` every time.
await ctx.watch()
// same applies to `serve`.
await ctx.serve({ servedir: "./dist", port: 3000 })
I keep all assets and HTML in a static
directory, hence the copyStaticFiles
call.
If you don't have static files, remove that call (or it'll throw an error saying "static" directory doesn't exist).
If you did everything correctly so far, simply entering node ./build.mjs
in your shell should bundle the project [2] into dist/index.js
.
Now, you can also call PureScript from JavaScript (or TypeScript):
-- src/Main.purs
module Main where
import Prelude
import Effect (Effect)
import Effect.Console (log)
= log "Hello, World!" main
// src/index.js
import { main } from "./Main.purs"
main(); // Hello, World!
In your static
directory, you can have an HTML file that references the bundled JS file generated by ESBuild:
<html>
<head>
<title> TypeScript is cope </title>
</head>
<body>
<script src="index.js" type="module"></script>
</body>
</html>
Watch mode and dev-server.
To run spago
and esbuild
in parallel, I use concurrently:
pnpm add -D concurrently
In your package.json
file, add a "dev"
script:
{
"scripts": {
"dev": "concurrently 'spago build --watch' 'node ./build.mjs --watch'"
}
}
You can run this pnpm command to have your project auto-build on every save:
pnpm dev
Next you'll want to... oh, we're done.
Well, If you've made it this far, I can already tell you're going to do well writing PureScript. The error messages are longer than this post :)
Backmatter
- Or multiple, if you've multiple entry points.
- Note that the build script ends in
.mjs
, and uses imports and top-level awaits. This might not work on older versions of node. You can either convert the script to a commonJS file, or upgrade your NodeJS version.