Serverless Rust with Cloudflare Workers


The Workers team just announced support for WebAssembly (WASM) within Workers. If you saw my post on Internet Native Apps, you’ll know that I believe WebAssembly will play a big part in the apps of the future.

It’s exciting times for Rust developers. Cloudflare’s Serverless Platform, Cloudflare Workers, allows you to compile your code to WASM, upload to 150+ data centers and invoke those functions just as easily as if they were JavaScript functions. Today I’m going to convert my lipsum generator to use Rust and explore the developer experience (hint: it’s already pretty nice).

The Workers teams notes in the documentation:

…WASM is not always the right tool for the job. For lightweight tasks like redirecting a request to a different URL or checking an authorization token, sticking to pure JavaScript is probably both faster and easier than WASM. WASM programs operate in their own separate memory space, which means that it’s necessary to copy data in and out of that space in order to operate on it. Code that mostly interacts with external objects without doing any serious “number crunching” likely does not benefit from WASM.

OK, I’m unlikely to gain significant performance improvements on this particular project, but it serves as a good opportunity illustrate the developer experience and tooling. 🦀

Setup the environment with wasm-pack

Coding with WASM has been bleeding edge for a while, but Rust’s tool for WASM development recently reached a fairly ergonomic state and even got a website. Make sure you have the prerequisites installed and then follow the steps below to get started.

wasm-pack allows you to compile Rust to WebAssembly, as well as generate bindings between JavaScript objects and Rust objects. We’ll talk about why that’s important later.

# Install wasm-pack
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

# Cargo generate to build apps based on templates
cargo install cargo-generate

# And generate a HelloWorld wasm app, based on the wasm-pack template
cargo generate --git https://github.com/rustwasm/wasm-pack-template

🤷  Project Name: bob-ross-lorem-ipsum-rust
🔧   Creating project called `bob-ross-lorem-ipsum-rust`...
✨   Done! New project created /Volumes/HD2/Code/cloudflare/bobross/bob-ross-lipsum-rust/api-rust/bob-ross-lorem-ipsum-rust

The WASM book describes some of the glue in the Cargo.toml file, but the meat of the project is here:

...
#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, bob-ross-lorem-ipsum-rust!");
}

This does two things

  1. Binds to the “external” function in our host environment where the WASM will run. If that’s the browser, it will popup a window.

  2. It also defines a Rust function, greet() which will be made available as a function callable from the host environment, in our case JavaScript.

Build with wasm-pack build

$ wasm-pack build
  
  [1/9] 🦀  Checking `rustc` version...
  [2/9] 🔧  Checking crate configuration...
  [3/9] 🎯  Adding WASM target...
  [4/9] 🌀  Compiling to WASM...
  [5/9] 📂  Creating a pkg directory...
  [6/9] 📝  Writing a package.json...
  ⚠️   [WARN]: Field 'description' is missing from Cargo.toml. It is not necessary, but recommended
  ⚠️   [WARN]: Field 'repository' is missing from Cargo.toml. It is not necessary, but recommended
  ⚠️   [WARN]: Field 'license' is missing from Cargo.toml. It is not necessary, but recommended
  [7/9] 👯  Copying over your README...
  [8/9] ⬇️  Installing wasm-bindgen...
  [9/9] 🏃‍♀️  Running WASM-bindgen...
  ✨   Done in 2 minutes
| 📦   Your wasm pkg is ready to publish at "/Volumes/HD2/Code/cloudflare/bobross/bob-ross-lipsum-rust/bob-ross

Subsequent builds will be faster. We eventually want to ship that .wasm file to a Worker, but I’d like to keep things local and test in a browser first.

There’s an npm package that will create a templated webpack webapp, preconfigured to import WebAssembly node modules, which we’ll use as a test harness.

$ npm init wasm-app www
npx: installed 1 in 2.533s
🦀 Rust + 🕸 Wasm = ❤

Install the dependencies with npm install and then npm start to fire up the webpack bundled web server to serve your page

