A Rust app in a Cloudflare Worker

Rust is extraordinarily portable. This post is the first of many describing how a Rust app can be integrated with multiple platforms.

Overview

So how does this work? When a Cloudflare worker receives a request it will:

  1. Convert the Cloudflare request into a Tide request
  2. Create the Rust app and pass the Tide request via the respond function
  3. Convert Tide response to a Cloudflare response and return the response from the worker.

Note, you don't need to create a separate library for the app, but I'll be referencing the next section in future blog posts where I'll integrate the same app into various platforms.

Creating the App

We're going to create a small portion of a note taking app. We'll create the index route which will render a list of notes. The app uses an API to persists notes, makes calls to the API with surf, and uses askama for templating. Feel free to view the complete source code or try out the demo.

Create a repo for the app.

cargo new --lib notes-demo

Update the Cargo.toml. For now, we need to use my fork of Tide to enable WASM. I have an open PR.

edition = "2021"

[dependencies]
serde = "1.0.132"
askama = "0.11.0"

[target.'cfg(target_arch = "wasm32")'.dependencies]
tide = { git = "https://github.com/logankeenan/tide.git", features = ["wasm"], branch = "wasm", default-features = false }
surf = { version = "2.3.2", default-features = false, features = ["wasm-client"] }

# This is needed for other non-wasm platforms
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tide = { git = "https://github.com/logankeenan/tide.git", branch = "wasm" }
surf = { version = "2.3.2" }

Update the lib.rs to include a model that represents a note.

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
pub struct Note {
    pub id: i32,
    pub title: String,
    // the note content can be rendered to markdown. See the source for more details
    pub markdown: String,
    pub update_at: Option<String>,
    pub created_at: String,
}

Create a file to render the markup in notes-demo/template/notes/index.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<nav>
    <a href="/notes">Notes</a>
    <a href="/notes/new">New Note</a>
</nav>

<h1>Notes</h1>
<ul>
    {% for note in notes %}
    <li>
        <a href="/notes/{{note.id}}">{{note.title}}</a>
    </li>
    {% endfor %}
</ul>

</body>
</html>

Create a struct to represent the model bound to the template.

// src/lib.rs

use askama::Template;

#[derive(Template)]
#[template(path = "notes/index.html")]
pub struct IndexTemplate<'a> {
    pub notes: &'a Vec<Note>,
}

Now, lets create a route which will call the API for notes and render the result as HTML.

// src/lib.rs

use surf::Response as SurfResponse;
use tide::{Request, Response, Server};

pub async fn index(_: Request<()>) -> tide::Result {
    let mut api_response: SurfResponse = surf::get(
        "https://rora-notes-demo-api.herokuapp.com/notes"
    ).await?;

    let notes: Vec<Note> = api_response.body_json().await?;
    let notes_template_model = IndexTemplate {
        notes: &notes
    };
    let body = notes_template_model.render()?;

    let response = Response::builder(200)
        .body(body)
        .build();
    Ok(response)
}

Create a public function which will create the Tide server with the index route and return it.

// src/lib.rs

pub fn create() -> Server<()> {
    let mut app = tide::new();
    app.at("/").get(index);

    app
}

Finally, build the library.

cargo build --target wasm32-unknown-unknown

Integrating the App into a Cloudflare Worker

Next, we need to create a Cloudflare worker which forwards requests to our app and return the responses.

Install @cloudflare/wranger and create a new rust project.

npm i @cloudflare/wrangler -g
wrangler generate notes-demo-cf-worker --type rust

Update the Cargo.toml file with the local notes-demo app from, my Tide fork, and the edition.

edition = "2021"

log = "0.4.17" # forcing a version of log due to conflicts
notes-demo = { path = "../notes-demo" }
tide = { git = "https://github.com/logankeenan/tide.git", features = ["wasm"], branch = "wasm", default-features = false }

Now lets update src/lib.rs to convert the Cloudflare worker request to Tide request.

// src/lib.rs
use std::str::FromStr;
use tide::http::{Method, Url, Request as TideRequest, Response as TideResponse};

let method = Method::from_str(req.method().to_string().as_str()).unwrap();
let url = Url::parse(req.url().unwrap().as_str()).unwrap();
let tide_request = TideRequest::new(method, url);
// add headers and body too

Create the app server and pass the Tide request to it

// src/lib.rs

let app_server = notes_demo::create();
let mut tide_response: TideResponse = app_server.respond(tide_request).await.unwrap();

Convert the Tide response to a Cloudflare response and return the result.

// src/lib.rs

let response = Response::from_html(tide_response.body_string().await.unwrap()).unwrap();
let status_code: u16 = tide_response.status().to_string().parse().unwrap();
let response = response.with_status(status_code);
// add headers too

return Ok(response);

Run the Cloudflare worker with wrangler dev. You should see some HTML with a Notes heading and a few links above it. This isn't the complete app, but you can try the demo and view source for the complete app.

What's Next?

It'd be pretty easy to create a much more complex app with all the perks of Rust and it's ecosystem. Since this just compiles to WASM, we could also run this app in the browser. I'll have another post on that. Integrating Cloudflare's recently announced D1 SQL database would allow for a complete full-stack app.