Using SASS with Phoenix 1.6 (alongside esbuild)
In this post I'm going to describe the way I've managed the recent changes in the Phoenix framework that moves from a Webpack build pipeline, to a much simpler esbuild based process.
Phoenix switches to esbuild
Recently the Phoenix team announced a switch away from Webpack to esbuild for javascript building.
This is a great step in my view as esbuild is a super fast modern tool and is much easier to deal with than Webpack.
What's gone, however, is built in support for SASS/SCSS. I miss this, so I set out on a journey to find a way to have my sass back.
Version 1, the naive implementation
Install SASS globally and add it to the watches, in watch
mode.
config :wod, WodWeb.Endpoint,
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
sass: [
"./css/app.scss:../priv/static/assets/app.css",
"--watch"
]
Now we have a problem. When we close the phoenix server, the operating system process running sass
doesn't get terminated. So you're left with a dangling process. Kill and restart your server a few times and you now have many un-terminated instances of sass
running.
The way this is handled by esbuild with Phoenix is through a change Jose suggested, the short version being, monitor STDIN and terminate the application if that gets closed.
A more bespoke way
The sass
cli doesn't seem to support the STDIN monitoring, so the next way was to write a JavaScript file that would do the work for me.
So I add to my watchers
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
node: [
"sass-watch.js",
cd: Path.expand("../assets", __DIR__),
into: IO.stream(:stdio, :line),
stderr_to_stdout: true
]
And create a file that has to handle several things.
- It must monitor STDIN and close itself if STDIN gets closed
- It must compile SCSS and output to the desired location
- It must trigger a rebuild of the SCSS if a file changes
This is where I landed
const sass = require("sass");
const sane = require("sane");
const fs = require("fs");
process.stdin.on("end", () => {
process.exit();
});
process.stdin.resume();
function styleChanged() {
const before = new Date();
const { css } = renderSCSS();
const after = new Date();
const duration = after - before;
console.log(`CSS rebuilt in ${duration}ms`);
fs.writeFileSync("../priv/static/assets/app.css", css);
}
function renderSCSS() {
return sass.renderSync({
file: "css/app.scss",
sourceMapEmbed: true,
});
}
const styleWatcher = sane("css", { glob: ["**/*.scss"] });
styleWatcher.on("ready", styleChanged);
styleWatcher.on("add", styleChanged);
styleWatcher.on("delete", styleChanged);
styleWatcher.on("change", styleChanged);
So what's going on here?
Firstly we're making sure we're watching STDIN and responding if it ends.
process.stdin.on("end", () => {
process.exit();
});
process.stdin.resume();
Next, I'm using a library called sane for file watching. This works really well with another tool called watchman from FaceBook. This combination solves a lot of problems with file watching across platforms.
const styleWatcher = sane("css", { glob: ["**/*.scss"] });
styleWatcher.on("ready", styleChanged);
styleWatcher.on("add", styleChanged);
styleWatcher.on("delete", styleChanged);
styleWatcher.on("change", styleChanged);
Finally there's a very simple function that is called every time a SCSS file changes (and once on initial boot).
There's nothing really exciting happening here, no incremental compilation or diffing to see if we need to do an actual rebuild, just run the function every time a watched file changes.
function styleChanged() {
const before = new Date();
const { css } = renderSCSS();
const after = new Date();
const duration = after - before;
console.log(`CSS rebuilt in ${duration}ms`);
fs.writeFileSync("../priv/static/assets/app.css", css);
}
Production?
One of the things that is cool here is that I no longer need to have a special 'production' step. I can commit the output in priv/static/assets
and have it available as part of my deployed app without a special step.
I haven't added a minification step and am not sure it's even necessary with gzip / brotli compression (plus my CSS payloads are tiny because I'm rarely using frameworks).
Maybe I'm too optimistic, but I feel ike the combo of compression and HTTP/2 or HTTP/3 makes it minification far less worthwhile.