$ npm start

> create-wasm-app@0.1.0 start /Volumes/HD2/Code/cloudflare/bobross/bob-ross-lipsum-rust/bob-ross-lorem-ipsum-rust/www
> webpack-dev-server

ℹ 「wds」: Project is running at http://localhost:8080/

Open your web browser at http://localhost:8080 and you should see your first WASM generated content!

OK, that’s promising, but it’s not actually our code. Our greet function returned "Hello, bob-ross-lorem-ipsum-rust!"

If we open up www/index.js, we can see this:

import * as wasm from "hello-wasm-pack";

wasm.greet();

So it’s importing a node module “hello-wasm-pack” which was part of the template. We want to import our own module we built with cargo generate earlier.

First, expose our WASM package as a node module:

# Create a global node_modules entry pointing to your local wasm pkg
$ cd pkg
$ npm link
...
/Users/steve/.nvm/versions/node/v8.11.3/lib/node_modules/bob-ross-lorem-ipsum-rust -> /Volumes/HD2/Code/cloudflare/bob-ross-lorem-ipsum-rust/pkg

Then make it available as a node_module in our test harness.

$ cd ../www
$ npm link bob-ross-lorem-ipsum-rust
/Volumes/HD2/Code/cloudflare/bobross/bob-ross-lorem-ipsum-rust/www/node_modules/bob-ross-lorem-ipsum-rust -> /Users/steve/.nvm/versions/node/v8.11.3/lib/node_modules/bob-ross-lorem-ipsum-rust -> /Volumes/HD2/Code/cloudflare/bobross/bob-ross-lorem-ipsum-rust/pkg

Import it in the index.js file

//import * as wasm from "hello-wasm-pack";
import * as wasm from "bob-ross-lorem-ipsum-rust"

and run!

npm run build
npm run start

Better! That’s our code.

Rust WASM Hello World 2

Quick recap

We have:

  • A Hello, World WASM module
  • Exposed as an npm module
  • A webpack app which imports that module
  • And invokes the greet() function

We’re now going to port our Bob Ross Lorem Ipsum generator to Rust, and try it out locally before uploading as a worker. Check it out on in the GitHub repo, or follow along.


use std::vec::Vec;
use rand::distributions::{Range, Distribution};
use rand::rngs::SmallRng;
use rand::FromEntropy;

static PHRASES: [&str; 370] = [...elided for clarity];

fn get_random_indexes(cnt: usize) -> Vec<usize> {
    let mut rng = get_rng();
    let range = Range::new(0, PHRASES.len());    
    (0..cnt)
        .map(|_| range.sample(&mut rng))
        .collect()
}

fn get_phrase(idx: usize) -> &'static str {
    PHRASES[idx]        
}

fn get_rng() -> SmallRng {    
    SmallRng::from_entropy()
}

fn get_phrases(idxs: &Vec<usize>) -> Vec<&'static str> {    
    idxs.iter()
        .map(|idx| get_phrase(*idx))
        .collect()
}

fn need_newline(newline: usize, idx: usize) -> bool {
    //idx+1 because idx is zero-based but we want a new line after "every x phrases".
    (newline > 0) && (idx > 0) && ((idx + 1) % newline == 0)
}

fn need_space(newline: usize, idx: usize) -> bool {
    !need_newline(newline, idx)
}

fn build_phrase_text(idxs: Vec<usize>, newline: usize) -> String {
    let phrases_vec = get_phrases(&idxs);
    let mut string = String::new();
    for i in 0..phrases_vec.len() {
        //the phrase
        string.push_str(phrases_vec[i]);
        //spaces between phrases
        if need_space(newline, i) {
            string.push(' ');
        }
        //new lines
        if need_newline(newline, i) {
            string.push_str("nn");
        }
    }
    string
}

