Avatar

Leveraging WASM to write isomorphic Golang ➡️ JavaScript library

← Back to list
Posted on 19.12.2023
Image by AI on Midjourney
Refill!

When I was a JS/JS developer, I was always proud of the ability of JS to run in both NodeJS and in a browser. This is something I really miss with the Golang/TS stack. But what if... having isomorphic library is still an option?

My work project had an endpoint, that neither produced side effects nor used the database state, it was pure Golang. The logic was also heavily used by another service of our backed application.

Nevertheless, I thought "Is there possibly a way to re-use this chunk of code on the client and avoid that network round-trip?" And so it turns out, there is away - using WASM.

The concept

The idea of this PoC is simple:

  • Write a function in Go and compile it to WASM byte code.
  • Find a way to pass a JavaScript object as an argument, and get the result as an array of objects. This is a typical use-case, because IRL functions are a bit more complex rather than "pass a number - get a string in return".
  • Use the function in a browser through TypeScript.

Preparations

I create two folders be/ and fe/ for the back-end and front-end respectively. The front-end folder contains a Create React app, and the backend - just a regular Go application.

👉 Get the wasm_exec.js file:

$
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./fe/src/
The code is licensed under the MIT license

The file contains some stubs required when running wasm in a browser. Also, as globalThis wasn't defined, I had to modify the file and substitute globalThis with just window.

👉 When using IntelliJ, the project setup must be altered. Go to Preferences -> Go -> Build Tags & Vendoring. Change OS and Arch to js and wasm respectively. If skipped, the editor throws errors when trying to import wasm-specific go modules.

The code

The Go function is defined as follows:

👉 📃  be/lib/main.go
package main
import (
"syscall/js"
"github.com/brianvoe/gofakeit/v6"
"github.com/norunners/vert"
)
type GetItemsRequest struct {
Amount int32
}
type Item struct {
ID int32 `js:"id"`
Title string `js:"title"`
Date string `js:"date"`
}
type GetItemsResponse struct {
Error string `js:"error"`
Items []Item `js:"items"`
}
func convertGetItemsRequest(jsRequest js.Value) *GetItemsRequest {
return &GetItemsRequest{
Amount: int32(jsRequest.Get("amount").Int()),
}
}
func getItems(request *GetItemsRequest) *GetItemsResponse {
response := &GetItemsResponse{}
if request.Amount <= 0 {
response.Error = "request amount should be a positive number"
return response
}
items := make([]Item, 0)
for i := int32(0); i < request.Amount; i++ {
items = append(items, Item{
ID: gofakeit.Int32(),
Title: gofakeit.BeerName(),
Date: gofakeit.Date().Format("2006-01-02T15:04:05Z07:00"),
})
}
response.Items = items
return response
}
func main() {
js.Global().Set("getItems", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return vert.ValueOf(getItems(convertGetItemsRequest(args[0]))).JSValue()
}))
// block forever
select {}
}
The code is licensed under the MIT license

Few notable points here:

  • js.Global() is mapped to globalThis or window after compilation. Set() is used for defining a new property getItems.
  • The main() function must never exit, that's why there is a block in the end.
  • gofakeit package is used to create random data.

It get a bit tricky with Go <-> JS interoperability, where JS objects get converted to Go structures and back.

  • convertGetItemsRequest() converts js.Value to a go-typed structure. The .Get("amount").Int() converts raw value to an integer.
  • JS Date objects cannot be converted, it must be passed through as an ISO string.
  • The vert package is used to convert go structures to JS value. Simple returning of a go structure will cause an error. Structure tags are used to map values to object properties accordingly.

The next thing is a wrapper around wasm on the TypeScript side:

👉 📃  fe/src/wasm.ts
import "./wasm_exec";
let go: Go | null = null; // "let go" ^_^
export type GetItemsRequest = {
amount: number;
};
export type Item = {
id: string;
title: string;
date: string;
}
export type GetItemsResponse = {
error: string;
items: Item[];
}
declare global {
interface Window {
getItems: (request: GetItemsRequest) => GetItemsResponse;
}
}
type Methods = {
getItems: (request: GetItemsRequest) => GetItemsResponse;
};
export const getWASM = async (): Promise<Methods> => {
if (!go) {
go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject);
go.run(result.instance);
}
return {
getItems: window.getItems,
};
};
The code is licensed under the MIT license

There is only one important thing: go.run() should not be await-ed. Remember that block on the Go's side? As long as the runtime is running, the go function getItems() is available. When the go's main() function stops, the JavaScript counterpart returns an error.

Here is just a piece of UI to demonstrate the output of the function:

👉 📃  fe/src/App.tsx
import React, {useState} from 'react';
import logo from './logo.svg';
import './App.css';
import {getWASM, Item} from "./wasm";
function App() {
const [items, setItems] = useState<Item[]>([]);
const onRunClick = async () => {
const wasmMethods = await getWASM();
const result = wasmMethods.getItems({
amount: 10,
});
console.log(result);
setItems(result.items);
};
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<button onClick={onRunClick}>Get items!</button>
<div>
{
items.map(item => {
return (<div key={item.id}>{item.title} delivered on {item.date}</div>);
})
}
</div>
</header>
</div>
);
}
export default App;
The code is licensed under the MIT license

The result

Let's build the file and move the output to the fe/ folder.

$
cd ./be
GOOS=js GOARCH=wasm go build -o build/main.wasm ./lib/main.go
mv ./build/main.wasm ../fe/public/
The code is licensed under the MIT license

As soon as the application is reloaded, the list of generated items is seen after clicking the button. Note that all the activity happens exclusively in the browser.

A pinch of salt!

Here comes the sad part, a spoon of pitch in a barrel of honey, so to say. Actually, it's rather a deal-breaker.

The thing is, the output file generated with go build is pretty hefty. A bare-bone runtime weights something shy of 3mb, and when stuffed with the logic there is literary no limit. When making a PoC for my work project, the size ended up being 35mb! By moving the code to a standalone service and importing only that, I could more or less shrink it down to probably... 10mb? But that's still too much of an improvement.

This is perfectly fine if the wasm runtime is executed on the server side within NodeJS, but for in-browser usage this is certainly a no-go.

I could also make use of alternative compilers, such as TinyGo, but then I would have had to cover the library with unit tests from the TypeScript side, as the code actually stops being reliable: who knows if it even works after being compiled with a different, especially reduced compiler?

Anyway. The technology is amazing and promising, so I'll keep an eye on it.

Useful articles

The complete code is, as usual, here. Enjoy.


Avatar

Sergei Gannochenko

Business-oriented fullstack engineer, in ❤️ with Tech.
Golang, React, TypeScript, Docker, AWS, Jamstack.
15+ years in dev.