Golang & WebAssembly - build a Web app with them

Golang & WebAssembly - build a Web app with them

Technology is getting more enjoyable and interesting way day by day. Options are getting more with the day for solving problems. This can be understood in both bad and good. Most of the time there is always a better way of solving a problem.

less talk more action!

This post will try to give brief information about how to develop a simple Web app using Golang & WebAssembly. We will develop an app that will encrypt the URL that we gave and create a new encrypted URL for redirection. For encryption & decryption, we will use the Blowfish Encryption Algorithm (Don't need to know about the details of this algorithm).

Project: ghost-url.netlify.app

Source code: github.com/ParvinEyvazov/ghost-url (don't forget to leave a star⭐)

The project mainly consists of 3 parts.

  • Logic (Go)
  • UI (HTML/CSS and a bit js)
  • WebServer (Go)

1. Project/Folder Setup

├ cmd
│   |-- wasm
│   |   |-- main.go
│   |-- webserver
│   |   |-- main.go
├ core
│   |-- core.go
│   |-- decrypt.go
│   |-- encrypt.go
├ static
│   |-- index.html
│   |-- loadwasm.js
│   |-- main.wasm
│   |-- styles.css
│   |-- wasm_exec.js
├ url
|   |-- url.go
go.mod
go.sum

2. How does it work overall?

We are developing the logic code using Go and compile to the .wasm (WebAssembly) file. Inside our js scripts, we load this .wasm file and run the functions that we wrote. And let's get into the codes.

3.1. Logic (Go)

Our logic codes are in cmd/wasm/main.go file. Here is the codes:

cmd/wasm/main.go

package main

import (
    "log"
    "syscall/js"

    "github.com/ParvinEyvazov/ghost-url/core"
    "github.com/ParvinEyvazov/ghost-url/url"
)

func main() {
    done := make(chan struct{}, 0)

    js.Global().Set("ghostUrl", js.FuncOf(ghostUrl))
    js.Global().Set("recoverUrl", js.FuncOf(recoverUrl))

    <-done
}

func ghostUrl(this js.Value, args []js.Value) interface{} {

    urlText := (args[0]).String()

    isValid := url.Valid(urlText)

    if !isValid {
        return ""
    }

    encryptedText, err := core.Encrypt(urlText)
    if err != nil {
        return ""
    }

    finalURL := url.Create(encryptedText)

    return finalURL
}

func recoverUrl(this js.Value, args []js.Value) interface{} {

    encrptedFullUrlText := (args[0]).String()

    encrptedUrlText := url.Parse(encrptedFullUrlText)

    decryptedURL, err := core.Decrypt(encrptedUrlText)

    if err != nil {
        log.Panic("ERROR: Decryption error", err)
    }

    return decryptedURL
}

Basically, we are creating 2 functions to use in our js.

  • ghostUrl
  • recoverUrl

ghostUrl is encrypting the URL provided by the user and returns the encrypted one. When a user comes with the encrypted URL to the app, recoverUrl is decrypting the URL and returning to js and inside js, the page redirects the user to this URL. This is kind of working like URL shorteners, but not saving them to the Database.

js.Global().Set("ghostUrl", js.FuncOf(ghostUrl))
js.Global().Set("recoverUrl", js.FuncOf(recoverUrl))

These codes are working kind of bridge functionality from Go (compiled .wasm) to the Js file.

These are the Blowfish encryption and decryption codes:

core/core.go

package core

import (
    "crypto/cipher"

    "encoding/base64"

    "golang.org/x/crypto/blowfish"
)

const _KEY string = "suPerHarD!To*FinD"

func encodeBase64(b []byte) string {
    return base64.StdEncoding.EncodeToString(b)
}

func blowfishChecksizeAndPad(value []byte) []byte {
    modulus := len(value) % blowfish.BlockSize
    if modulus != 0 {
        padnglen := blowfish.BlockSize - modulus
        for i := 0; i < padnglen; i++ {
            value = append(value, 0)
        }
    }
    return value
}

func blowfishEncrypt(value, key []byte) ([]byte, error) {
    bcipher, err := blowfish.NewCipher(key)
    if err != nil {
        return nil, err
    }
    returnMe := make([]byte, blowfish.BlockSize+len(value))
    eiv := returnMe[:blowfish.BlockSize]
    ecbc := cipher.NewCBCEncrypter(bcipher, eiv)
    ecbc.CryptBlocks(returnMe[blowfish.BlockSize:], value)
    return returnMe, nil
}

func encryptToByte(value string) ([]byte, error) {
    var returnMe, valueInByteArr, paddedByteArr, keyByteArr []byte
    valueInByteArr = []byte(value)
    keyByteArr = []byte(_KEY)
    paddedByteArr = blowfishChecksizeAndPad(valueInByteArr)
    returnMe, err := blowfishEncrypt(paddedByteArr, keyByteArr)
    if err != nil {
        return nil, err
    }
    return returnMe, nil
}

func decodeBase64(s string) ([]byte, error) {
    data, err := base64.StdEncoding.DecodeString(s)
    if err != nil {
        return nil, err
    }
    return data, nil
}

func blowfishDecrypt(value, key []byte) ([]byte, error) {
    dcipher, err := blowfish.NewCipher(key)
    if err != nil {
        return nil, err
    }
    div := value[:blowfish.BlockSize]
    decrypted := value[blowfish.BlockSize:]
    if len(decrypted)%blowfish.BlockSize != 0 {
        return nil, err
    }
    dcbc := cipher.NewCBCDecrypter(dcipher, div)
    dcbc.CryptBlocks(decrypted, decrypted)
    return decrypted, nil
}

func decryptToByte(value string) ([]byte, error) {
    var returnMe, keyByteArr []byte
    keyByteArr = []byte(_KEY)
    decodeB64, err1 := decodeBase64(value)
    if err1 != nil {
        return nil, err1
    }
    returnMe, err2 := blowfishDecrypt(decodeB64, keyByteArr)
    if err2 != nil {
        return nil, err2
    }
    return returnMe, nil
}

For Decryption (from string to string)

core/decrypt.go

package core

func Decrypt(value string) (string, error) {
    decryptedByteArr, err := decryptToByte(value)
    if decryptedByteArr == nil {
        return "", err
    }
    return string(decryptedByteArr[:]), nil
}

For Encryption (from string to string)

core/encrypt.go

package core

func Decrypt(value string) (string, error) {
    decryptedByteArr, err := decryptToByte(value)
    if decryptedByteArr == nil {
        return "", err
    }
    return string(decryptedByteArr[:]), nil
}

For the URL operations

url/url.go

package url

import (
    "net/url"
    "strings"
)

type Routes map[string]string

var RoutesMap Routes = Routes{
    "encryption": "/",
    "decryption": "?d=",
}

var _SPLITTER string = RoutesMap["decryption"]

// var HOST string = "http://localhost:3000"

var HOST string = "https://ghost-url.netlify.app"

func Valid(text string) (isValid bool) {
    _, err := url.ParseRequestURI(text)

    if err == nil {
        isValid = true
    }

    return
}

func Create(encrypted string) string {

    return HOST + _SPLITTER + encrypted
}

func Parse(text string) (encrypted string) {

    texts := strings.Split(text, _SPLITTER)

    encrypted = texts[1]

    return
}

3.2. UI (HTML/CSS and a bit js)

For UI we will surely use the building blocks of Web application which is HTML and CSS. For communicating between WebAssembly and UI we will use a bit of JavaScript. Let's start with the HTML file. static/index.html is the file starting point.

static/index.html

<html>
  <head>
    <link rel="stylesheet" href="styles.css" />

    <meta charset="utf-8" />
    <script src="wasm_exec.js"></script>
    <script src="loadWasm.js"></script>
  </head>

  <body>
    <div class="name-container">
      <h1>Ghost Url 👻</h1>
    </div>

    <div class="container">
      <div class="input-container">
        <div class="error-message-container">
          <h1 id="error-message"></h1>
        </div>

        <div class="webflow-style-input">
          <input
            type="text"
            id="url"
            name="url"
            placeholder="type url here..."
          />
        </div>

        <div class="button-container">
          <a class="animated-button" id="create-button">
            <span></span>
            <span></span>
            <span></span>
            <span></span>
            👻 ghost
          </a>
        </div>

        <div
          class="text-container webflow-style-input copy-text"
          style="display: none"
        >
          <span id="short-url"
            ></span
          >
          <a class="animated-button copy-button">
            <span></span>
            <span></span>
            <span></span>
            <span></span>
            copy
          </a>
        </div>
      </div>
    </div>

    <script>
      (async () => {
        await init();

        const params = new Proxy(new URLSearchParams(window.location.search), {
          get: (searchParams, prop) => searchParams.get(prop),
        });

        if (params.d != undefined && params.d != "") {
          let direct_url = recoverUrl(window.location.href);

          if (direct_url != undefined && direct_url != "") {
            window.location.replace(direct_url);
          }
        }
      })().catch((e) => {
        console.log(e);
      });

      var url = document.querySelector("#url");
      var short_url = document.querySelector("#short-url");
      var text_container = document.querySelector(".copy-text");
      var error_message = document.querySelector("#error-message");

      if (short_url.innerHTML == "" || short_url.innerHTML == undefined) {
        text_container.style.display = "none";
      }

      document
        .getElementById("create-button")
        .addEventListener("click", async function () {
          let a = ghostUrl(url.value);

          if (a != "" && a != undefined) {
            cleanError();
            text_container.style.display = "inline-block";
            short_url.innerHTML = a;
          } else {
            showError("Please enter a valid url");
          }
        });

      text_container.addEventListener("click", () => {
        copyMessage(short_url.innerHTML);
      });

      function showError(message) {
        error_message.innerHTML = "Please enter a valid url";
      }

      function cleanError() {
        error_message.innerHTML = "";
      }

      // copy to clipboard
      function copyMessage(string) {
        const selBox = document.createElement("textarea");
        selBox.style.position = "fixed";
        selBox.style.left = "0";
        selBox.style.top = "0";
        selBox.style.opacity = "0";
        selBox.value = string;
        document.body.appendChild(selBox);
        selBox.focus();
        selBox.select();
        document.execCommand("copy");
        document.body.removeChild(selBox);
      }
    </script>
  </body>
</html>

Loading .wasm file for using:

static/loadwasm.js

async function init() {
  const go = new Go();
  const result = await WebAssembly.instantiateStreaming(
    fetch("main.wasm"),
    go.importObject
  );
  go.run(result.instance);
}

Style:

static/style.css

Would be better if you copy it from here (because of the number of lines): static/style.css

And a JavaScript file provided by Go to load your .wasm file into a Web page. It is a dynamic file, and because of that would be awesome if you always install the latest version of it from Go itself. You can also copy it from the URL provided below.

static/wasm_exec.js

There are 2 ways.

  • Copy from Go source (run this in project folder terminal):
    cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./static/
    
    OR
  • Create a static/wasm_exec.js file and copy from here

3.3. WebServer (Go)

It will be a basic HTTP file server written in Go. Here, we will basically serve our ./static file. Here is the code:

cmd/webserver/main.go

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/ParvinEyvazov/ghost-url/url"
)

type IServer interface {
    start()
}

type Server struct {
}

func main() {

    server := NewServer()

    server.start()
}

func (*Server) start() {
    fmt.Println()
    http.Handle(url.RoutesMap["encryption"], EncryptionHandler())

    log.Println("Listening on http://localhost:3000")
    err := http.ListenAndServe("localhost:3000", nil)
    if err != nil {
        log.Fatal(err)
    }
}

func EncryptionHandler() http.Handler {

    return http.FileServer(http.Dir("./static"))
}

func NewServer() IServer {
    return &Server{}
}

4. Run the project

We finally completed the code part and let's run our project:

  • Create .mod file
go mod init <project name>
go mod tidy

Don't forget to update names of the packages in the imports sections of cmd\wasm\main.go and cmd\webserver\main.go.

For my scenario

go mod init github.com/ParvinEyvazov/ghost-url
go mod tidy
  • Build the .wasm file (will compile it into ./static file)
GOOS=js GOARCH=wasm go build -o static/main.wasm cmd/wasm/main.go
  • Run the project
go run ./cmd/webserver/main.go
  • Open in browser

http://localhost:3000

If any error occurs on running or building steps, please take a look at the source code here: github.com/ParvinEyvazov/ghost-url

My local Golang version is 1.18.1

That is all you need to run Go on Web UI using WebAssembly.

I hope this blog will be helpful to you. Thanks for reading and have a wonderful day :)