pub fn get_phrase_text(phrase_cnt: usize, newline: usize) -> String {
    let idxs = get_random_indexes(phrase_cnt);
    build_phrase_text(idxs,newline)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn get_test_indexes() -> Vec<usize> {
        vec![34, 2, 99, 43, 128, 300, 45, 56, 303, 42, 11]
    }
    
    #[test]
    fn get_phrases() {
        let randoms = get_test_indexes();
        let phrases = super::get_phrases(&randoms);
        println!("{:?}", phrases);
    }
}

Running the tests shows everything looks good:

$ cargo test -- --nocapture
    Finished dev [unoptimized + debuginfo] target(s) in 0.40s                                                                                       
     Running target/debug/deps/bob_ross_lorem_ipsum_rust-5be29ab9ead7494d

running 1 test
["Decide where your cloud lives. Maybe he lives right in here.", "A fan brush is a fantastic piece of equipment. Use it. Make friends with it.", "If we're going to have animals around we all have to be concerned about them and take care of them.", "Don't kill all your dark areas - you need them to show the light.", "It's almost like something out of a fairytale book.", "We don't have anything but happy trees here.", "Even the worst thing we can do here is good.", "Everything is happy if you choose to make it that way.", "We don't make mistakes we just have happy little accidents.", "Don't hurry. Take your time and enjoy.", "All you have to learn here is how to have fun."]
test phrases::tests::get_phrases ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

And I’ve exposed the method to WASM like this:

#[wasm_bindgen]
pub fn get_phrase_text(phrase_cnt: usize, new_line: usize) -> String {
    phrases::get_phrase_text(phrase_cnt, new_line)
}

So, we should be good to call our WASM from the browser test harness. Let’s modify www/index.js to invoke get_phrase_text and fire it up in the browser!

//wasm.greet();
let phraseText = wasm.get_phrase_text(100, 10);
console.log(phraseText);
alert(phraseText);

Fail.

Rust WASM Fail no entropy

If you’ve played around with Rust, you’ll know how jarring it can be to see your code compile and tests pass, only to have it blow up at runtime. The strictness of the language means your code behaves exactly as you expect more often than other languages, so this failure really threw me.

Analysing the stack trace, we can see the failure starts at FromEntropy. My first instinct was that the WASM host didn’t support providing entropy. I re-jigged the code to use a time-based seed instead and that failed too. The common theme seemed to be both entropy, and the current time, both make system calls.

Reading through the relevant Github issues which discuss this here and here, it looks like the design for how Rust generated WASM will handle system calls remains open. If the compiler isn’t able to guarantee the system calls will be available, shouldn’t the linker fail to compile? I think the answer lies in the wasm-unknown-unknown triplet that we compile to. There are no guarantees on what the target platform provides when you target unknown, so you’re on your own.

That said, we know that the v8 JavaScript engine will be our host in both the browser, and in Workers. There are libraries which allow us to make all Web APIs defined in the ECMAScript standard available to Rust, such as js-sys

Using that, I can rewrite the failing get_rng() method to return a pseudo-random number generated seed with a time-based value using the Date object provided by the JavaScript host, rather than making a system call. Full code listing is on Github

fn get_rng() -> SmallRng {    
    use js_sys::Date;
    use rand::SeedableRng;

	//from Javascript	
    let ticks = Date::now(); 
    //convert the number to byte array to use as a seed
    let tick_bytes = transmute(ticks as u128); 
    SmallRng::from_seed(tick_bytes)
}

After another wasm-pack build and reloading our test page…

Generating random phrases

Huzzah! OK, if my WASM module returns the right output in Chrome, I’m feeling good about it working in Workers.

From local browser harness to Workers

You can use the API or UI to upload. Below, I upload the .wasm file in my /pkg director and bind it to the global variable BOBROSS_WASM, where it will be available in my Worker.

Rust WASM upload

If you’re following and looked at the output of the wasm-pack build command, you might have noticed it produced a JavaScript glue file in the pkg folder, which is actually what the browser executed.

It looks like this:

/* tslint:disable */
import * as wasm from './bob_ross_lorem_ipsum_rust_bg';

