Customizing Elm compiler options when using Parcel

I ran into the issue at work recently (that others have also encountered) where it’s impossible to configure Elm compiler flags for Parcel. In my case I wanted to enable --optimize, since I was working on some performance-sensitive code and wanted to get quick feedback on how well different optimizations were working without doing a full production build.

The solution we ended up with was making a little Elm ‘proxy’ script (also named elm) that is prepended to the PATH by our build script before running Parcel, so that Parcel finds the proxy instead of Elm itself. The script then tweaks command-line arguments as necessary (removes --debug, adds --optimize, etc.) before invoking the actual Elm compiler. While I was at it I also added the ability to optionally call elm-optimize-level-2 instead of the Elm compiler. The choice of what executable to run (elm or elm-optimize-level-2) and what arguments to pass are controlled by a single environment variable ELM_PROXY_OPTIMIZATION_LEVEL.

Here’s the script in all its glory, in case it’s useful to others - happy to answer any questions! It’s Node-specific and requires you to separately add it to the PATH somehow (we have a build.ts script that does that) but should be able to be adapted for other setups if needed.

#!/usr/bin/env node

/* eslint-env node */


const child_process = require("child_process");
const fs = require("fs");
const path = require("path");


/**
 * Remove --debug from an argument list to disable the Elm debugger.
 * 
 * @param arguments An array of command-line arguments.
 * @returns A modified list of arguments.
 */
function disableDebug(arguments) {
    return arguments.filter(arg => arg != "--debug");
}


/**
 * Add --optimize to an argument list to enable Elm compiler optimisations
 * (implies disableDebug).
 * 
 * @param arguments An array of command-line arguments.
 * @returns A modified list of arguments.
 */
function enableOptimize(arguments) {
    return [...disableDebug(arguments), "--optimize"];
}


/**
 * Keep only those arguments that elm-optimize-level-2 accepts (input and output
 * file names).
 * 
 * @param arguments An array of command-line arguments.
 * @returns A modified list of arguments.
 */
function inputAndOutputOnly(arguments) {
    return arguments.filter(arg => arg.endsWith(".elm") || (arg == "--output") || arg.endsWith(".js"))
}


/**
 * 
 * @param arguments An array of command-line arguments.
 * @returns The string path to the output JavaScript file, or undefined if no
 * output file is specified.
 */
function getOutputFile(arguments) {
    return arguments.find(arg => arg.endsWith(".js"))
}


/**
 * Determine what to actually run based on the desired optimisation level.
 * 
 * @param optimizationLevel One of the optimisation level constants:
 * "DISABLE_DEBUG", "ENABLE_OPTIMIZE" or "ELM_OPTIMIZE_LEVEL_2". If anything
 * else, the Elm compiler will be invoked as normal without any changes.
 * @param elm Path to the Elm compiler.
 * @param elmOptimizeLevel2 Path to elm-optimize-level-2.
 * @param originalArguments Original arguments passed to Elm (by Parcel etc.).
 * @returns An object with 'executable' and 'arguments' fields that can be
 * passed to process.spawn() or similar.
 */
function proxy(optimizationLevel, { elm, elmOptimizeLevel2, originalArguments }) {
    switch (optimizationLevel) {
        case "DISABLE_DEBUG":
            return {
                executable: elm,
                arguments: disableDebug(originalArguments)
            }

        case "ENABLE_OPTIMIZE":
            return {
                executable: elm,
                arguments: enableOptimize(originalArguments)
            }

        case "ELM_OPTIMIZE_LEVEL_2":
            return {
                executable: elmOptimizeLevel2,
                arguments: inputAndOutputOnly(originalArguments)
            }

        default:
            return {
                executable: elm,
                arguments: originalArguments
            }
    }
}


/**
 * Remove the elm-proxy directory from a PATH variable value.
 * 
 * @param originalPath Original (current) value of the PATH variable.
 * @returns A modified PATH string.
 */
function removeElmProxyFromPath(originalPath) {
    return originalPath.split(path.delimiter).filter(entry => !entry.includes("elm-proxy")).join(path.delimiter);
}


