A Rust + WASM development environment with Nix

ยท 1681 words ยท 8 minute read

Getting started with a new ecosystem can be difficult. Using Nix makes the solution reproducible!


I was recently following the setup guide for Rust and WebAssembly and found more surprises than I was expecting while setting up the development environment using a Nix Flake. As I’ve also been working towards a personal repository of Flake templates, I thought it might help others to detail the issues that I encountered and how I solved them. It’s as much about the process of debugging a new Flake environment (and a new toolchain in general) as it is about the final state of the template!

This post assumes some basic familiarity with Nix and Flakes. I’m still relatively new to Nix, so if you notice anything in this post that you think is incorrect, please open an issue so that I can fix it.

If you already know what you’re doing and you’re here looking to get started without the more in-depth explanation, you should be able to use the rust-wasm-project template via github:jkaye2012/flake-templates#rust-wasm-project. More detailed instructions can be found in the last section of the post.

With the disclaimers out of the way, let’s get started!

Rust toolchains ๐Ÿ”—

The first step in setting up a development environment is installing dependencies (surprise!). We need the Rust toolchain, wasm-pack (to build WebAssembly from Rust code), and npm (to manage web dependencies, build/run the site, etc). This is Nix, so dependency installation should be the easy part:

 1{
 2  description = "Rust with WebAssembly";
 3
 4  inputs = {
 5    fenix = {
 6      url = "github:nix-community/fenix";
 7      inputs.nixpkgs.follows = "nixpkgs";
 8    };
 9    nixpkgs.url = "nixpkgs/nixos-23.11";
10    flake-utils.url = "github:numtide/flake-utils";
11  };
12
13  outputs = { self, fenix, nixpkgs, flake-utils }:
14    flake-utils.lib.eachDefaultSystem
15      (system:
16        let
17          pkgs = nixpkgs.legacyPackages.${system};
18          f = fenix.packages.${system};
19        in
20          {
21            devShells.default =
22              pkgs.mkShell {
23                name = "rust-wasm-first-attempt";
24
25                packages = with pkgs; [
26                  f.stable.toolchain
27                  nodejs_21
28                  wasm-pack
29                ];
30              };
31          }
32      );
33}

Unfortunately, this isn’t quite the entire story. If we attempt to build the wasm-pack template project in this environment, the Rust build functions normally, but the WebAssembly portion fails loudly:

 1[jkaye@jkaye-nixos:~/test]$ nix develop
 2(nix:rust-wasm-first-attempt-env) [jkaye@jkaye-nixos:~/test]$ cargo build
 3   Compiling proc-macro2 v1.0.81
 4   Compiling unicode-ident v1.0.12
 5   Compiling wasm-bindgen-shared v0.2.92
 6   Compiling bumpalo v3.16.0
 7   Compiling log v0.4.21
 8   Compiling once_cell v1.19.0
 9   Compiling wasm-bindgen v0.2.92
10   Compiling cfg-if v1.0.0
11   Compiling quote v1.0.36
12   Compiling syn v2.0.60
13   Compiling wasm-bindgen-backend v0.2.92
14   Compiling wasm-bindgen-macro-support v0.2.92
15   Compiling wasm-bindgen-macro v0.2.92
16   Compiling console_error_panic_hook v0.1.7
17   Compiling replace-me v0.1.0 (/home/jkaye/test)
18
19    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.34s
20(nix:rust-wasm-first-attempt-env) [jkaye@jkaye-nixos:~/test]$ wasm-pack build
21[INFO]: ๐ŸŽฏ  Checking for the Wasm target...
22Error: wasm32-unknown-unknown target not found in sysroot: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02"
23
24Used rustc from the following path: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02/bin/rustc"
25It looks like Rustup is not being used. For non-Rustup setups, the wasm32-unknown-unknown target needs to be installed manually. See https://rustwasm.github.io/wasm-pack/book/prerequisites/non-rustup-setups.html on how to do this.
26
27Caused by: wasm32-unknown-unknown target not found in sysroot: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02"
28
29Used rustc from the following path: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02/bin/rustc"
30It looks like Rustup is not being used. For non-Rustup setups, the wasm32-unknown-unknown target needs to be installed manually. See https://rustwasm.github.io/wasm-pack/book/prerequisites/non-rustup-setups.html on how to do this.

