(Wasm) is a remarkable technology with numerous attractive features. It is multiplatform, offers near-native performance, and can be used both in browsers and on the server side. WebAssembly However, due to its relative youth, certain basic tasks can be more challenging than expected, particularly for newcomers. One such unexpectedly difficult task is passing and returning complex objects to and from WebAssembly modules. This challenge arises because WebAssembly only supports primitive datatypes such as int32, int64, float32, and float64. Consequently, passing complex objects like , , and structs with named fields ultimately boils down to the problem of passing arrays of bytes and applying serialization/deserialization algorithms to the data. arrays strings The general approach to achieving this is quite simple. First, allocate memory on the guest side (the Wasm module side) and copy the request's data from the host to that memory buffer. Then, pass the pointer to that memory along with the buffer size to the guest. The guest can process the data based on its own business logic and generate a result. Next, allocate memory for the result, copy the result's bytes into that buffer, and return a similar pair (pointer + buffer size) from the WebAssembly module to the host. Finally, it is important to remember to properly free all previously allocated memory buffers. When it comes to memory management in WebAssembly, it heavily depends on the language used for compiling to WebAssembly instructions. Some languages have built-in garbage collectors (GC), while others do not. Additionally, selecting a serialization format and library to interpret the passed bytes is not a trivial task. Ultimately, when it comes to writing the actual code that works correctly and can be safely deployed to production, things can become somewhat complicated, to say the least. [Footnote - 1] This problem is exacerbated by the lack of comprehensive and clear information available on the Internet. Despite official WebAssembly resources, documentation from various WebAssembly runtimes, and software engineers' blogs, the information regarding this task is often vague or incomplete. For instance, while there are some recipes available for Rust or JavaScript, not all of them are applicable to Go (which is the focus of this article). In other cases, we may come across examples of how to pass a string or array of data into the WebAssembly module, but finding good examples of returning similar strings from WebAssembly is challenging. Additionally, some examples illustrate the principles but lack proper memory management, rendering them useless and not suitable for production. In this article, we will walk through the solution to the task described above. We cannot cover all the diversity of languages and Wasm runtimes, so focus just on the following. We will write our guest application in , compile it to Wasm with compiler, and embed it with runtime into the host application which will be written also in Go. For serialization, we will use which is a format and a library very similar to the well-known Go TinyGo Wasmtime Karmem [Footnote - 2] Protobuf. Table of Contents API of the guest application. Memory management on the guest side Prepare request and pass it from host to guest The rest of the guest application implementation Accept the result on the host side How to run the complete example Conclusion Footnotes API of the guest application Our guest application will accept complex objects of type, which in Karmem language could be described as this: DataRequest struct DataRequest inline { Numbers []int32; K int32; } The type has two fields: an array of integers and a number . Our guest application will do very simple following business logic: return only those numbers which are greater than the given number. So, our guest application will return objects of type: DataRequest Numbers K K DataResponse struct DataResponse inline { NumbersGreaterK []int32; } These datatype definitions are located in the api.km file. We need to call Karmem code generator with a command: hackernoon_article_1/api$ go run karmem.org/cmd/karmem build --golang -o "v1" api.km This command generates for us a file which contains Go code for serialization and deserialization of and struct types. Karmem has very intuitive API, for example, here is a piece of code that creates a and serializes it to : api/v1/api_generated.go DataRequest DataResponse DataRequest []byte import "api/v1" req := v1.DataRequest{ Numbers: []int32{10, 43, 13, 24, 56, 16}, K: 42, } writer := karmem.NewWriter(4 * 1024) if _, err := req.WriteAsRoot(writer); err != nil { panic(err) } reqBytes := writer.Bytes() Deserialization could be accomplished in a similar (mirrored) manner: import "api/v1" reader := karmem.NewReader(reqBytes) req := new(v1.DataRequest) req.ReadAsRoot(reader) Now we are able to convert our requests and responses to and from arrays of bytes. Let's proceed to the next steps. Memory management on the guest side Before we begin to directly pass the data to the Wasm module, let's look at some details of memory management of our guest application. According to the description of our general approach, we need to: []byte allocate a buffer of guest's memory on the host side (need to copy request's bytes there), allocate a buffer of guest's memory on the guest side (need to copy response's bytes there), deallocate (free) previously allocated memory buffer on the host side (both buffers will be deallocated on the host side). Thus all we need here is a pair of functions: and (which are very similar to those used in the C language) exported by the guest application. Here is the function: Malloc Free [Footnote - 3] Malloc var allocatedBytes = map[uintptr][]byte{} //go:export Malloc func Malloc(size uint32) uintptr { buf := make([]byte, size) ptr := &buf[0] unsafePtr := uintptr(unsafe.Pointer(ptr)) allocatedBytes[unsafePtr] = buf return unsafePtr } Comment is not just a comment but a TinyGo way to mark the functions that should be exported out from the resulting Wasm module. The map holds all the references to all allocated memory buffers so GC will not come and collect them until they will be freed. The only non-trivial part here could be this ‘magic’: . //go:export Malloc allocatedBytes unsafePtr := uintptr(unsafe.Pointer(ptr)) This is simply a way in Go to get the raw (and thus ‘unsafe’) pointer to some object. We need raw pointer because we should treat it like an integer number so we are able to pass it to (and from) the Wasm. Implementation of the function is trivial, it simply deletes references to previously allocated buffers from the map: Free allocatedBytes //go:export Free func Free(ptr uintptr) { delete(allocatedBytes, ptr) } Also, we have a helper function to access the allocated memory buffers on the guest side: func getBytes(ptr uintptr) []byte { return allocatedBytes[ptr] } See the for the complete source code of the memory management module of the guest application. All this memory management code along with the rest of the guest application code will be compiled into Wasm instructions with the TinyGo compiler. The exact command will be presented a little bit later in this article. mem.go Prepare request and pass it from host to guest All the required preparations are done, so in this paragraph, we are ready to see the details of the host application code. We skip details of the Wasm runtime initialization because this is not the main focus of the article. The initialization is encapsulated in the function and we call it in the very beginning: newWasmInstance instance, store, mem, err := newWasmInstance("../guest/guest.wasm") if err != nil { panic(err) } The does all the initialization needed according to the Wasmtime and returns references to the Wasm VM instance as well as to its store and linear memory. newWasmInstance Getting started documentation Next, we get references to the three exported guest function objects that we will need: malloc := instance.GetFunc(store, "Malloc") free := instance.GetFunc(store, "Free") processRequest := instance.GetFunc(store, "ProcessRequest") They are the memory management and functions (those discussed in the previous paragraph) and the function which is the guest's function that implements the guest's API. Malloc Free ProcessRequest Conceptually accepts an instance of a struct type and returns an instance of a struct type. But in fact, it accepts two 32-bit integers and returns one 64-bit integer. ProcessRequest DataRequest DataResponse Here is its signature as declared in the guest's sources: //go:export ProcessRequest func ProcessRequest(reqPtr uintptr, reqLen uint32) uint64 { // ... } The two integers that function accepts are: ProcessRequest is the address to the beginning of the memory buffer where the serialized bytes are copied to reqPtr DataRequest is the size of that buffer. reqLen The resulting 64-bit integer holds bit representation of the two 32-bit integers that represent the address of the buffer and its size where serialized bytes of are copied to. The reason why it is a single 64-bit integer instead of a tuple of two 32-bit integers is that it is super unclear how TinyGo treats data when the function returns complex tuple-like results. It was not documented at the moment of writing this article (or simply I could not find it). So this should be considered as a workaround hack (that works very well) to return a pair of 32-bit integers from a function that is exported from a Wasm module. DataResponse // Here `reqBytes` is a []byte array with the DataRequest serialized bytes reqBytesLen := int32(len(reqBytes)) ptrReq, err := malloc.Call(store, reqBytesLen) if err != nil { panic(err) } int32PtrReq := ptrReq.(int32) copy( mem.UnsafeData(store)[int32PtrReq : int32PtrReq+reqBytesLen], reqBytes, ) respPtrLen, err := processRequest.Call(store, int32PtrReq, reqBytesLen) if err != nil { panic(err) } free.Call(store, int32PtrReq) // `respPtrLen` points to the `DataResponse`, we will use it in the next steps. TO BE CONTINUED... Here, we call function that allocates the memory buffer of the exact size to fit the data, copy that bytes data to the buffer, and call the function. Right after that, we call on the allocated memory buffer. Malloc reqBytes ProcessRequest Free There is a lot of typecasting happening here and this deserves a bit of explanation. For example, function as you may have noticed accepts two 32-bit integers of unsigned types: and . But Wasm supports only signed int32 types. You may see it if you inspect (for example with command, more info about their CLI tool) the Wasm module: ProcessRequest uintptr uint32 wasmer inspect guest.wasm here Type: wasm Size: 86.3 KB <...> Exports: Functions: <...> "ProcessRequest": [I32, I32] -> [I64] <...> That is why we have to do all the on the host side explicitly, it is not performed automatically because Go does not allow this. Here is the full source code of the host's module. typecasting main.go The rest of the guest application implementation So far so good, we have sent the address to the serialized bytes and the number of those bytes to the guest. Let's see how guest handles this data: DataRequest //go:export ProcessRequest func ProcessRequest(reqPtr uintptr, reqLen uint32) uint64 { reader := karmem.NewReader(getBytes(reqPtr)) req := new(v1.DataRequest) req.ReadAsRoot(reader) resp := doProcessRequest(req) writer := karmem.NewWriter(4 * 1024) if _, err := resp.WriteAsRoot(writer); err != nil { panic(err) } respBytes := writer.Bytes() respBytesLen := uint32(len(respBytes)) ptrResp := Malloc(respBytesLen) respBuf := getBytes(ptrResp) copy(respBuf, respBytes) return packPtrAndSize(ptrResp, respBytesLen) // NOTE: It is the host's responsibility to free this memory! } First, the guest asks its memory management ‘subsystem’ to find a corresponding array by calling function. Second, the Karmem library is used to deserialize the struct instance and as a result the variable references to it. After that, the function is called which contains all the ‘business logic’ of our guest application. []bytes getBytes(reqPtr) DataRequest req doProcessRequest Here it is (it is trivial, but the main point here is that it produces an instance of a struct): DataResponse func doProcessRequest(req *v1.DataRequest) *v1.DataResponse { result := make([]int32, 0) for _, number := range req.Numbers { if number > req.K { result = append(result, number) } } resp := v1.DataResponse{ NumbersGreaterK: result, } return &resp } At the end of the function, some interesting things happen. We take the , call the function to allocate the memory buffer (which is Wasm's linear memory, but the host also is perfectly able to read from it) and copy the serialized (with Karmem) bytes of data into that buffer. ProcessRequest respBytes Malloc DataResponse Then, we call the function that ‘joins’ two 32-bit integers into one 64-bit integer and return it to the host. The implementation of the has some bit manipulation: packPtrAndSize packPtrAndSize func packPtrAndSize(ptr uintptr, size uint32) (ptrAndSize uint64) { return uint64(ptr)<<uint64(32) | uint64(size) } It takes a 64-bit integer and writes the 32-bits of the variable to the high bits of it. It also writes the 32-bits of the variable to the low bits of the 64-bit integer. Then returns the 64-bit integer as a result. The full source code of the guest's module could be found . ptr size main.go here One very important thing in this part of the article is that the memory buffer that was for the result should be at some point in time when it will be not needed anymore. How this is implemented in code will be seen right in the next paragraph. The reasoning behind this way of managing memory is that Wasm module (compiled by TinyGo) has it's own GC onboard. allocated by the guest DataResponse deallocated by the host If we simply return the address of the byte buffer that returned Karmem to us (the variable), there is no guarantee that GC will not collect this memory after the function completes. And this could be fatal if garbage collection of that memory will happen before the host will use the response. respBytes ProcessRequest After all the sources of the guest application is in their place, we can call the TinyGo compiler and build the Wasm module with a command: hackernoon_article_1/guest$ tinygo build -target=wasi -o guest.wasm . This should produce a binary file which is used by the host application. hackernoon_article_1/guest/guest.wasm Accept the result on the host side Let's look at what happens on the host side right after the function call: ProcessRequest // `respPtrLen` points to the `DataResponse`, we will use it in the next steps. TO BE CONTINUED... respPtr, respLen := unpackPtrAndSize(uint64(respPtrLen.(int64))) resp := new(v1.DataResponse) respBytes := mem.UnsafeData(store)[int32(respPtr) : int32(respPtr)+int32(respLen)] resp.ReadAsRoot(karmem.NewReader(respBytes)) fmt.Printf("NumbersGreaterK=%v\n", resp.NumbersGreaterK) free.Call(store, respPtr) // This memory was allocated on the guest side, we free it on the host side here The resulting 64-bit variable is being ‘unpacked’ into two 32-bit integers which are the address of the memory buffer and the size of that buffer. The function that does the opposite thing that was made by : respPtrLen unpackPtrAndSize packPtrAndSize func unpackPtrAndSize(ptrSize uint64) (ptr uintptr, size uint32) { ptr = uintptr(ptrSize >> 32) size = uint32(ptrSize) return } Then, we call Karmem to deserialize the bytes in the resulting buffer into the struct instance and use the data from there (in our example this usage is a simple printing of the to the standard output). After we used the resulting data we call on the because it is the host's responsibility to free that memory buffer that was allocated by the guest code on the guest side. DataResponse resp resp.NumbersGreaterK Free respPtr End of story! How to run the complete example The complete sources of the example discussed in this article are in my GitHub . What you need to do as a prerequisite is: repo Install the latest Go (I used ) according to their official . go version go1.19.5 linux/amd64 instructions Install the latest TinyGo (I used ) according to their official . tinygo version 0.27.0 linux/amd64 (using go version go1.19.5 and LLVM version 15.0.0) instructions Make sure you have both and executables in your . go tinygo PATH I have created a bunch of in the git repo, so theoretically all you have to do is to execute: Makefiles hackernoon_article_1$ make Which should do the following: generate the Karmem serialization/deserialization code from the definitions api.km call the TinyGo compiler and build the guest.wasm Wasm module run the host Go application Here is the output on my machine: vitvlkv@littlepea:~/hackernoon_article_1$ make cd ./host && make make[1]: Entering directory '/home/vitvlkv/hackernoon_article_1/host' cd ../api && make make[2]: Entering directory '/home/vitvlkv/hackernoon_article_1/api' go run karmem.org/cmd/karmem build --golang -o "v1" api.km make[2]: Leaving directory '/home/vitvlkv/hackernoon_article_1/api' cd ../guest && make make[2]: Entering directory '/home/vitvlkv/hackernoon_article_1/guest' cd ../api && make make[3]: Entering directory '/home/vitvlkv/hackernoon_article_1/api' make[3]: Nothing to be done for 'all'. make[3]: Leaving directory '/home/vitvlkv/hackernoon_article_1/api' tinygo build -target=wasi -o guest.wasm . make[2]: Leaving directory '/home/vitvlkv/hackernoon_article_1/guest' WASMTIME_BACKTRACE_DETAILS=1 go run . Numbers=[10 43 13 24 56 16], K=42 NumbersGreaterK=[43 56] make[1]: Leaving directory '/home/vitvlkv/hackernoon_article_1/host' As you can see, we passed to the guest array of and integer and received back an array with two integers that are larger than the given in the input array. This is exactly what we wanted our guest application to do! Numbers=[10 43 13 24 56 16] K=42 NumbersGreaterK=[43 56] K Numbers Conclusion This article proposes a solution to a common problem faced by software engineers working with WebAssembly: passing non-primitive datatypes to and from a Wasm module. This solution has been effectively applied at , where I currently work. WebAssembly has a steep learning curve, but it also holds great potential as a technology. I hope this article will be helpful to someone in their work or personal projects. TakeProfit Inc. Footnotes If we'd write our guest application in Rust, then the whole task could be solved in a much simpler manner, using their library. [1] wasm-bindgen JSON format could be used too, but it should be noted that Go's standard library doesn't work in TinyGo, because TinyGo does not support reflection. People who need to use JSON without schema in Wasm usually go with gson library. tinyjson seems to be a good alternative for cases where the schema of all JSON messages is known. For this article I was looking for something like Protobuf but unfortunately, their Go's implementation does not work with TinyGo. Karmem is very close to Protobuf conceptually, that is why I decided to use it. [2] encoding/json When TinyGo compiles Go sources into the Wasm module it automatically adds some own implementations of and functions to the exports list (they could be observed if you inspect the Wasm module with some corresponding tool like ). We do not use them for two reasons: 1) TinyGo promises nothing about the stability of exported and 2) we have to call for storing the result somehow on the guest side, it is unclear how to call standard this way but very straightforward with our own custom . [3] malloc free Wasmer inspect malloc free Malloc malloc Malloc The lead image for this article was generated by HackerNoon's AI Image Generator via the prompt "A computer screen with web assembly displayed"