Nix: Using symlinkJoin with nodePackages

1. Using symlinkJoin

NixOS’s <nixpkgs> repository comes with a handy function for combining different packages (derivations) into a single, large derivations: symlinkJoin. It replicates the directory structure of a list of derivations, creating symlinks to the original files each source derivation. Unfortunately, as we’ll discuss in this post, it chokes badly if you try to join an NPM package into your derivation.

Using symlinkJoin in this way, to compile several derivations into one, is one approach to handling runtime-dependencies. Instead of messing around with the $PATH, or editing the application in question to insert absolute paths to files, just put everything in the same directory structure. The following shows an example case: Emacs’ LSP Mode provides IDE-like features to Emacs, but it relies on different external tools for each programming language you want to use. By using symlinkJoin, we can ensure Emacs has access to these tools.

 1: {
 2:   description = "Emacs, with runtime dependencies";
 3: 
 4:   outputs = { nixpkgs, ... }:
 5:     let
 6:       overlay = final: prev: rec {
 7:         my-emacs =
 8:             final.symlinkJoin {
 9:               name = "my-emacs";
10:               meta.mainProgram = "emacs";
11:               paths = with final; [
12:                 emacs
13:                 deno # LSP: JavaScript support
14:                 nodePackages.vscode-html-languageserver-bin # LSP: HTML
15:               ];
16:             };
17:       };
18: 
19:       pkgs = import nixpkgs {
20:         system = "x86_64-linux";
21:         overlays = [ overlay ];
22:       };
23:     in {
24:       inherit overlay;
25:       defaultPackage.x86_64-linux = pkgs.my-emacs;
26:     };
27: }

In the above example, we’ve combined emacs, deno, and vscode-html-languageserver-bin into a single derivation, so LSP has access to the executables it needs for JavaScript and HTML support. We can build this and see symlinkJoin in action:

$ nix build .# && ls -l result/bin

total 24
lrwxrwxrwx 1 root root 64 Dec 31  1969 ctags       -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/ctags
lrwxrwxrwx 1 root root 64 Dec 31  1969 deno        -> /nix/store/cnbwlf178zhg4lsbcgqf7l8y669jx7zx-deno-1.17.2/bin/deno
lrwxrwxrwx 1 root root 66 Dec 31  1969 ebrowse     -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/ebrowse
lrwxrwxrwx 1 root root 10 Dec 31  1969 emacs       -> emacs-27.2
lrwxrwxrwx 1 root root 69 Dec 31  1969 emacs-27.2  -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/emacs-27.2
lrwxrwxrwx 1 root root 70 Dec 31  1969 emacsclient -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/emacsclient
lrwxrwxrwx 1 root root 64 Dec 31  1969 etags       -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/etags

You can see that our built bin/ directory includes executables for both emacs and deno, but none for nodePackages.vscode-html-languageserver-bin (specifically: html-languageserver)! This is a bug, for sure, but why is it happening?

  • FYI, we confirm which executables are missing with:

    $ nix build nixpkgs#nodePackages.vscode-html-languageserver-bin
    $ ls -l result/ result/bin/
    
    total 28K
    lrwxrwxrwx 1 root root 25K Dec 31  1969 html-languageserver
    
    result/:
    total 4
    lrwxrwxrwx 1 root root   21 Dec 31  1969 bin -> lib/node_modules/.bin
    dr-xr-xr-x 3 root root 4096 Dec 31  1969 lib
    
    result/bin/:
    total 0
    lrwxrwxrwx 1 root root 51 Dec 31  1969 html-languageserver -> ../vscode-html-languageserver-bin/htmlServerMain.js
    

2. Why it breaks with nodePackages (NPM)

symlinkJoin fails to include the html-languageserver executable because for two reason:

2.1. Reason 1: NPM uses a different directory for executables

For whatever reason, when you build an NPM package that includes executable files, the executable files are installed in a hidden directory: ./lib/node_packages/.bin/. NixOS derivations seem to solve this by simply linking ./bin to ./lib/node_packages/.bin/.

