Lockfile trick: Package an npm project with Nix in 20 lines

December 18, 2022


I recently needed to package a JavaScript npm project with Nix. This is very simple to do and does not require any ugly workarounds like importing from derivation or generating Nix files and works just fine with sandboxed builds. Here’s the general idea and an example.

The idea is to use the project lockfile (package-lock.json) to tell Nix how to fetch the dependencies. Then we use the project’s own tools (e.g. npm) to actually perform the build.

The idea is not new and I’ve talked about it before in my 2019 NixCon talk: An Overview of Language Support in Nix. It’s the idea powering naersk and napalm.

NOTE: When I first wrote napalm, the npm cache command wasn’t very stable and hence the solution I describe here is much simpler, more lightweight (i.e. does not require an npm registry written in Haskell …) and more reliable. Also with time I’ve come to realize that mileage does vary significantly from project to project, so instead of releasing yet another opinionated library like napalm or naersk I’ll just explain the idea and suggest you just copy/paste it to your projects. :)


The files

Let’s start with some basics. Most npm projects contain at least two files:

Here’s a simple package.json (the project description) that simply specifies two dependencies, namely the typescript compiler, and the corresponding LSP server:

{
  "name": "ts-env",
  "author": "",
  "license": "ISC",
  "dependencies": {
    "typescript": "^4.9.4",
    "typescript-language-server": "^2.2.0"
  }
}

Nothing fancy going on. What’s of real interest to us though is the lockfile:

{
  "name": "ts-env",
  "version": "1.0.0",
  "lockfileVersion": 2,
  "requires": true,
  "packages": {
    "": {
      "name": "ts-env",
      "dependencies": { ... }
    },
    "node_modules/commander": {
      "version": "9.4.1",
      "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
      "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
      },
    ...
  }
}

This is a stripped down version of the actual lockfile, but as you can see each dependency specifies its npmjs.org URL (the registry where packages are uploaded to and downloaded from) and an integrity field, which is the hash of the tarball (that can be found at the URL resolved). We’ll use those two fields (resolved and integrity) to tell Nix exactly how to download the dependencies.

Note that the lockfile (package-lock.json) can be generated from the package.json by running npm install.

Fetching the dependencies as derivations

As mentioned above, each dependency in the lockfile has a URL (resolved) and a hash (integrity). These can be used as arguments of nixpkgs’ fetchurl to turn the URL into a derivation.

However in order to be able to generate the fetchurl derivations we need Nix to know about those values. For that we use builtins.readFile and builtins.fromJSON to read package-lock.json as a Nix attribute set. Then we can read the interesting fields of the lockfile (packages and dependencies) and clean it up a bit (in particular we drop the "" package, which is basically the local package, which of course does not have a URL or hash since… it’s local).

Here’s what this looks like:

let
  pkgs = import <nixpkgs> { };

  # The path to the npm project
  src = ./.;

  # Read the package-lock.json as a Nix attrset
  packageLock = builtins.fromJSON (builtins.readFile (src + "/package-lock.json"));

  # Create an array of all (meaningful) dependencies
  deps = builtins.attrValues (removeAttrs packageLock.packages [ "" ])
    ++ builtins.attrValues (removeAttrs packageLock.dependencies [ "" ])
  ;

  # Turn each dependency into a fetchurl call
  tarballs = map (p: pkgs.fetchurl { url = p.resolved; hash = p.integrity; }) deps;

  # Write a file with the list of tarballs
  tarballsFile = pkgs.writeTextFile {
    name = "tarballs";
    text = builtins.concatStringsSep "\n" tarballs;
  };
in

Note that as the last step we wrote the list of tarballs to a file (tarballsFile). The file really just contains a list of derivations/store paths which we’ll need to read from during the actual build:

/nix/store/pp5fx4grxk686dwdsrp7i39pbc2lsznx-commander-9.4.1.tgz
/nix/store/cqhbvmj5ny6nxgn40lgd1ma2r8lnd6p0-crypto-random-string-4.0.0.tgz
/nix/store/6cs6lnz1x96y68nwm7fqc5k9j5a7f2lv-type-fest-1.4.0.tgz
/nix/store/38g318wbnnqlpfmg66pdbvkhv9883v58-deepmerge-4.2.2.tgz
/nix/store/v8dwmkyar0p5g12ia0ikmyb8dhcim5jg-find-up-6.3.0.tgz
...

Speaking of “actual build”, what would that look like? Well first, we populate an npm cache with all the dependencies. We don’t do this ourselves but instead use the npm cache command, which takes a tarball, computes its checksum (hash), opens it up to figure some details like its project name etc (each tarball has a package.json) and then stores it in a cache somewhere (we don’t have to care about the details). Then we run npm ci which reads the lockfile and installs the necessary dependencies; in this case however the dependencies are not downloaded but fetched directly from the cache.

pkgs.stdenv.mkDerivation {
  inherit (packageLock) name version;
  inherit src;
  buildInputs = [ pkgs.nodejs ];
  buildPhase = ''
    export HOME=$PWD/.home
    export npm_config_cache=$PWD/.npm
    mkdir -p $out/js
    cd $out/js
    cp -r $src/. .

    while read package
    do
      echo "caching $package"
      npm cache add "$package"
    done <${tarballsFile}

    npm ci
  '';

  installPhase = ''
    ln -s $out/js/node_modules/.bin $out/bin
  '';
}

And that’s pretty much it. A quick nix-build later and you’re able to access all those sweet packages:

$ nix-build
this derivation will be built:
  /nix/store/58ad9kzwsqr7gkmjhwf40ahfjvzxphwa-ts-env.drv
building '/nix/store/58ad9kzwsqr7gkmjhwf40ahfjvzxphwa-ts-env.drv'...
... nixpkgs stuff
building
caching /nix/store/pp5fx4grxk686dwdsrp7i39pbc2lsznx-commander-9.4.1.tgz
caching /nix/store/cqhbvmj5ny6nxgn40lgd1ma2r8lnd6p0-crypto-random-string-4.0.0.tgz
... more dependencies being cached

[##################] reify:typescript-language-server: ... npm output
added 34 packages in 430ms

12 packages are looking for funding
  run `npm fund` for details

$ ./result/bin/tsc --help
tsc: The TypeScript Compiler - Version 4.9.4
                                                                                      TS
COMMON COMMANDS

  tsc
  Compiles the current project (tsconfig.json in the working directory.)
...

Voilà! By reading information from the lockfile from Nix we’re able to set up an npm environment with the sandbox enabled and without any import from derivation or any need to generate Nix files.

This can be tweaked to instead e.g. build an npm project with npm run build or do whatever you might need to do. Again, I discuss the more general idea in this talk, and something based on this is implemented in napalm but really I recommend just copy pasting the code here and adapting it to your needs.


Like Nix and build systems? Here's more on the topic: