Adding experimental WebAssembly support to Decaton – Part 2

In the 1st part of this article, we looked into how we can integrate WebAssembly runtime into a Java application. Then we faced a big limitation of current Wasm/WASI that it doesn’t support sockets, making most of the practical Decaton processors difficult to be hosted on it due to disabled network I/O. In this 2nd part, we will attempt to extend WASI APIs and Rust’s standard library to add support for sockets. The goal is to make the below Decaton Wasm processor working (the processor implementation is exactly the same as the one shown in the 1st part):

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    path: String,
}

#[no_mangle]
pub unsafe extern "C" fn run() {
    let mut task_buf = [0u8; TASK_BUF_SIZE];
    let task_len = poll_task(task_buf.as_mut_ptr() as i64, task_buf.len() as i32);
    let task: Task = serde_json::from_slice(&task_buf[..task_len as usize]).unwrap();
    let mut stream = TcpStream::connect("127.0.0.1:8080").unwrap();
    write!(stream, "GET {} HTTP/1.0\r\n\r\n", task.path).unwrap();

    io::copy(&mut stream, &mut io::stdout()).unwrap();
}

Implementing sockets for WASI

The framework for implementing APIs for the Wasm/WASI ecosystem seems to be well-considered and designed, so it wouldn’t be that difficult to try that!

I started with understanding the current project structure around WASI, Wasmtime, and Rust. Filesystem-related operations are a good example to look into, as it is a working example of WASI on Rust.

So let’s see how std::fs::File::open() works for the wasi platform. Clone rust-lang/rust to my local and checkout the stable branch to start reading code. Looking into File implementation in libstd/sys/wasi/fs.rs, the implementation consists of two parts: open_parent() and open_at().

// libstd/sys/wasi/fs.rs
impl File {
    pub fn open(path: &Path, opts: &OpenOptions) -> io::Result<File> {
        let (dir, file) = open_parent(path)?;
        open_at(&dir, &file, opts)
    }

The open_parent function takes a path and tries to look up if there’s a “preopened” file descriptor associated with the prefix of the given path. The fd lookup is implemented by __wasilibc_find_relpath which is provided by wasi-libc – the libc implementation intended to be a common layer for Wasm runtimes that supports WASI. The “preopen” concept is common to WASI which requires every filesystem access within a Wasm program to have its ancestor directory explicitly opened (allowed), which is similar to chroot on Unix. If the target path has its ancestor preopened, __wasilibc_find_relpath returns successfully and continues to open_at.

// libstd/sys/wasi/fs.rs
fn open_parent(p: &Path) -> io::Result<(ManuallyDrop<WasiFd>, PathBuf)> {
...
        let fd = __wasilibc_find_relpath(p.as_ptr(), &mut ret);
...
        return Ok((ManuallyDrop::new(WasiFd::from_raw(fd as u32)), path));
    }

    extern "C" {
        pub fn __wasilibc_find_relpath(
            path: *const libc::c_char,
            relative_path: *mut *const libc::c_char,
        ) -> libc::c_int;
    }

The implementation of open_at in wasi/fs.rs is just a delegation for WasiFd::openWasiFd::open is again a delegation for wasi::path_open which is defined in external crate, wasi.

// libstd/sys/wasi/fs.rs
fn open_at(fd: &WasiFd, path: &Path, opts: &OpenOptions) -> io::Result<File> {
    let fd = fd.open(
        opts.dirflags,
        osstr2str(path.as_ref())?,
        opts.oflags,
        opts.rights_base(),
        opts.rights_inheriting(),
        opts.fdflags,
    )?;
    Ok(File { fd })
}
...
// libstd/sys/wasi/fd.rs
impl WasiFd {
...
    pub fn open(...) -> io::Result<WasiFd> {
        unsafe {
            wasi::path_open(
                self.fd,
                dirflags,
                path,
                oflags,
                fs_rights_base,
                fs_rights_inheriting,
                fs_flags,
            )
            .map(|fd| WasiFd::from_raw(fd))
            .map_err(err2io)
        }
    }

The implementation of path_open exists in src/lib_generated.rs at wasi crate. As you can see, what it basically does is just calling another function wasi_snapshot_preview1::path_open().

// lib_generated.rs
pub unsafe fn path_open(
...
    let rc = wasi_snapshot_preview1::path_open(
        fd,
        dirflags,
        path.as_ptr(),
        path.len(),
        oflags,
        fs_rights_base,
        fs_rights_inherting,
        fdflags,
        opened_fd.as_mut_ptr(),
    );
...

So where is wasi_snapshot_preview1::path_open() defined? Actually you can find it in the same file as follows:

// lib_generated.rs
pub mod wasi_snapshot_preview1 {
    use super::*;
    #[link(wasm_import_module = "wasi_snapshot_preview1")]
    extern "C" {
    ...
        pub fn path_open(
...

So the module wasi_snapshot_preview1 contains a bunch of externally defined function’s prototypes, but the actual implementation is supplied externally as the extern "C" block indicates. The interesting line here is #[link(wasm_import_module = "wasi_snapshot_preview1")]. As we saw in the first processor example, this attribute means the “extern” functions below are linked (imported) dynamically by WebAssembly runtime. That is, to find the implementation of the function wasi_snapshot_preview1::path_open(), we need to look at the WebAssembly runtime – Wasmtime.

WASI implementation in Wasmtime

The path_open implementation can be located at crates/wasi-common/src/snapshots/wasi_snapshot_preview1.rs in the Wasmtime repository. Besides other works like checking permission (called “rights”), its main part is to call dirfd.openat() implemented by sys::osdir::OsDir which then calls sys::unix::path::open().

// crates/wasi-common/src/snapshots/wasi_snapshot_preview1.rs
impl<'a> WasiSnapshotPreview1 for WasiCtx {
...
    fn path_open(
...
    ) -> Result<types::Fd> {
...
        let fd = dirfd.openat(&path, read, write, oflags, fdflags)?;
...

sys::unix::path::open() calls openat() function that calls yanix::file::openat(), a function of the crate which stands for Unix system interface layer for Wasmtime, as explained in its crate document.

// crates/wasi-common/src/libstd/sys/unix/path.rs
pub(crate) fn open(
...
    use yanix::file::{fstatat, openat, AtFlags, FileType, Mode, OFlags};
...
    let fd_no = unsafe {
        openat(
            dirfd.as_raw_fd(),
            path,
            nix_all_oflags,
            Mode::from_bits_truncate(0o666),
        )
    };
// crates/wasi-common/yanix/src/file.rs
pub unsafe fn openat<P: AsRef<Path>>(
...
    from_result(libc::openat(
        dirfd,
        path.as_ptr(),
        oflag.bits(),
        libc::c_uint::from(mode.bits()),
    ))

Note that the libc crate here is compiled for unix target since Wasmtime itself is compiled and run on the host platform, so the openat called here is a well-known POSIX compliant openat.

The below image describes these relationships among Rust (Wasm source), Wasm runtime, and WASI.

Implement socket in WASI

As we now understand how WASI works, it’s time to start implementing socket support on it.

Add new interface to WASI (witx)

At first, we need to add some functions to WASI to support operations related to sockets.

Both of wasi crate and Wasmtime employs code generation to keep it WASI compliant. In wasi crate, nearly all of its content is found in lib_generated.rs and as its name implies, it is generated from other sources. The source is witx in the WASI repository, an interface description format developed for WASI.

Looking into it, we can find that it already contains three functions related to socket – sock_recvsock_send, and sock_shutdown.

  ;;; Receive a message from a socket.
  ;;; Note: This is similar to `recv` in POSIX, though it also supports reading
  ;;; the data into multiple buffers in the manner of `readv`.
  (@interface func (export "sock_recv")
    (param $fd $fd)
    ;;; List of scatter/gather vectors to which to store data.
    (param $ri_data $iovec_array)
    ;;; Message flags.
    (param $ri_flags $riflags)
    (result $error $errno)
    ;;; Number of bytes stored in ri_data.
    (result $ro_datalen $size)
    ;;; Message flags.
    (result $ro_flags $roflags)
  )

  ;;; Send a message on a socket.
  ;;; Note: This is similar to `send` in POSIX, though it also supports writing
  ;;; the data from multiple buffers in the manner of `writev`.
  (@interface func (export "sock_send")
    (param $fd $fd)
    ;;; List of scatter/gather vectors to which to retrieve data
    (param $si_data $ciovec_array)
    ;;; Message flags.
    (param $si_flags $siflags)
    (result $error $errno)
    ;;; Number of bytes transmitted.
    (result $so_datalen $size)
  )
...

These are good examples and all we need is to add one more function for creating and connecting socket. Let’s add a new function that creates a new socket with connecting it to the given address. I added the definition below to phases/snapshot/witx/wasi_snapshot_preview1.witx in a local copy of the WASI repository.

(@interface func (export "sock_connect")
  (param $ipv4_addr u32)
  (param $port u16)
  (result $error $errno)
  (result $sock_fd $fd)
)

Then I can regenerate src/lib_generated.rs for the wasi crate.

@ ~/wasi (main)
$ cargo build --manifest-path=crates/witx-bindgen/Cargo.toml
$ ./target/debug/witx-bindgen /path/to/my/WASI/phases/snapshot/witx/wasi_snapshot_preview1.witx > src/lib_generated.rs

Now it exports the new sock_connect function to use it from Rust code.

diff --git a/src/lib_generated.rs b/src/lib_generated.rs
index 4227941..bb7b01d 100644
--- a/src/lib_generated.rs
+++ b/src/lib_generated.rs
...
@@ -1826,6 +1840,7 @@ pub mod wasi_snapshot_preview1 {
         /// required, it's advisable to use this function to seed a pseudo-random
         /// number generator, rather than to provide the random data directly.
         pub fn random_get(buf: *mut u8, buf_len: Size) -> Errno;
+        pub fn sock_connect(ipv4_addr: u32, port: u16, sock_fd: *mut Fd) -> Errno;
         /// Receive a message from a socket.
         /// Note: This is similar to `recv` in POSIX, though it also supports reading
         /// the data into multiple buffers in the manner of `readv`.

Patch Rust’s standard library

Now I have my own wasi crate that provides the sock_connect function and we’re ready to implement socket for wasi platform in Rust’s stdlib. The implementation of TcpStream for the wasi platform is at libstd/sys/wasi/net.rs. As we were told by the error message, currently it has no implementation except call for unsupported() which generates that error message.

// libstd/sys/wasi/net.rs
impl TcpStream {
    pub fn connect(_: io::Result<&SocketAddr>) -> io::Result<TcpStream> {
        unsupported()
    }

    pub fn connect_timeout(_: &SocketAddr, _: Duration) -> io::Result<TcpStream> {
        unsupported()
    }

Before start implementing these methods let’s refer to how it’s implemented for the Unix platform.

The implementation of TcpStream for Unix can be found in libstd/sys_common/net.rs and it is actually common among many other platforms with it depending on platform-specific Socket implementation that exists in libstd/sys/unix/net.rs for Unix. The common TcpStream depends on various POSIX compliant constants and functions provided by crate::sys::net::netc, which is actually the libc crate (libstd/sys/unix/net.rs).

Making use of common TcpStream isn’t workable for the wasi platform for now because the libc crate compiled for the wasi platform doesn’t provide many POSIX compliant members. So the strategy here is to implement TcpStream for the wasi platform directly using functions provided by wasi crate.

To use the patched version of wasi crate, we need to change libstd/Cargo.toml to point wasi crate that we have modified to support sockets.

diff --git a/src/libstd/Cargo.toml b/src/libstd/Cargo.toml
...
 [target.wasm32-wasi.dependencies]
-wasi = { version = "0.9.0", features = ['rustc-dep-of-std'], default-features = false }
+wasi = { path = "/path/to/wasi", features = ['rustc-dep-of-std'], default-features = false }

Then we can use our local copy version of wasi crate that provides the sock_connect function. The patch for TcpStream looks like this:

// libstd/sys/wasi/net.rs
fn iovec<'a>(a: &'a mut [IoSliceMut<'_>]) -> &'a [wasi::Iovec] {
    assert_eq!(mem::size_of::<IoSliceMut<'_>>(), mem::size_of::<wasi::Iovec>());
    assert_eq!(mem::align_of::<IoSliceMut<'_>>(), mem::align_of::<wasi::Iovec>());
    // SAFETY: `IoSliceMut` and `IoVec` have exactly the same memory layout
    unsafe { mem::transmute(a) }
}

fn ciovec<'a>(a: &'a [IoSlice<'_>]) -> &'a [wasi::Ciovec] {
    assert_eq!(mem::size_of::<IoSlice<'_>>(), mem::size_of::<wasi::Ciovec>());
    assert_eq!(mem::align_of::<IoSlice<'_>>(), mem::align_of::<wasi::Ciovec>());
    // SAFETY: `IoSlice` and `CIoVec` have exactly the same memory layout
    unsafe { mem::transmute(a) }
}

impl TcpStream {
    pub fn connect(addr: io::Result<&SocketAddr>) -> io::Result<TcpStream> {
        if let SocketAddr::V4(ipv4) = addr? {
            let addr: u32 = ipv4.ip().clone().into();
            let port: u16 = ipv4.port();
            let fd = unsafe { wasi::sock_connect(addr, port).unwrap() };
            Ok(Self { fd: unsafe { WasiFd::from_raw(fd) } })
        } else {
            // Ignore ipv6 for now...
            unsupported()
        }
    }
...
    pub fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
        self.read_vectored(&mut [IoSliceMut::new(buf)])
    }

    pub fn read_vectored(&self, iov: &mut [IoSliceMut<'_>]) -> io::Result<usize> {
        Ok(unsafe { wasi::sock_recv(self.fd.as_raw(), iovec(iov), 0).unwrap().0 })
    }
...
    pub fn write(&self, buf: &[u8]) -> io::Result<usize> {
        self.write_vectored(&[IoSlice::new(buf)])
    }

    pub fn write_vectored(&self, iov: &[IoSlice<'_>]) -> io::Result<usize> {
        Ok(unsafe { wasi::sock_send(self.fd.as_raw(), ciovec(iov), 0).unwrap() })
    }
...

Now let’s try building Rust. Building Rust is done with a single command, but we need to edit config.toml a bit to tell it to build wasm32-wasi target as well as the default platform target:

$ diff -ud config.toml.example config.toml
--- config.toml.example 2020-08-06 14:28:51.000000000 +0900
+++ config.toml 2020-09-04 18:14:19.000000000 +0900
@@ -129,7 +129,7 @@
...
-#target = ["x86_64-unknown-linux-gnu"] # defaults to just the build triple
+target = ["wasm32-wasi"]
...
@@ -400,7 +400,7 @@

 # Indicates whether LLD will be compiled and made available in the sysroot for
 # rustc to execute.
-#lld = false
+lld = true
...
@@ -508,8 +508,9 @@
 # linked binaries
 #musl-root = "..."

+[target.wasm32-wasi]
 # The root location of the `wasm32-wasi` sysroot.
-#wasi-root = "..."
+wasi-root = "/path/to/wasi-libc/sysroot"
...

To build wasm32-wasi target, we need to provide a built sysroot of wasi-libc that can be done by cloning it and run below command:

@ ~/wasi-libc (master)
$ LLVM_ROOT=/usr/local/Cellar/llvm/$LLVM_VERSION
$ make WASM_CC=$LLVM_ROOT/bin/clang WASM_AR=$LLVM_ROOT/bin/llvm-ar WASM_NM=$LLVM_ROOT/bin/llvm-nm -j8

Then we can build Rust by following one command:

@ ~/rust (stable)
$ ./x.py build

Once the build is complete, a newly built sysroot is ready under build/x86_64-apple-darwin/stage2. We can add it as a custom toolchain to rustup:

@ ~/rust (stable)
$ rustup toolchain link my-stable $(pwd)/build/x86_64-apple-darwin/stage2
$ rustup run my-stable rustc --version
rustc 1.45.2-dev

Add WASI function implementations for Wasmtime

Next is to implement these functions in Wasmtime, which are to be imported by the Wasm module and used to deal with sockets. Actually, I had to implement not only the function I added – sock_connect – but also other socket functions that were not implemented in Wasmtime yet.

I tried to keep the implementation as simple as possible so unlike filesystem related operations all the work is implemented directly in WasiSnapshotPreview1 methods bypassing yanix and many layers better to have for production quality code. In this implementation, sock_connect simply creates a new TcpStream and stores it in entries with custom Handle implementation for sockets.

It’s entirely about gluing WASI types to Rust stdlib types, so it should be very straightforward to understand. Cleanup of the file descriptor underlying TcpStream is done by the existing fd_close function which is called by compiled Wasm code at where it is appropriate and drops SocketHandle so the inner TcpStream is also dropped and cleaned up.

// crates/wasi-common/src/snapshots/wasi_snapshot_preview1.rs
impl<'a> WasiSnapshotPreview1 for WasiCtx {
...
    fn sock_connect(&self, ipv4_addr: u32, port: u16) -> Result<types::Fd> {
        let addr = SocketAddrV4::new(Ipv4Addr::from(ipv4_addr), port);
        let stream = TcpStream::connect(addr)?;
        let handle: Box<dyn crate::handle::Handle> =
            Box::new(SocketHandle(std::cell::RefCell::new(stream)));
        let entry = Entry::new(EntryHandle::from(handle));
        self.insert_entry(entry)
    }

    fn sock_recv(
        &self,
        fd: types::Fd,
        ri_data: &types::IovecArray<'_>,
        _ri_flags: types::Riflags,
    ) -> Result<(types::Size, types::Roflags)> {
        let mut bufs = Vec::with_capacity(ri_data.len() as usize);
        for iov in ri_data.iter() {
            let iov = iov?;
            let iov: types::Iovec = iov.read()?;
            let buf = iov.buf.as_array(iov.buf_len).as_slice()?;
            bufs.push(buf);
        }
        let mut iovs: Vec<_> = bufs.iter_mut().map(|s| IoSliceMut::new(&mut *s)).collect();
        let total_size = self
            .get_entry(fd)?
            .as_handle(&HandleRights::empty())?
            .read_vectored(&mut iovs)?;
        Ok((total_size as u32, types::Roflags::try_from(0)?))
    }

    fn sock_send(
        &self,
        fd: types::Fd,
        si_data: &types::CiovecArray<'_>,
        _si_flags: types::Siflags,
    ) -> Result<types::Size> {
        let mut bufs = Vec::with_capacity(si_data.len() as usize);
        for iov in si_data.iter() {
            let iov = iov?;
            let iov: types::Ciovec = iov.read()?;
            let buf = iov.buf.as_array(iov.buf_len).as_slice()?;
            bufs.push(buf);
        }
        let iovs: Vec<_> = bufs.iter().map(|s| IoSlice::new(&*s)).collect();
        let total_size = self
            .get_entry(fd)?
            .as_handle(&HandleRights::empty())?
            .write_vectored(&iovs)?;
        Ok(total_size as u32)
    }
...
}

struct SocketHandle(std::cell::RefCell<std::net::TcpStream>);

impl crate::handle::Handle for SocketHandle {
    fn read_vectored(&self, iovs: &mut [io::IoSliceMut]) -> Result<usize> {
        Ok(self.0.borrow_mut().read_vectored(iovs)?)
    }

    fn write_vectored(&self, iovs: &[io::IoSlice]) -> Result<usize> {
        Ok(self.0.borrow_mut().write_vectored(iovs)?)
    }

    fn get_file_type(&self) -> types::Filetype {
        types::Filetype::SocketStream
    }
...
}

We can then confirm that it builds well by running cargo build.

$ cargo build
    Finished dev [unoptimized + debuginfo] target(s) in 0.50s

Now the implementation for socket functions is ready in Wasmtime.

Testing

Now everything’s ready!

Let’s try rebuilding the Wasm processor with our own build of rust toolchain.

@ ~/decaton-wasm-processor (master)
$ rustup run my-stable cargo wasi build
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
# Check if sock_connect is actually used ...
$ wasm2wat target/wasm32-wasi/debug/decaton_wasm_processor.wasm | grep 'call .*wasi_snapshot_preview1::sock_connect'
        call $wasi::lib_generated::wasi_snapshot_preview1::sock_connect::h456eae0d83b6c006

After rebuilding and reinstalling wasmtime-java with the patched version of Wasmtime, I can try re-running Decaton processor:

$ ./bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP_SERVERS --topic $TOPIC <<'EOS'
{"path":"/README.md"}
EOS
...

@ ~/decaton/wasmton (wasmton)
$ ../gradlew run ...
...
HTTP/1.0 200 OK
Server: ...
Date: Mon, 31 Aug 2020 10:11:27 GMT
Content-type: application/octet-stream
Content-Length: 5173
Last-Modified: Fri, 17 Jul 2020 03:43:59 GMT

Decaton
=======

[![Build Status](https://travis-ci.com/line/decaton.svg?branch=master)](https://travis-ci.com/line/decaton)

Decaton is a streaming task processing framework built on top of [Apache Kafka](https://kafka.apache.org/).
It is designed to enable "concurrent processing of records consumed from one partition" which isn't possible in many Kafka consumer frameworks.
...

Yup, now I see the README of the Decaton project served by the Python HTTP server 🙂

Another example with Redis

A very basic socket example worked, proving the capability of Wasm/WASI to apply it for the language-agnostic processor interface for Decaton. However I implemented only a few functions so it might be still far from something ready to use for production.

Let’s end this experiment with trying another, probably much interesting processor example. I modified the processor to access Redis and store key-value instead of the HTTP request. This time I used redis crate as a client so it should be much closer to real-world processors.

static mut REDIS_CLIENT: Option<redis::Client> = None;

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    key: String,
    value: i32,
}

#[link(wasm_import_module = "decaton")]
extern "C" {
    fn poll_task(addr: i64, len: i32) -> i32;
}

#[no_mangle]
pub unsafe extern "C" fn _initialize() {
    REDIS_CLIENT.replace(redis::Client::open("redis://127.0.0.1/").unwrap());
}

#[no_mangle]
pub unsafe extern "C" fn run() {
    let mut buf = [0u8; TASK_BUF_SIZE];
    let len = poll_task(buf.as_mut_ptr() as i64, buf.len() as i32);
    let task: Task = serde_json::from_slice(&buf[..len as usize]).unwrap();

    let mut con = REDIS_CLIENT.as_ref().unwrap().get_connection().unwrap();
    // throw away the result, just make sure it does not fail
    let _: () = con.set(&task.key, task.value).unwrap();
    let val: i32 = con.get(&task.key).unwrap();
    eprintln!("Store value: {}", val);
}

I built it and ran it exactly in the same way as other examples.

@ ~/decaton/wasmton (wasmton)
$ ../gradlew --no-daemon run --args "$BOOTSTRAP_SERVERS $TOPIC /path/to/decaton_wasm_processor.wasm"
...

$ redis-server &
...

$ ./bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP_SERVERS --topic $TOPIC <<'EOS'
{"key":"foo","value":1234}
{"key":"bar","value":5678}
EOS

$ redis-cli monitor 
OK
1599130203.826583 [0 127.0.0.1:52310] "SET" "foo" "1234"
1599130203.826866 [0 127.0.0.1:52310] "GET" "foo"
1599130212.775376 [0 127.0.0.1:52314] "SET" "bar" "5678"
1599130212.775538 [0 127.0.0.1:52314] "GET" "bar"

Surprisingly, such minimal socket implementation was enough to make Redis client work as well! This made me confident to think expanding WASI for sockets and for other groups of system calls would be just doable.

Conclusion

In this article, we have overviewed two topics both related to WebAssembly, how to embed WebAssembly execution capability to Java applications and how to extend Wasm runtime when the existing capabilities aren’t sufficient to do something we want.

In terms of WebAssembly applied outside of browsers, I think it has a very promising future. We can think of numerous applications such as FaaS, code embedding to edge proxy, or to a database for coprocessors and as an alternative (or complemental) application deployment format which might empower hosting providers to enable a totally new way of managing code deployment which was not possible with black box containers.

While I have no doubt about the value of goals that Wasm/WASI is aiming to achieve, I also think its success will highly depend on how many major programming languages will provide support for it as a compilation target. Fortunately, some languages seem to have started planning already such as Go and Swift. To support this trend, I think it will be very important to quickly mature Wasm/WASI specs especially for some core functionalities that are required by most applications such as sockets.

The WASI socket support that I implemented in this article is just a toy and production-grade implementation requires much careful consideration and designing of APIs especially in regards to how to sandbox network resources accessible from the guest. Fortunately, there are few people already working on it, so we might see it officially supported by WASI soon.

As you saw in this article, it is very easy to work with Wasm without any burden (at least with Rust toolchain!) so why don’t you try it! I have published whole repositories that I have mentioned in this article at decaton-wasm-playground so please feel free to pull it into your local and play with it 🙂