A Rust + WASM development environment with Nix

ยท 1501 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:

{
  description = "Rust with WebAssembly";

  inputs = {
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nixpkgs.url = "nixpkgs/nixos-23.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, fenix, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          f = fenix.packages.${system};
        in
          {
            devShells.default =
              pkgs.mkShell {
                name = "rust-wasm-first-attempt";

                packages = with pkgs; [
                  f.stable.toolchain
                  nodejs_21
                  wasm-pack
                ];
              };
          }
      );
}

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:

[jkaye@jkaye-nixos:~/test]$ nix develop
(nix:rust-wasm-first-attempt-env) [jkaye@jkaye-nixos:~/test]$ cargo build
   Compiling proc-macro2 v1.0.81
   Compiling unicode-ident v1.0.12
   Compiling wasm-bindgen-shared v0.2.92
   Compiling bumpalo v3.16.0
   Compiling log v0.4.21
   Compiling once_cell v1.19.0
   Compiling wasm-bindgen v0.2.92
   Compiling cfg-if v1.0.0
   Compiling quote v1.0.36
   Compiling syn v2.0.60
   Compiling wasm-bindgen-backend v0.2.92
   Compiling wasm-bindgen-macro-support v0.2.92
   Compiling wasm-bindgen-macro v0.2.92
   Compiling console_error_panic_hook v0.1.7
   Compiling replace-me v0.1.0 (/home/jkaye/test)

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.34s
(nix:rust-wasm-first-attempt-env) [jkaye@jkaye-nixos:~/test]$ wasm-pack build
[INFO]: ๐ŸŽฏ  Checking for the Wasm target...
Error: wasm32-unknown-unknown target not found in sysroot: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02"

Used rustc from the following path: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02/bin/rustc"
It 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.

Caused by: wasm32-unknown-unknown target not found in sysroot: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02"

Used rustc from the following path: "/nix/store/l7rmk66qn9hbdfd0190wqzdh2qfhyysr-rust-stable-2024-05-02/bin/rustc"
It 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:

{
  description = "Rust with WebAssembly";

  inputs = {
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nixpkgs.url = "nixpkgs/nixos-23.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, fenix, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          f = with fenix.packages.${system}; combine [ # <-- change here
            stable.toolchain
            targets.wasm32-unknown-unknown.stable.rust-std
          ];
        in
          {
            devShells.default =
              pkgs.mkShell {
                name = "rust-wasm-second-attempt";

                packages = with pkgs; [
                  f # <-- change here
                  nodejs_21
                  wasm-pack
                ];
              };
          }
      );
}

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?

[jkaye@jkaye-nixos:~/test]$ nix develop
warning: Git tree '/home/jkaye/test' is dirty
(nix:rust-wasm-second-attempt-env) [jkaye@jkaye-nixos:~/test]$ wasm-pack build
[INFO]: ๐ŸŽฏ  Checking for the Wasm target...
[INFO]: ๐ŸŒ€  Compiling to Wasm...
   Compiling cfg-if v1.0.0
   Compiling wasm-bindgen v0.2.92
   Compiling console_error_panic_hook v0.1.7
   Compiling replace-me v0.1.0 (/home/jkaye/test)

error: linking with `rust-lld` failed: exit status: 127
  |
  = note: Could not start dynamically linked executable: rust-lld
          NixOS cannot run dynamically linked executables intended for generic
          linux environments out of the box. For more information, see:
          https://nix.dev/permalink/stub-ld
          

error: could not compile `replace-me` (lib) due to 1 previous error; 1 warning emitted
Error: Compiling your crate to WebAssembly failed
Caused by: Compiling your crate to WebAssembly failed
Caused by: failed to execute `cargo build`: exited with exit status: 101
  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:

{
  description = "Rust with WebAssembly";

  inputs = {
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nixpkgs.url = "nixpkgs/nixos-23.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, fenix, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          f = with fenix.packages.${system}; combine [
            stable.toolchain
            targets.wasm32-unknown-unknown.stable.rust-std
          ];
        in
          {
            devShells.default =
              pkgs.mkShell {
                name = "rust-wasm-final-attempt";

                packages = with pkgs; [
                  f
                  llvmPackages.bintools # <-- change here
                  nodejs_21
                  wasm-pack
                ];

                CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER = "lld"; # <-- change here
              };
          }
      );
}

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:

[jkaye@jkaye-nixos:~/test]$ nix develop
warning: Git tree '/home/jkaye/test' is dirty
(nix:rust-wasm-final-attempt-env) [jkaye@jkaye-nixos:~/test]$ wasm-pack build
[INFO]: ๐ŸŽฏ  Checking for the Wasm target...
[INFO]: ๐ŸŒ€  Compiling to Wasm...
   Compiling cfg-if v1.0.0
   Compiling wasm-bindgen v0.2.92
   Compiling console_error_panic_hook v0.1.7
   Compiling replace-me v0.1.0 (/home/jkaye/test)

    Finished `release` profile [optimized] target(s) in 1.47s
[INFO]: โฌ‡๏ธ   Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: โœจ   Done in 1.92s
[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:

{
  description = "Rust with WebAssembly";

  inputs = {
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nixpkgs.url = "nixpkgs/nixos-23.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, fenix, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          f = with fenix.packages.${system}; combine [
            stable.toolchain
            targets.wasm32-unknown-unknown.stable.rust-std
          ];
        in
          {
            devShells.default =
              pkgs.mkShell {
                name = "rust-wasm-final-attempt";

                packages = with pkgs; [
                  f
                  llvmPackages.bintools
                  nodePackages.typescript-language-server # <-- change here
                  nodejs_21
                  vscode-langservers-extracted # <-- change here
                  wasm-pack
                ];

                CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER = "lld"; 
              };
          }
      );
}

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:

mkdir test && cd test
nix flake init -t github:jkaye2012/flake-templates#rust-wasm-project
git init && git add .
nix develop
wasm-pack build
cd www
npm install
npm 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!