2.2. Reason 2: lndir can’t handle directory symlinks

Under the hood, symlinkJoin delegates much of its work to an X11 utility, called lndir:

431: symlinkJoin = args:
432:   let
433:     # ...cut...
434:   in runCommand name args
435:     ''
436:       mkdir -p $out
437:       for i in $(cat $pathsPath); do
438:         ${lndir}/bin/lndir -silent $i $out
439:       done
440:     '';

According to its manual (emphasis mine):

Name
lndir - create a shadow directory of symbolic links to another directory tree
Synopsis
lndir [ -silent ] [ -ignorelinks ] [ -withrevinfo ] fromdir [ todir ]
Description
The lndir program makes a shadow copy todir of a directory tree fromdir, except that the shadow is not populated with real files but instead with symbolic links pointing at the real files in the fromdir directory tree. … If a file in fromdir is a symbolic link, lndir will make the same link in todir rather than making a link back to the (symbolic link) entry in fromdir.

2.3. The Result

If we check the build log (nix log .#), we can see that lndir spit out one error: bin: File exists. Instead of linking to the files inside ./lib/node_packages/.bin/, lndir tried to copy the ./bin symlink itself!

3. The Solution

Since we know exactly which links are missing, and where there are, we can easily fix this by linking the nodePackages executable ourselves in a postBuild script:

 1: {
 2:   description = "Emacs, with runtime dependencies";
 3: 
 4:   outputs = { nixpkgs, ... }:
 5:     let
 6:       overlay = final: prev: rec {
 7:         my-emacs =
 8:             final.symlinkJoin {
 9:               name = "my-emacs";
10:               meta.mainProgram = "emacs";
11:               paths = with final; [
12:                 emacs
13:                 deno # LSP: JavaScript support
14:                 nodePackages.vscode-html-languageserver-bin # LSP: HTML
15:               ];
16: 
17:               # symlinkJoin can't handle symlinked dirs and nodePackages
18:               # symlinks ./bin -> ./lib/node_modules/.bin/.
19:               postBuild = ''
20:                 for f in $out/lib/node_modules/.bin/*; do
21:                    path="$(readlink --canonicalize-missing "$f")"
22:                    ln -s "$path" "$out/bin/$(basename $f)"
23:                 done
24:               '';
25:             };
26:       };
27: 
28:       pkgs = import nixpkgs {
29:         system = "x86_64-linux";
30:         overlays = [ overlay ];
31:       };
32:     in {
33:       inherit overlay;
34:       defaultPackage.x86_64-linux = pkgs.my-emacs;
35:     };
36: }

And now we can see all our files are present:

$ nix build .# && ls -l result/bin

total 24
lrwxrwxrwx 1 root root 64 Dec 31  1969 ctags               -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/ctags
lrwxrwxrwx 1 root root 64 Dec 31  1969 deno                -> /nix/store/cnbwlf178zhg4lsbcgqf7l8y669jx7zx-deno-1.17.2/bin/deno
lrwxrwxrwx 1 root root 66 Dec 31  1969 ebrowse             -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/ebrowse
lrwxrwxrwx 1 root root 10 Dec 31  1969 emacs               -> emacs-27.2
lrwxrwxrwx 1 root root 69 Dec 31  1969 emacs-27.2          -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/emacs-27.2
lrwxrwxrwx 1 root root 70 Dec 31  1969 emacsclient         -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/emacsclient
lrwxrwxrwx 1 root root 64 Dec 31  1969 etags               -> /nix/store/i4g0sp8vvqkn70glcwh254d5symlnh05-emacs-27.2/bin/etags
lrwxrwxrwx 1 root root 64 Dec 31  1969 html-languageserver -> /nix/store/3n57m8bfzp9xqgfyknr9gimy5r4436s9-vscode-html-languageserver-bin-1.4.0/lib/node_modules/vscode-html-languageserver-bin/htmlServerMain.js