This failure is because the Rust toolchain is being managed with Nix rather than with Rustup directly. The error message tells us how to solve this problem: the wasm32-unknown-unknown target needs to be installed manually. The Fenix documentation points to the combine derivation, which allows for combining multiple Rust toolchain components into a single derivation that we can use. So, instead of relying directly upon stable.toolchain, we instead must bring the wasm32-unknown-unknown component into the mix:

 1{
 2  description = "Rust with WebAssembly";
 3
 4  inputs = {
 5    fenix = {
 6      url = "github:nix-community/fenix";
 7      inputs.nixpkgs.follows = "nixpkgs";
 8    };
 9    nixpkgs.url = "nixpkgs/nixos-23.11";
10    flake-utils.url = "github:numtide/flake-utils";
11  };
12
13  outputs = { self, fenix, nixpkgs, flake-utils }:
14    flake-utils.lib.eachDefaultSystem
15      (system:
16        let
17          pkgs = nixpkgs.legacyPackages.${system};
18          f = with fenix.packages.${system}; combine [ # <-- change here
19            stable.toolchain
20            targets.wasm32-unknown-unknown.stable.rust-std
21          ];
22        in
23          {
24            devShells.default =
25              pkgs.mkShell {
26                name = "rust-wasm-second-attempt";
27
28                packages = with pkgs; [
29                  f # <-- change here
30                  nodejs_21
31                  wasm-pack
32                ];
33              };
34          }
35      );
36}

If I hadn’t read the Fenix documentation, it wouldn’t have been so easy to solve this problem. Reading documentation is something that sounds simple and obvious, but in my experience it’s a rare skill. If you’re finding yourself frustrated with a problem and can’t find a solution to it, take a deep breath and read the docs. A large portion of the time you’ll find what you’re looking for.

Linking WASM ๐Ÿ”—

The WebAssembly toolchain is good to go! So what’s next?

 1[jkaye@jkaye-nixos:~/test]$ nix develop
 2warning: Git tree '/home/jkaye/test' is dirty
 3(nix:rust-wasm-second-attempt-env) [jkaye@jkaye-nixos:~/test]$ wasm-pack build
 4[INFO]: ๐ŸŽฏ  Checking for the Wasm target...
 5[INFO]: ๐ŸŒ€  Compiling to Wasm...
 6   Compiling cfg-if v1.0.0
 7   Compiling wasm-bindgen v0.2.92
 8   Compiling console_error_panic_hook v0.1.7
 9   Compiling replace-me v0.1.0 (/home/jkaye/test)
10
11error: linking with `rust-lld` failed: exit status: 127
12  |
13  = note: Could not start dynamically linked executable: rust-lld
14          NixOS cannot run dynamically linked executables intended for generic
15          linux environments out of the box. For more information, see:
16          https://nix.dev/permalink/stub-ld
17          
18
19error: could not compile `replace-me` (lib) due to 1 previous error; 1 warning emitted
20Error: Compiling your crate to WebAssembly failed
21Caused by: Compiling your crate to WebAssembly failed
22Caused by: failed to execute `cargo build`: exited with exit status: 101
23  full command: cd "/home/jkaye/test" && "cargo" "build" "--lib" "--release" "--target" "wasm32-unknown-unknown"

Linking issues! This was a new one for me on NixOS. Matklad has a great explanation of what’s going on here; however, I didn’t love the idea of modifying the flags in the Cargo configuration because that wouldn’t be reproducible for other users. My solution was instead to modify Cargo’s environment variables in the development environment:

 1{
 2  description = "Rust with WebAssembly";
 3
 4  inputs = {
 5    fenix = {
 6      url = "github:nix-community/fenix";
 7      inputs.nixpkgs.follows = "nixpkgs";
 8    };
 9    nixpkgs.url = "nixpkgs/nixos-23.11";
10    flake-utils.url = "github:numtide/flake-utils";
11  };
12
13  outputs = { self, fenix, nixpkgs, flake-utils }:
14    flake-utils.lib.eachDefaultSystem
15      (system:
16        let
17          pkgs = nixpkgs.legacyPackages.${system};
18          f = with fenix.packages.${system}; combine [
19            stable.toolchain
20            targets.wasm32-unknown-unknown.stable.rust-std
21          ];
22        in
23          {
24            devShells.default =
25              pkgs.mkShell {
26                name = "rust-wasm-final-attempt";
27
28                packages = with pkgs; [
29                  f
30                  llvmPackages.bintools # <-- change here
31                  nodejs_21
32                  wasm-pack
33                ];
34
35                CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER = "lld"; # <-- change here
36              };
37          }
38      );
39}