/** 
 * Post-process resulting JS so that elm-hot works properly. This is needed
 * since elm-optimize-level-2 reformats the output JavaScript slightly.
 * 
 * @param output A string of JavaScript output from elm-optimize-level-2. 
 * @returns A string of modified output.
 */
function fixupForElmHot(output) {
    // Relevant code from elm-hot (in inject.js):
    //
    //   // attach a tag to Browser.Navigation.Key values. It's not really fair to call this a hack
    //   // as this entire project is a hack, but this is evil evil evil. We need to be able to find
    //   // the Browser.Navigation.Key in a user's model so that we do not swap out the new one for
    //   // the old. But as currently implemented (2018-08-19), there's no good way to detect it.
    //   // So we will add a property to the key immediately after it's created so that we can find it.
    //   const navKeyDefinition = "var key = function() { key.a(onUrlChange(_Browser_getUrl())); };";
    //   const navKeyTag = "key['elm-hot-nav-key'] = true";
    //   modifiedCode = originalElmCodeJS.replace(navKeyDefinition, navKeyDefinition + "\n" + navKeyTag);
    //   if (modifiedCode === originalElmCodeJS) {
    //       throw new Error("[elm-hot] Browser.Navigation.Key def not found. Version mismatch?");
    //   }
    //
    // elm-optimize-level-2 adds a space after 'function' in its output, so the
    // above replace operation fails.
    const originalString = "var key = function () { key.a(onUrlChange(_Browser_getUrl())); };";
    const replacementString = "var key = function() { key.a(onUrlChange(_Browser_getUrl())); };";
    return output.replace(originalString, replacementString);
}


// Find path to node_modules directory
let nodeModules = path.resolve(__dirname, "..", "..", "node_modules");

// Find the path the actual Elm compiler binary
let elm = path.resolve(nodeModules, "elm", "bin", "elm");

// Find the path to elm-optimize-level-2
let elmOptimizeLevel2 = path.resolve(nodeModules, "elm-optimize-level-2", "bin", "elm-optimize-level-2.js");

// Drop the first two command-line arguments ('node' and the path to this script
// file itself)
let originalArguments = process.argv.slice(2);

// Choose what to actually run based on the current optimisation level
let optimizationLevel = process.env.ELM_PROXY_OPTIMIZATION_LEVEL;
let { executable, arguments } =
    proxy(optimizationLevel, {
        elm: elm,
        elmOptimizeLevel2: elmOptimizeLevel2,
        originalArguments: originalArguments
    })

// Remove the elm-proxy directory from the PATH to avoid weird cyclical calls
// (e.g. if elm-optimize-level-2 calling back into this script instead of the
// Elm compiler itself).
let modifiedEnv = { ...process.env, PATH: removeElmProxyFromPath(process.env.PATH) };

// Actually run the chosen executable with the (possibly modified) arguments
let { status } = child_process.spawnSync(executable, arguments, { stdio: "inherit", env: modifiedEnv });

// Post-process the output file so elm-hot works properly (only necessary when
// using elm-optimize-level-2, since it reformats the output slightly in a way
// that confuses elm-hot)
const outputFile = getOutputFile(arguments);
if (outputFile && executable == elmOptimizeLevel2) {
    const contents = fs.readFileSync(outputFile, { encoding: "utf8" });
    const modifiedContents = fixupForElmHot(contents);
    fs.writeFileSync(outputFile, modifiedContents, { encoding: "utf8" });
}

// Exit with the status code from whatever executable was run
process.exit(status);

Note: the hacky fixupForElmHot function should not be necessary in the future =)

13 Likes

Nice!

At work we use patch-package to patch node-elm-compiler. Parcel uses its compileToString function. In it, I changed resolve(data) into resolve(patch(data)) and added function patch(code) { return code.replace("whatever", "something else") }. More or less. (Our use case is to apply the Virtual DOM patch. And we already had patch-package anyway because of node-elm-compiler#91.)

4 Likes

Cool, thanks for sharing!

Nice. Fellow parcel + elm user as well. Thanks for sharing.