How we integrated a React App into a GO CLI.

How we Integrated a React App into a GO CLI

Embedding a React app into a GO CLI is a complex process that took our team close to three to four weeks to solve. Here is how our team solved it.

Sebastain Florek
Sebastain Florek

Embedding a React app into a GO CLI is an uncommon product decision, so before diving into the technicalities it’s worth calling out why our team made this decision.

If you are unfamiliar with Plural, it is an open-source DevOps platform that simplifies deploying open-source software on Kubernetes. Our platform is available via a cloud shell experience or through downloading our CLI experience.

While we recommend using our cloud shell experience since it comes with all the tools and dependencies needed to run Plural, users still prefer to use their own CLI for an extra added layer of security.

Previously, deploying Plural via our CLI or cloud shell was a suboptimal experience for engineers. It required a ton of re-typing, and users could not go back and update values. As a result, around 30 - 35% of users were receiving error messages during onboarding.

We knew that we needed to improve the onboarding process to increase the user success rate. At the start of the year, we completely redesigned our cloud-shell experience with the hopes of reducing error rates. A big part of this redesign was implementing an install wizard in our cloud shell. That alone led to a drop in users hitting errors in our cloud shell experience and we’re now consistently around a 90% success rate for onboarding users.

However, users still faced similar issues with our CLI deployments, the most secure and robust way to use Plural. Making this experience as seamless as good as possible was a high priority for our team. Earlier in the year, we saw a drop in user error rates when we implemented an install wizard in our cloud shell. To stay consistent in our product experience and to reduce error rates with users onboarding through our CLI experience, we implemented the install wizard in our CLI.

The only problem was embedding a React app into a GO CLI is a complex process that took our team close to three to four weeks to solve. Here is how our team solved it.

Identifying a Framework for Embedding Our UI

Before identifying a framework, I had a few requirements in mind that our solution had to contain.

  • The framework had to be available within Go so I could reuse our current code base which is written in Go. The main reason for this was so I could call some GO functions directly in the front end to avoid rewriting all this logic.
  • I needed to use React so I could reuse our design system for creating this UI in the CLI.

Early on in my research, I came across Electron and Tauri.

I had previous experience working with Electron and it is a popular solution for embedding Chromium and Node.js to create desktop applications. However, there are some major downsides to it such as it embeds the whole chromium inside making our binary file way too big which is what we were looking to avoid.

Tauri’s framework was interesting and has a ton of potential. While it’s similar to the one that we ended up choosing, it was written in Rust and didn’t allow me to reuse our current code base.

Ultimately I landed on using the Wails framework and it has been a flexible solution for our team so far. Wails is a project that enables you to write desktop apps using Go and web technologies. A big reason for choosing Wails was that it automatically makes Go methods available to JavaScript, allowing us to call them by name from our front end.

The Components that make up a Wails App. Image courtesy of Wails Docs.

Once I selected our framework, it was time to implement it. But, this did come with a few unexpected challenges that I had to quickly overcome.

Challenge 1: Re-Configuring our GitHub Deployment Pipeline

Since Plural does not provide the UI for all architectures (we only support a handful of architectures), I had to build a Go build pipeline and rework from scratch our GitHub deployment pipeline that we also use for our CLI.

To achieve this I had three parallel builds at the same time using Windows, Linux, and MacOS host machines thanks to the GoReleaser pro "split" feature.

The first job matrix runs all jobs in parallel at the same time on three separate hosts OS (Windows, Linux, Darwin) and builds the result binaries. It is then picked up by the next step (release) and it takes care of uploading artifacts to our GitHub release and updating our upstream brew artifact. The last part takes care of uploading dockerized images w/ CLI that we can further use (i.e. in the Cloud Shell in the Plural app.)

The below code uses GoReleaser pro split build feature and our conditional tag-based Go build pipeline. Depending on the architecture it will either build a CLI binary with our without embedded.

# Requires a GoReleaser Pro to run
partial:
  by: goos

before:
  hooks:
    - go mod tidy

