Nursing Code

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.

  1. It must monitor STDIN and close itself if STDIN gets closed
  2. It must compile SCSS and output to the desired location
  3. 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.