WASIX with gRPC
Introduction
This is a tutorial on how to use gRPC with https in WASIX. We will use the tonic (opens in a new tab) crate to implement a simple gRPC server and client in Rust.
This tutorial does not focus on instantiating gRPC with rust but is rather focused on getting WASIX compatability in gRPC with tonic.
Prerequisites
Please check that you have the latest version of wasmer runtime as this tutorial depends on version 4.1.1 or higher.
The project requires the following tools to be installed on your system:
Project Description
Weโll be following tls_rustls (opens in a new tab) example from tonicโs official repo. This example implements a unary hello world example with https support.
Project Setup
Let's create a new project with cargo.
$ cargo new --bin wasix-grpc
Created binary (application) `wasix-grpc` package
Your wasix-grpc
directory structure should look like this:
As we'll place both server and the client in the same project. We won't be needing the main.rs
file. So, let's delete it.
$ rm src/main.rs
Rather we'd need a server.rs
and a client.rs
file. So, let's create them.
$ touch src/server.rs src/client.rs
Both of these will be our entry points for the server and the client respectively. We'll also need a proto
file to define our service. So, let's create a proto
directory and a hello.proto
file inside it.
$ mkdir proto
$ touch proto/helloworld.proto
Let's also add the following dependencies to our Cargo.toml
file.
[dependencies]
tonic = { version = "0.9", features = ["tls"] }
prost = "0.11"
tokio = { version = "=1.24.2", default-features = false, features = [
"full",
] }
tokio-stream = "0.1.14"
hyper-rustls = { version="0.24.1", features = [
"http2",
] }
tokio-rustls = "0.24.1"
hyper = "0.14.27"
tower = "0.4.13"
http-body = "0.4.5"
tower-http = "0.4.3"
rustls-pemfile = "1.0.3"
rustls-native-certs = "0.6.3"
[build-dependencies]
tonic-build = "0.9"
The build-dependencies
section is required to compile the proto file.
We need to also define our entry points in the Cargo.toml
file.
[[bin]] # Bin to run the HelloWorld gRPC server
name = "helloworld-server"
path = "src/server.rs"
[[bin]] # Bin to run the HelloWorld gRPC client
name = "helloworld-client"
path = "src/client.rs"
While we're at it let's also add build.rs
for compiling the proto file.
$ touch build.rs
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/helloworld.proto")?;
Ok(())
}
Okay, we're all set to start writing our proto file and then our server & client.
But first let's see our directory structure.
Proto file
Let's define our service in the proto/helloworld.proto
file.
// proto/helloworld.proto
syntax = "proto3";
package helloworld;
service Greeter {
rpc Send(HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
Wiring up client and server
Let's start with the server.
// src/server.rs
use tonic::{transport::Server, Request, Response, Status};
use hello_world::greeter_server::{Greeter, GreeterServer};
use hello_world::{HelloReply, HelloRequest};
pub mod hello_world {
tonic::include_proto!("helloworld"); // The string specified here must match the proto package name
}
#[derive(Debug, Default)]
pub struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn send(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloReply>, Status> {
println!("Got a request: {:?}", request);
let reply = HelloReply {
message: format!("Hello {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "127.0.0.1:50051".parse()?;
let greeter = MyGreeter::default();
println!("Server listening on {}", addr);
Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
Now, let's write our client.
// src/client.rs
use hello_world::greeter_client::GreeterClient;
use hello_world::HelloRequest;
pub mod hello_world {
tonic::include_proto!("helloworld");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = GreeterClient::connect("http://127.0.0.1:50051").await?;
let request = tonic::Request::new(HelloRequest {
name: "Tonic".into(),
});
let response = client.say_hello(request).await?;
println!("RESPONSE={:?}", response);
Ok(())
}
Testing the server and client
Let's test our server and client.
$ cargo run --bin helloworld-server
Finished dev [unoptimized + debuginfo] target(s) in 0.14s
Running `target/debug/helloworld-server`
Server listening on 127.0.0.1:50051
Now, in another terminal run the client.
$ cargo run --bin helloworld-client
Finished dev [unoptimized + debuginfo] target(s) in 0.15s
Running `target/debug/helloworld-client`
RESPONSE=Response \{ metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Mon, 28 Aug 2023 12:54:16 GMT", "grpc-status": "0"} }, message: HelloReply { message: "Hello Tonic!" }, extensions: Extensions }
Adding in https support
For adding https support we need server and client certificates. You can get them from tonic's repo (opens in a new tab) or you can generate them yourself.
Let's place the certificates in a tls
directory.
$ mkdir tls
Copy the ca.pem
, server.pem
and server.key
files from tonic's repo (opens in a new tab) to the tls
directory.
Certificates Infomation:
ca.pem
- Client Certificateserver.pem
- Server certificateserver.key
- Server private key
Modify the server.rs
file to use the certificates.
Note: I'm just copy-pasting most of the code from the tonic repo's tls_rustls (opens in a new tab) example and modifying it according to our proto file.
use hyper::server::conn::Http;
use tokio_rustls::rustls::{OwnedTrustAnchor, RootCertStore, ServerConfig};
use tonic::{transport::Server, Request, Response, Status};
use hello_world::greeter_server::Greeter;
use hello_world::{HelloReply, HelloRequest};
use tokio::sync::mpsc;
use tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt};
use std::error::Error;
use std::io::ErrorKind;
use std::pin::Pin;
use std::time::Duration;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_rustls::{
rustls::{Certificate, PrivateKey},
TlsAcceptor,
};
use tower_http::ServiceBuilderExt;
pub mod hello_world {
tonic::include_proto!("helloworld"); // The string specified here must match the proto package name
}
#[derive(Debug, Default)]
pub struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn send(&self, request: Request<HelloRequest>) -> Result<Response<HelloReply>, Status> {
Ok(Response::new(HelloReply {
message: format!("hello {}", request.get_ref().name),
}))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let data_dir = if cfg!(target_os = "wasi") {
std::env::current_dir()?
} else {
std::path::PathBuf::from(std::env!("CARGO_MANIFEST_DIR"))
};
let certs = {
let fd = std::fs::File::open(data_dir.join("tls/server.pem"))?;
let mut buf = std::io::BufReader::new(&fd);
rustls_pemfile::certs(&mut buf)?
.into_iter()
.map(Certificate)
.collect()
};
let key = {
let fd = std::fs::File::open(data_dir.join("tls/server.key"))?;
let mut buf = std::io::BufReader::new(&fd);
rustls_pemfile::pkcs8_private_keys(&mut buf)?
.into_iter()
.map(PrivateKey)
.next()
.unwrap()
};
let mut tls = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs, key)?;
tls.alpn_protocols = vec![b"h2".to_vec()];
let server = MyGreeter::default();
let svc = Server::builder()
.add_service(hello_world::greeter_server::GreeterServer::new(server))
.into_service();
let mut http = Http::new();
http.http2_only(true);
let listener = TcpListener::bind("127.0.0.1:50051").await?;
let tls_acceptor = TlsAcceptor::from(Arc::new(tls));
println!("Server listening on {}", listener.local_addr()?);
loop {
let (conn, addr) = match listener.accept().await {
Ok(incoming) => incoming,
Err(e) => {
eprintln!("Error accepting connection: {}", e);
continue;
}
};
let http = http.clone();
let tls_acceptor = tls_acceptor.clone();
let svc = svc.clone();
tokio::spawn(async move {
let mut certificates = Vec::new();
let conn = tls_acceptor
.accept_with(conn, |info| {
if let Some(certs) = info.peer_certificates() {
for cert in certs {
certificates.push(cert.clone());
}
}
})
.await
.unwrap();
let svc = tower::ServiceBuilder::new()
.add_extension(Arc::new(ConnInfo { addr, certificates }))
.service(svc);
http.serve_connection(conn, svc)
.await
.map_err(|e| {
eprintln!("Error serving connection: {}", e);
e
})
.unwrap();
});
}
}
#[derive(Debug)]
struct ConnInfo {
addr: std::net::SocketAddr,
certificates: Vec<Certificate>,
}
Now the client.
use std::{sync::Arc, time::Duration};
use hello_world::greeter_client::GreeterClient;
use hello_world::HelloRequest;
use http_body::{combinators::UnsyncBoxBody, Body};
use hyper::{body::Bytes, client::HttpConnector, Client, Request, Uri};
use hyper_rustls::HttpsConnector;
use tokio_rustls::rustls::{ClientConfig, ConfigBuilder, OwnedTrustAnchor, RootCertStore};
use tokio_stream::{Stream, StreamExt};
use tonic::{
body::BoxBody,
client::GrpcService,
transport::{Channel, ClientTlsConfig},
Status,
};
use tower::{util::MapRequest, ServiceExt};
pub mod hello_world {
tonic::include_proto!("helloworld");
}
async fn say_hello<T>(client: &mut GreeterClient<T>)
where
T: tonic::client::GrpcService<tonic::body::BoxBody> + Send + 'static,
T::ResponseBody: Body<Data = Bytes> + Send + 'static,
<T::ResponseBody as Body>::Error: Into<Box<dyn std::error::Error + Send + Sync>> + Send,
{
let request = tonic::Request::new(HelloRequest {
name: "Alice".into(),
});
let response = client.send(request).await.unwrap();
println!("RESPONSE={:?}", response);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let data_dir = if cfg!(target_os = "wasi") {
std::env::current_dir()?
} else {
std::path::PathBuf::from(std::env!("CARGO_MANIFEST_DIR"))
};
let fd = std::fs::File::open(data_dir.join("tls/ca.pem"))?;
let mut roots = RootCertStore::empty();
let mut buf = std::io::BufReader::new(&fd);
let certs = rustls_pemfile::certs(&mut buf)?;
roots.add_parsable_certificates(&certs);
let tls = ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(roots)
.with_no_client_auth();
let mut http = HttpConnector::new();
http.enforce_http(false);
let connector = tower::ServiceBuilder::new()
.layer_fn(move |s| {
let tls = tls.clone();
hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_or_http()
.enable_http2()
.wrap_connector(s)
})
.map_request(|_: Uri| Uri::from_static("https://127.0.0.1:50051"))
.service(http);
let client = hyper::Client::builder().build(connector);
// Using `with_origin` will let the codegenerated client set the `scheme` and
// `authority` from the provided `Uri`.
let uri = Uri::from_static("https://example.com");
let mut client = GreeterClient::with_origin(client, uri);
say_hello(&mut client).await;
Ok(())
}
Testing the server and client
In a terminal run the server.
$ cargo run --bin helloworld-server
Finished dev [unoptimized + debuginfo] target(s) in 1.77s
Running `target/debug/helloworld-server`
Server listening on 127.0.0.1:50051
Now, in another terminal run the client.
$ cargo run --bin helloworld-client
Finished dev [unoptimized + debuginfo] target(s) in 2.55s
Running `target/debug/helloworld-client`
RESPONSE=Response \{ metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Mon, 28 Aug 2023 13:29:27 GMT", "grpc-status": "0"} }, message: HelloReply { message: "hello Alice" }, extensions: Extensions }
Yup, it works.
WASIX Compatability
For compiling our project to wasix, we need to pin and patch some dependencies in our Cargo.toml
file.
[dependencies]
tonic = { version = "0.9", features = ["tls"] }
prost = "0.11"
tokio = { version = "=1.24.2", git = "https://github.com/wasix-org/tokio.git", branch = "epoll", default-features = false, features = [
"full",
] } # ๐๐ผ pinned to wasix fork
libc = { version = "0.2.139", git = "https://github.com/wasix-org/libc.git", branch = "master" } # ๐๐ผ pinned to wasix fork
tokio-stream = "0.1.14"
hyper-rustls = { git = "https://github.com/wasix-org/hyper-rustls.git", branch = "main", features = [
"http2",
] } # ๐๐ผ pinned to wasix fork
tokio-rustls = { version = "0.24.1", git = "https://github.com/wasix-org/tokio-rustls.git", branch = "main" } # ๐๐ผ pinned to wasix fork
hyper = { git = "https://github.com/wasix-org/hyper.git", branch = "v0.14.27" } # ๐๐ผ pinned to wasix fork
tower = "0.4.13"
http-body = "0.4.5"
tower-http = { version = "0.4.3", features = ["util", "add-extension"] }
rustls-pemfile = "1.0.3"
rustls-native-certs = { git = "https://github.com/wasix-org/rustls-native-certs.git" } # ๐๐ผ pinned to wasix fork
[build-dependencies]
tonic-build = "0.9"
[patch.crates-io]
rustls-native-certs = { git = "https://github.com/wasix-org/rustls-native-certs.git" } # ๐๐ผ patched to wasix fork
socket2 = { git = "https://github.com/wasix-org/socket2.git", branch = "v0.4.9" } # ๐๐ผ patched to wasix fork
tokio = { git = "https://github.com/wasix-org/tokio.git", branch = "epoll" } # ๐๐ผ patched to wasix fork
hyper = { git = "https://github.com/wasix-org/hyper.git", branch = "v0.14.27" } # ๐๐ผ patched to wasix fork
ring = { git = "https://github.com/wasix-org/ring.git", branch = "wasix" } # ๐๐ผ patched to wasix fork
rustls = { git = "https://github.com/wasix-org/rustls.git", branch = "v0.21.5" } # ๐๐ผ patched to wasix fork
For a list of all of our forks, you can checkout the patched repos page.
Compiling the project to WASIX
$ cargo wasix build --release
...
warning: `wasix-grpc` (bin "helloworld-server") generated 2 warnings (run `cargo fix --bin "helloworld-server"` to apply 1 suggestion)
warning: `wasix-grpc` (bin "helloworld-client") generated 6 warnings (run `cargo fix --bin "helloworld-client"` to apply 3 suggestions)
Finished release [optimized] target(s) in 26.41s
info: Post-processing WebAssembly files
Optimizing with wasm-opt
Optimizing with wasm-opt
Testing the server and client
In the client and server code you'll find this snippet.
let data_dir = if cfg!(target_os = "wasi") {
std::env::current_dir()?
} else {
std::path::PathBuf::from(std::env!("CARGO_MANIFEST_DIR"))
};
This code maps the data_dir
to current directory if the target_os
is wasi. This is important because CARGO_MANIFEST_DIR is not available in WASIX.
Running the server
$ wasmer run target/wasm32-wasmer-wasi/release/helloworld-server.wasm --net --mapdir /tls:./tls
Server listening on 127.0.0.1:50051
Running the client
$ wasmer run target/wasm32-wasmer-wasi/release/helloworld-client.wasm --net --mapdir /tls:./tls
RESPONSE=Response \{ metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Mon, 28 Aug 2023 15:01:09 GMT", "grpc-status": "0"} }, message: HelloReply { message: "hello Alice" }, extensions: Extensions }
Yup, works the same.
Flags Information
--net
- enables networking support--mapdir /tls:./tls
- maps the/tls
directory in the WASIX filesystem to the./tls
directory in the host filesystem.
To know more about the flags run wasmer run --help
.
๐คธ๐ผ Exercise Time
- Try to implement a server streaming example with WASIX.
- Try to implement a client streaming example with WASIX.
- Try to implement a bidirectional streaming example with WASIX.
Conclusion
In this tutorial we learned:
- how to use gRPC with https in WASIX. We used the tonic (opens in a new tab) crate to implement a simple gRPC server and client in Rust
- how to compile the project to WASIX
- how to run the gRPC server and client in WASIX
- how to use the
--net
and--mapdir
flags in Wasmer to enable networking support and map directories respectively.
For the full example code, you can checkout the repository below.
wasix-rust-examples/wasix-grpc