builds:
  - id: plural-cli
    targets:
      - linux_amd64
      - linux_arm64
      - windows_amd64
      - windows_arm64
      - darwin_amd64
      - darwin_arm64
    env:
      - CGO_ENABLED=1
    ldflags:
      - -s
      - -w
      - -X "github.com/pluralsh/plural/cmd/plural.Version={{.Version}}"
      - -X "github.com/pluralsh/plural/cmd/plural.Commit={{.Commit}}"
      - -X "github.com/pluralsh/plural/cmd/plural.Date={{.Date}}"
      - -X "github.com/pluralsh/plural/pkg/scm.GitlabClientSecret={{.Env.GITLAB_CLIENT_SECRET}}"
    tags:
      - desktop
      - production
      - ui
    binary: plural
    # Do not embed UI into linux/arm64 and windows/arm64 binaries
    overrides:
      - goos: linux
        goarch: arm64
        tags: [linux]
        env:
          - CGO_ENABLED=0
      - goos: windows
        goarch: arm64
        tags: [windows]
        env:
          - CGO_ENABLED=0
Source code https://github.com/pluralsh/plural-cli/blob/cb40526e084f007db3ef83b773654f4043f6243e/.goreleaser.yaml#L4-L46

The above code It is then utilized by the modified GitHub action.

name: CD / CLI
on:
  push:
    tags:
      - 'v*.*.*'

jobs:
  # Build binaries with GoReleaser
  prepare:
    strategy:
      matrix:
        os: [ ubuntu-latest, macos-latest, windows-latest ]
        include:
          - os: ubuntu-latest
            goos: linux
          - os: macos-latest
            goos: darwin
          - os: windows-latest
            goos: windows
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Setup Go
        uses: actions/setup-go@v3
        with:
          go-version: 1.19
          cache: true
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 16.18.1
      - name: Setup SHA variable
        shell: bash
        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
      - name: Setup Cache
        uses: actions/cache@v3.2.3
        with:
          path: dist/${{ matrix.goos }}
          key: ${{ matrix.goos }}-${{ env.sha_short }}
          enableCrossOsArchive: true
      - name: Install Dependencies
        if: matrix.goos == 'linux'
        shell: bash
        run: sudo apt install -y libwebkit2gtk-4.0-dev libgtk-3-dev
      - name: Build web
        shell: bash
        run: make build-web
      - name: GoReleaser (Build)
        uses: goreleaser/goreleaser-action@v4
        with:
          distribution: goreleaser-pro
          version: latest
          args: release --clean --split
        env:
          CGO_LDFLAGS: "${{ matrix.goos == 'darwin' && '-framework UniformTypeIdentifiers' || '' }}"
          GOOS: ${{ matrix.GOOS }}
          GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLAB_CLIENT_SECRET: ${{ secrets.GITLAB_CLIENT_SECRET }}
          HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

  # Release binaries with GoReleaser
  release:
    runs-on: ubuntu-latest
    needs: prepare
    env:
      DOCKER_CLI_EXPERIMENTAL: "enabled"
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v3
        with:
          go-version: 1.19
          cache: true
      - name: Copy Cache From Previous Job
        shell: bash
        run: |
          echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
      - name: Restore Linux Cache
        uses: actions/cache@v3.2.3
        with:
          path: dist/linux
          key: linux-${{ env.sha_short }}
      - name: Restore Darwin Cache
        uses: actions/cache@v3.2.3
        with:
          path: dist/darwin
          key: darwin-${{ env.sha_short }}
      - name: Restore Windows Cache
        uses: actions/cache@v3.2.3
        with:
          path: dist/windows
          key: windows-${{ env.sha_short }}
          enableCrossOsArchive: true
      - name: GoReleaser (Release)
        uses: goreleaser/goreleaser-action@v4
        if: steps.cache.outputs.cache-hit != 'true' # do not run if cache hit
        with:
          distribution: goreleaser-pro
          version: latest
          args: continue --merge
        env:
          GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLAB_CLIENT_SECRET: ${{ secrets.GITLAB_CLIENT_SECRET }}
          HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
Source Code https://github.com/pluralsh/plural-cli/blob/cb40526e084f007db3ef83b773654f4043f6243e/.github/workflows/GOreleaser-cd.yml#L1-L111

Challenge 2: Configuring and Running the UI After typing Plural Install