I don’t think this modification is necessary on operating systems other than NixOS, but in my testing it also doesn’t seem to have any negative effects, so for now I’m happy with it. With this, we can now successfully build our WASM:

 1[jkaye@jkaye-nixos:~/test]$ nix develop
 2warning: Git tree '/home/jkaye/test' is dirty
 3(nix:rust-wasm-final-attempt-env) [jkaye@jkaye-nixos:~/test]$ wasm-pack build
 4[INFO]: ๐ŸŽฏ  Checking for the Wasm target...
 5[INFO]: ๐ŸŒ€  Compiling to Wasm...
 6   Compiling cfg-if v1.0.0
 7   Compiling wasm-bindgen v0.2.92
 8   Compiling console_error_panic_hook v0.1.7
 9   Compiling replace-me v0.1.0 (/home/jkaye/test)
10
11    Finished `release` profile [optimized] target(s) in 1.47s
12[INFO]: โฌ‡๏ธ   Installing wasm-bindgen...
13[INFO]: Optimizing wasm binaries with `wasm-opt`...
14[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
15[INFO]: โœจ   Done in 1.92s
16[INFO]: ๐Ÿ“ฆ   Your wasm pkg is ready to publish at /home/jkaye/test/pkg.

Editor integrations ๐Ÿ”—

The final change is for quality of life more than anything. Fenix provides a Rust LSP server, but for TypeScript, JS, HTML, and CSS editor support is still lacking. Of course, there are nixpkgs for this functionality, so fixing this is the easiest change of all:

 1{
 2  description = "Rust with WebAssembly";
 3
 4  inputs = {
 5    fenix = {
 6      url = "github:nix-community/fenix";
 7      inputs.nixpkgs.follows = "nixpkgs";
 8    };
 9    nixpkgs.url = "nixpkgs/nixos-23.11";
10    flake-utils.url = "github:numtide/flake-utils";
11  };
12
13  outputs = { self, fenix, nixpkgs, flake-utils }:
14    flake-utils.lib.eachDefaultSystem
15      (system:
16        let
17          pkgs = nixpkgs.legacyPackages.${system};
18          f = with fenix.packages.${system}; combine [
19            stable.toolchain
20            targets.wasm32-unknown-unknown.stable.rust-std
21          ];
22        in
23          {
24            devShells.default =
25              pkgs.mkShell {
26                name = "rust-wasm-final-attempt";
27
28                packages = with pkgs; [
29                  f
30                  llvmPackages.bintools
31                  nodePackages.typescript-language-server # <-- change here
32                  nodejs_21
33                  vscode-langservers-extracted # <-- change here
34                  wasm-pack
35                ];
36
37                CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER = "lld"; 
38              };
39          }
40      );
41}

You’ll still have to configure your editor of choice to detect and integrate these language servers, but I don’t think that choosing an editor for someone is the right thing to do. So long as your editor is configured for LSP support in general (personally, I’m using Neovim for this), things should “just work”.

Bringing it all together ๐Ÿ”—

The moral of the story is: when trying something new, don’t let unexpected errors scare you away. The solution is often closer than you may think.

The easiest way to use all of this if you’re just trying to get started with a new project is to use the template directly. These templates are mostly for my personal use, but if you’re okay with a small amount of drift, feel free to use them yourself. I’ve tried to make it about as easy as possible to start a new project:

1mkdir test && cd test
2nix flake init -t github:jkaye2012/flake-templates#rust-wasm-project
3git init && git add .
4nix develop
5wasm-pack build
6cd www
7npm install
8npm run start

This assumes that you have Nix installed and Flakes enabled. If so, you should be off to the races! Browse to localhost:8080 and your site should be up and running. The slug replace-me can be replaced using sed or any other method of your choice to modify the project name. This one-liner may be of use:

find . -type f -exec sed -i'' s/replace-me/cool-project-name/g {} \;

Happy hacking!

Enjoyed the post?
Please consider following for new post notifications!