ranz2nix - A new approach to packaging node packages with Nix

A few weeks ago I was asked to create a simple online gallery for my sisters wedding pictures. The whole process of selecting the right software probably deserves a blog post on it’s own. In the end I decided to go with photoprism as it looked like the best solution (on multiple scales).

The unfortunate fact was that they are using Node.js to build their frontend application which meant that I had to use node2nix, yarn2nix, … or to roll my own solution. A few attempts into using node2nix (paired with previous experiences) I decided that it isn’t worth my time. I don’t mean to discredit the work that was done there but for me it simply doesn’t solve the problem.

Mostly I am not a fan of generating mostly duplicate Nix code out of some other source repository when all the information (the native package manager requires) are already available. I could never figure out how to properly patch the generated node2nix expressions without resorting to manually calling patch as part of the upgrade process. It also doesn’t really work without checking out the projects source code and running the code generation within it.

After all I just wanted to use niv to pin and update it without requiring further recurring manual steps.

I started looking at the contents of the package-lock.json to see if it contains anything of use for an alternative approach and was pleasantly surprised.

Unlike many other lock file formats it is just plain JSON that contains both the SRI hash and the source URI of each of the locked dependencies:

{
  "name": "photoprism",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "requires": true,
  "dependencies": {
    "@babel/cli": {
      "version": "7.10.4",
      "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.10.4.tgz",
      "integrity": "sha512-xX99K4V1BzGJdQANK5cwK+EpF1vP9gvqhn+iWvG+TubCjecplW7RSQimJ2jcCvu6fnK5pY6mZMdu6EWTj32QVA==",

This means I can just fetch all of the dependencies with Nix without writing a single line of Node.js or caring about internals of npm like dependency resolution (and circular dependencies).

But how does that help?

Well, iff NPM uses a “fetcher” that supports file:// URI’s I can rewrite the lockfile in such a way that the resolved values point to nix store paths without invalidating the integrity hashes. By doing this we no longer need network access during the build and everything can be provided within the build sandbox. The sources will be made available since the patched lockfile will carry them as dependencies.

At this stage all that is left is actually rewriting the contents of the file. Since Nix has builtin support for JSON so it was straight forward to recursively updating all the dependencies in the file:

{
  lockFilePath
}:
let
  # …
 lockFile = builtins.fromJSON (builtins.readFile lockFilePath);
  # …
  patchedLockfile = pkgs.writeTextFile {
    name = "package-lock.json";
    destination = "/package-lock.json";
    text = (
      let
        patchDep = sourcesList: name: v:
          let
            dsources = lib.mapAttrs (mkSource) (v.dependencies or { });
            src = findBestSource sourcesList name;
          in
          (v // {
            dependencies = lib.mapAttrs (patchDep ([ dsources ] ++ sourcesList)) (v.dependencies or { });
          }) // (if src == null then packageOverride name v else {
            resolved = "file://" + (toString src);
          }
          );

        file = lockFile // {
          dependencies = lib.mapAttrs (patchDep [ sources ]) (lockFile.dependencies or { });
        };
      in
      builtins.toJSON file
    );
  };
  # …
}

(Full source)

Now we can use the patched lockfile and create a new source tree with the lockfile being replaced. All that is left then is to call npm --offline install to install all the dependencies Node.js projects.

{
  # …
  patchedBuildRoot = pkgs.symlinkJoin {
    name = "patched-root-${lockFile.name}";
    paths = [
      patchedLockfile
      sourcePath
    ];
  };

  patchedBuild = pkgs.stdenv.mkDerivation {
    name = lockFile.name;
    src = patchedBuildRoot;
    buildInputs = [ nodejs ];

    buildPhase = ''
      export HOME=$(mktemp -d)
      chmod -R +rw .
      npm --offline install
    '';

    installPhase = ''
      ls -la
      mkdir $out
      cp -rv node_modules $out/node_modules
    '';

    passthru.lockFile = patchedLockfile + "/package-lock.json";
  };
  # …
}

(Full source)

With the node_modules folder being handled we can move on to the actual build process. For the case of photoprism there was one dependency that didn’t have any sources in the lockfile (namely minimist). For that purpose I quickly added a packageOverride attribute to the lockfile patching code. It allows specifying sources for all those where sources are missing. I expected there to be more of these edge cases but apparently that was all that was required. The entire frontend build expression turned out to be fairly simple:

{
    # …
    frontend = let
      noderanz = callPackage ranz2nix {
        nodejs = nodejs-12_x;
        sourcePath = src + "/frontend";
        packageOverride = name: spec: if name == "minimist" && spec ? resolved && spec.resolved == "" then {
          resolved = "file://" + (
            toString (
              fetchurl {
                url = "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz";
                sha256 = "0w7jll4vlqphxgk9qjbdjh3ni18lkrlfaqgsm7p14xl3f7ghn3gc";
              }
            )
          );
        } else {};
      };
      node_modules = noderanz.patchedBuild;
    in
      stdenv.mkDerivation {
        name = "photoprism-frontend";
        nativeBuildInputs = [ nodejs-12_x ];

        inherit src;

        sourceRoot = "photoprism-src/frontend";

        postUnpack = ''
          chmod -R +rw .
        '';

        NODE_ENV = "production";

        buildPhase = ''
          export HOME=$(mktemp -d)
          ln -sf ${node_modules}/node_modules node_modules
          ln -sf ${node_modules.lockFile} package-lock.json
          npm run build
        '';
        installPhase = ''
          cp -rv ../assets/static/build $out
        '';
      };
      # …
}

(Full source)

And that is all! Hurray!

When compared to any other Node.js approach this was actually fun to use and to develop. With a bit of dedicated time and brainstorming this little PoC could evolve into a more sophisticated solution. For now I am done with this project as it just does what I need and I can finally get back to deploying the gallery. :-)

EDIT: Since all of the above was just a PoC and was still very rough on the edges I started working on a cleaner version of this approach over at npmlock2nix.