After a user runs Plural Install in the CLI, the UI opens up to allow the user to select the applications they want to install. By default, Wales recommends building a standard binary that when executed opens the UI. In reality, it’s not destined to work directly with CLIs.

To solve for this I ended up creating a custom Golang pipeline using build tags to include the UI when we only type in Plural Install.

To conditionally embed the ‘install’ command into the CL during compilation we pass `-tags ui` or `-tags generate` to the `go build` command. Once I had this set up I then added this code to embed the code that actually handles the UI conditionally. This code block below simply opens the actual UI windows when you call `plural install`. It uses build constraints (tags) to only include this code when we need it.

//go:build ui || generate

package plural

import (
	"github.com/urfave/cli"

	"github.com/pluralsh/plural/pkg/manifest"
	"github.com/pluralsh/plural/pkg/ui"
	"github.com/pluralsh/plural/pkg/wkspace"
)

func (p *Plural) uiCommands() cli.Command {
	return cli.Command{
		Name:   "install",
		Usage:  "opens installer UI that simplifies application configuration",
		Action: tracked(rooted(p.run), "cli.install"),
	}
}

func (p *Plural) run(c *cli.Context) error {
	_, err := wkspace.Preflight()
	if err != nil {
		return err
	}

	_, err = manifest.FetchProject()
	if err != nil {
		return err
	}

	_, err = manifest.FetchContext()
	if err != nil {
		return err
	}

	p.InitPluralClient()
	return ui.Run(p.Client, c)
}
Source Code https://github.com/pluralsh/plural-cli/blob/cb40526e084f007db3ef83b773654f4043f6243e/cmd/plural/ui.go#L1

I then ensured that the tags mentioned above are not provided during the build process. If they aren't, this code will be embedded so that the build will not fail.

Next, I had to conditionally embed code that runs the UI in order to generate bindings that allow us to call Go methods in the UI to generate bindings that allow calling Go methods in the UI. The below code generates frontend bindings used to only call the backend when a ‘generate’ tag is provided during the build process.

//go:build generate

package ui

import (
	"log"
)

// Used to generate frontend bindings used to call backend.
// Only needed when 'generate' tag is provided during the build.
func init() {
	err := Run(nil, nil)
	if err != nil {
		log.Fatal(err)
	}
}

Challenge 3: Refactoring our GO project

Early on one thing that I overlooked was how much we were going to need to refactor how our team set up our Go project. Basically, we had to extract our application entry point in our main package from the `cmd/plural` directory to the root of the project since Wails currently does not play well with different setups. To do this it required both code refactoring and updating all our build CI/CD pipelines to work smoothly with the new code layout.

Along the way, there ended up being a good amount of smaller problems that we needed to quickly solve such as running our CI/CD. Previously, we simply would use `go build` on any host for cross-building our CLI. On the other hand, Wails required the 'CGO_ENABLED=1' flag when building the application and that forced us to actually run the build for target architecture on the target host OS.

Thankfully, GitHub Actions allow us to do just that and run the build natively on every OS. There are still ways to avoid that but when I looked into them, they were much more complicated than the solution I have decided to go with.

Another problem that I encountered was how to call Go functions from the front-end code base. This problem ended up being easier to solve than I anticipated, and I followed Wails official guidance for calling Go function bindings. To simplify this for us internally I ended up adding some wrapper code on top of what they recommended.

Moving Forward

Overall, I enjoyed working on this complex project. If you are looking to solve a similar problem I advise that you try not to overcomplicate everything.

Unfortunately, for us, our problem was too complex and we could not follow Wails recommendations because it involved being quite familiar with Golang and using Golang build constraints. A majority of use cases will likely not be as complex as ours and you should be able to follow along with their documentation and be good to go.

I hope you can learn a bit from this, and please do not hesitate to reach out if you have any additional questions about how we solved this as an engineering team.

To learn more about how Plural works and how we are helping engineering teams across the world deploy open-source applications in a cloud production environment, reach out to our team for more information.

Ready to effortlessly deploy and operate open-source applications in minutes? Get started with Plural today.

Join us on our Discord channel for questions, discussions, and to meet the rest of the community.

Tutorials

Sebastain Florek

Fullstack Engineer