Embed SvelteKit into a Go Binary

  • Sascha Aeppli

Go has this fantastic feature to embed parts of the filesystem directly into the binary. This example uses SvelteKit as an admin backend for a Go API.

TL&DR

You can't wait to get started? I got you:
https://github.com/munxar/goapi

What do we build?

Have you seen CLI apps or services that give you a way to access them with a GUI? Some very famous examples would be the vue cli. However, vue CLI is not written in go (obviously) but you get the idea.
We'll build a go binary that is a simple API with an admin GUI (aka backend) built with SvelteKit.

Build the Go API

Let's start by building a simple API in Go. I assume you have some recent version of Go installed on your machine.

go version
go version go1.18.3 darwin/arm64

Let's create a directory for the code

mkdir goapi
cd goapi

and initialize it as a go module.

go mod init goapi

Now create a main.go file

touch main.go

and add a simple HTTP server.

// main.go
package main

import (
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("content-type", "application/json")
        w.Write([]byte(`{ "version": "1.0" }`))
    })
    log.Fatal(http.ListenAndServe(":3001", mux))
}

Now run the go program

go run .

and check the API response from a second terminal session.

curl http://localhost:3001
{ "version": "1.0" }

Now stop the program by hitting ctrl-c in your terminal where the go API is running.

Add the Admin GUI

To be honest, this "admin" GUI doesn't do much. To keep things simple we just add two routes and fetch our pseudo payload from the Go API.
For that, you'll need a recent node and npm version installed.

node -v
v16.15.1
npm -v
8.11.0

Create the SvelteKit Project

Now create a new SvelteKit project inside the existing goapi folder.

npm create svelte frontend

Choose:

  • Skeleton project
  • Yes, using TypeScript syntax (optional)
  • Add ESLint for code linting? Yes
  • Add Prettier for code formatting? Yes
  • Add Playwright for browser testing? No

Then move into the frontend directory and add adapter-static as a dev dependency.

cd frontend
npm i -D @sveltejs/adapter-static

Configure SvelteKit

To enable the adapter and prepare for prerendering here is the config I'm using.

// frontend/svelte.config.js
import adapter from '@sveltejs/adapter-static';
import preprocess from 'svelte-preprocess';

/** @type {import('@sveltejs/kit').Config} */
const config = {
    // Consult https://github.com/sveltejs/svelte-preprocess
    // for more information about preprocessors
    preprocess: preprocess(),

    kit: {
        paths: {
            base: '/admin'
        },
        adapter: adapter(),
        prerender: {
            default: true
        }
    }
};

export default config;

A few notes here: We use adapter-static because we want to prerender every page of our admin backend. This reduces execution time in our clients because they usually only execute the dynamic parts of the app. Additionally, the app will feel snappier. The kit.prerender.default: true is needed to give us the described behaviour. The kit.paths.base: '/admin' is needed to tell SvelteKit where our app will be served from, in our case, it is the route /admin. If you prefer to use the root of your service, this setting is not needed, but instead, the API must be served from e.g. /api. Choose as you like, I'll go with the first approach.

Add Routes to SvelteKit

As mentioned before I keep it very simple and add just two routes / and /about. We need a navbar on every page so let's add a __layout.svelte too.

touch src/routes/about.svelte src/routes/__layout.svelte
<!-- src/routes/__layout.svelte -->
<script lang="ts">
    import { base } from '$app/paths';
</script>

<nav>
    <a href="{base}/">Home</a>
    <a href="{base}/about">About</a>
</nav>
<slot />
<!-- src/routes/about.svelte -->
<h1>About</h1>
// src/app.d.ts
/// <reference types="@sveltejs/kit" />

// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
    // interface Locals {}
    // interface Platform {}
    // interface Session {}
    // interface Stuff {}
}

interface ImportMetaEnv {
    VITE_API_URL: string;
}

note: this is needed for TypeScript to find the definition for import.meta.env.VITE_API_URL.

<!-- src/routes/index.svelte -->
<script lang="ts">
    // generate a path-dependent if we have VITE_API_URL defined (dev mode) or nor
    const apiUrl = (path: string) => `${import.meta.env.VITE_API_URL || ''}${path}`;

    // fetch the version from the API
    const getVersion = async () => {
        const url = apiUrl('/');
        const res = await fetch(url);
        if (!res.ok) {
            throw `Error while fetching data from ${url} (${res.status} ${res.statusText}).`;
        }
        const { version } = await res.json();
        return version;
    };
</script>