let cachedDecoder = new TextDecoder('utf-8');

let cachegetUint8Memory = null;
function getUint8Memory() {
    if (cachegetUint8Memory === null || cachegetUint8Memory.buffer !== wasm.memory.buffer) {
        cachegetUint8Memory = new Uint8Array(wasm.memory.buffer);
    }
    return cachegetUint8Memory;
}

function getStringFromWasm(ptr, len) {
    return cachedDecoder.decode(getUint8Memory().subarray(ptr, ptr + len));
}

export function __wbg_alert_8c454b1ebc6068d7(arg0, arg1) {
    let varg0 = getStringFromWasm(arg0, arg1);
    alert(varg0);
}
/**
* @returns {void}
*/
export function greet() {
    return wasm.greet();
}

let cachedGlobalArgumentPtr = null;
function globalArgumentPtr() {
    if (cachedGlobalArgumentPtr === null) {
        cachedGlobalArgumentPtr = wasm.__wbindgen_global_argument_ptr();
    }
    return cachedGlobalArgumentPtr;
}

let cachegetUint32Memory = null;
function getUint32Memory() {
    if (cachegetUint32Memory === null || cachegetUint32Memory.buffer !== wasm.memory.buffer) {
        cachegetUint32Memory = new Uint32Array(wasm.memory.buffer);
    }
    return cachegetUint32Memory;
}
/**
* @param {number} arg0
* @param {number} arg1
* @returns {string}
*/
export function get_phrase_text(arg0, arg1) {
    const retptr = globalArgumentPtr();
    wasm.get_phrase_text(retptr, arg0, arg1);
    const mem = getUint32Memory();
    const rustptr = mem[retptr / 4];
    const rustlen = mem[retptr / 4 + 1];

    const realRet = getStringFromWasm(rustptr, rustlen).slice();
    wasm.__wbindgen_free(rustptr, rustlen * 1);
    return realRet;

}

const __wbg_now_4410283ed4cdb45a_target = Date.now.bind(Date) || function() {
    throw new Error(`wasm-bindgen: Date.now.bind(Date) does not exist`);
};

export function __wbg_now_4410283ed4cdb45a() {
    return __wbg_now_4410283ed4cdb45a_target();
}

It takes care of the marshalling of strings from WASM into JavaScript and freeing the memory it uses. In an ideal world, we’d just include this in our Worker and be done. However, there a few differences between how Workers instantiates WebAssembly modules and the browser.

You need to:

  • Remove the import line
  • Remove the export keywords
  • Wrap all the functions in a module
  • Create an importObject referencing the methods
  • Pass that in when you create the WebAssembly instance

You can view a side-by-side diff or a patch to see the changes required to have it run in a Worker. Include the modified glue code into your worker and you can now call it like any other function. (Thanks for the tip Jake Riesterer!)

// Request Handler
async function handleRequest(request) {

    let url = new URL(request.url);

    //Serve the UI
    if (url.pathname === "/" ) {
        let init = { "status" : 200 , "headers" : { 'Content-Type': 'text/html' } };
        return new Response(ui, init);
    }

    let phraseCount = Math.min(parseInt(url.searchParams.get("phrases") || 100), 10000);
    let newLine = Math.min(parseInt(url.searchParams.get("newline") || 0), 10000);

    //Serverless Rust in 150+ data centers!
    let phraseText = mod.get_phrase_text(phraseCount, newLine);
    return new Response(phraseText);
}

Success!

Serverless Rust on the Edge

The full source code is on Github.

Summary

We can compile Rust to WASM, and call it from Serverless functions woven into the very fabric of the Internet. That’s huge and I can’t wait to do more of it.

There’s some wrangling of the generated code required, but the tooling will improve over time. Once you’ve modified the glue code, calling a function in Rust generated WASM modules is just as simple as JavaScript.

Are we Serverless yet? Yes we are.

In a future post, I’ll extract out the phrases and the UI to the KV store to show a full fledged serverless app powered by Rust and WASM.



Source link