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