<h1>Home</h1>
{#await getVersion()}
    loading...
{:then version}
    Version from Server: {version}
{:catch err}
    {err}
{/await}

There are a few things you may have noticed. In the layout file I use "{base}/" and "{base}/about" for my anchors. This will take the path we configured in the svelte.config.js. Without this, the routes would be without the prefix /admin and won't work as you'd expect.
Besides that the adapter-static needs to know the correct existing routes at build time, or it will fail to generate the files like index.html and about.html in this case.

In index.svelte is some switch to distinguish between dev mode or prod mode where the SvelteKit code is embedded and served from the same host. In a real project, this would be hidden somewhere in a service layer accessing your API's data. For simplicity, it's written out directly in index.svelte.

To make this work I add the environment variable directly into my package.json like this:

{
    ...
    "scripts": {
        "dev": "VITE_API_URL=http://localhost:3001 vite dev",
        ...
    },
    ...
}

Now if we start our API with go run . inside the project's root folder and the frontend inside the frontend folder with npm run dev the result will be a disappointing CORS error in the frontend. It makes sense because the go API runs on a different port that the frontend which is technically a different host.
Let's fix this.

// main.go
package main

import (
    "flag"
    "fmt"
    "goapi/frontend"
    "log"
    "net/http"
)

func cors(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("Access-Control-Allow-Origin", "*")
        handler.ServeHTTP(w, r)
    })
}

func main() {
    devMode := false
    flag.BoolVar(&devMode, "dev", devMode, "enable dev mode")
    flag.Parse()

    mux := http.NewServeMux()

    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"version": "1.0.0"}`))
    })

    mux.Handle("/admin/", frontend.SvelteKitHandler("/admin"))

    var handler http.Handler = mux

    if devMode {
        handler = cors(handler)
        fmt.Println("server running in dev mode")
    }

    log.Fatal(http.ListenAndServe(":3001", handler))
}

The cors function is a simple middleware that takes an http.Handler and returns a http.Handler. Because I only need this feature while developing I add a command line flag -dev to enable it.
Now stop the go API with ctrl-c and rerun it with go run . -dev. You should see server running in dev mode in the terminal.
Refresh the front end and if everything went well, you'll see Version from Server: 1.0.0 in your browser.

Embedding SvelteKit

Right now we only have an API and a Frontend running separately. Let's fix this.
First, create a file frontend/embed.go and add the following content.

package frontend

import (
    "embed"
    "io/fs"
    "log"
    "net/http"
    "strings"
)

//go:generate npm i
//go:generate npm run build
//go:embed all:build
var files embed.FS

func SvelteKitHandler(path string) http.Handler {    
    fsys, err := fs.Sub(files, "build")
    if err != nil {
        log.Fatal(err)
    }
    filesystem := http.FS(fsys)

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        path := strings.TrimPrefix(r.URL.Path, path)
        // try if file exists at path, if not append .html (SvelteKit adapter-static specific)
        _, err := filesystem.Open(path)
        if errors.Is(err, os.ErrNotExist) {
            path = fmt.Sprintf("%s.html", path)
        }
        r.URL.Path = path
        http.FileServer(filesystem).ServeHTTP(w, r)
    })
}

The important part here is the var files embed.FS and the comment above //go:embed all:build. This tells Go to take everything inside the build/ folder and put it into the files variable. The SvelteKitHandler just helps to serve those files. It uses the standard http.FileServer but add one thing mentioned here. If a route like /about is processed, it first tries to find the file in the filesystem. This works for everything like assets but will fail for all the prerendered .html files. So in this example, the handler tries to find about as a file, fails and then adds a .html and passes this to the http.FileServer and that can resolve the about.html in our case. With this logic in place, we can hard reload the browser on every route and the prerendered SvelteKit app should work as expected.

Build the Binary

You may have spotted the two commend in the embed.go file. //go:generate npm i and //go:generate npm run build. This is just a shortcut I found convenient because I don't have to cd into the frontend dir and execute them manually. So now in the root of the example project, I can run the following commands to first generate the frontend and then build the Go API that embeds it.

go generate ./...
go build

Now run your binary and check out http://localhost:3001/admin

./goapi

Isn't that super cool? You can now deploy or pass this single binary around and even cross-compile it for different systems or architectures.

Conclusion

Go makes it very simple to embed any kind of assets from your filesystem with embed.FS. If combined with a webserver and some frontend app, you'll end up with a very powerful tool that gives you the ability to add complex UIs to a web service or CLI. I used SvelteKit in this example, but you can easily use React, Angular, Vue or whatever you prefer, even combining multiple apps at once. The sky is the limit.

So far: have fun, take care and build great stuff!


Tell us what you think