DEV Community

Cover image for Serving Single-Page Application in a single binary file with Go
Arya Prakasa
Arya Prakasa

Posted on • Updated on

Serving Single-Page Application in a single binary file with Go

Although there are many alternatives to deploy a single-page application (SPA), you might find a situation where you need to deploy it in an isolated environment or just portability concern.

So in this article, we'll be using SvelteKit to generate SPA (alternatively, you can also use any popular front-end framework) and embed it with Go into a single binary file.

📦 The final repository is hosted at Github.

Table Of Contents

Video Version

Initialize SvelteKit

Please be advised that this is not a SvelteKit crash course. So we'll only add some basic functionality such as routing and fetching JSON data.

Create a new folder for the project by running the command at the terminal:

mkdir go-embed-spa && cd go-embed-spa
Enter fullscreen mode Exit fullscreen mode

To scaffold SvelteKit inside the root of the project directory within the frontend folder, run the command below:

npm init svelte@next frontend
Enter fullscreen mode Exit fullscreen mode

For the sake of simplicity, use the skeleton project template without Typescript, ESLint and Prettier.

✔ Which Svelte app template? › Skeleton project
✔ Use TypeScript? … No
✔ Add ESLint for code linting? … No
✔ Add Prettier for code formatting? … No
Enter fullscreen mode Exit fullscreen mode

The initial project structure:

go-embed-spa/
└── frontend/         # generated through `npm init svelte@next frontend`
Enter fullscreen mode Exit fullscreen mode

Configure Adapter Static

SvelteKit has many adapters for building the application. But in this case, we'll use the static adapter. So at the package.json file between "devDependencies", replace the adapter from adapter-auto to adapter-static.

- "@sveltejs/adapter-auto": "next", // delete this line
+ "@sveltejs/adapter-static": "next", // add this line
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/package.json

Open svelte.config.js file and replace the adapter-auto with adapter-static as well and set the fallback with index.html

import adapter from "@sveltejs/adapter-static";

/** @type {import('@sveltejs/kit').Config} */
const config = {
  kit: {
    adapter: adapter({
      fallback: "index.html", // for a pure single-page application mode
    }),
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/svelte.config.js

Install all the dependencies by running:

cd frontend && npm install
Enter fullscreen mode Exit fullscreen mode

Or, can also use the --prefix keyword, if don't want to navigate to the frontend folder.

npm install --prefix ./frontend
Enter fullscreen mode Exit fullscreen mode

Add Page Routes

At the frontend/src/routes directory add a new file called about.svelte with a content below:

<script>
  const status = fetch("/hello.json")
    .then((r) => r.json())
    .catch((err) => {
      throw new Error(err);
    });
</script>

<svelte:head>
  <title>About</title>
</svelte:head>

<h1>About</h1>

<p>
  <strong>server respond</strong>:
  {#await status}
    loading
  {:then data}
    {data.message}
  {:catch err}
    failed to load data
  {/await}
</p>

<p>This is about page</p>

<style>
  h1 {
    color: green;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/src/routes/about.svelte

You'll notice there is a script to fetch JSON data. No worry, we'll create the handler in Go later.

At the index.svelte file, add <svelte:head> with a title tag. So the application will have a title in the <head> tag. Add a style for heading one with a color of blueviolet.

<svelte:head>
  <title>Homepage</title>
</svelte:head>

<h1>Welcome to SvelteKit</h1>
<p>
  Visit <a href="https://kit.svelte.dev" rel="external">kit.svelte.dev</a> to
  read the documentation
</p>

<style>
  h1 {
    color: blueviolet;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/src/routes/about.svelte

Add a CSS file at go-embed-spa/frontend/src/global.css

body {
  background-color: aliceblue;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

Next, add __layout.svelte. Import the global.css and add a navigation.

<script>
  import "../global.css";
</script>

<nav>
  <a href=".">Home</a>
  <a href="/about">About</a>
  <a href="/404">404</a>
</nav>

<slot />
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/src/routes/__layout.svelte

The last one will be adding the __error.svelte page with the content below:

<script context="module">
  export function load({ status, error }) {
    return {
      props: { status, error },
    };
  }
</script>

<script>
  export let status, error;
</script>

<svelte:head>
  <title>{status}</title>
</svelte:head>

<h1>{status}</h1>

<p>{error.message}</p>

<style>
  h1 {
    color: crimson;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/src/routes/__error.svelte

Build The Static Files

To generate the static files, run this command below:

npm run build
# or
npm run build --prefix ./frontend
# if not inside the frontend directory
Enter fullscreen mode Exit fullscreen mode

The run build command has generated static files inside the build directory, which we'll embed and serve with Go later.

go-embed-spa/
└── frontend/
    ├── .svelte-kit/
    ├── build/          # generated from the build command
    ├── node_modules/
    ├── src/
    └── static/
Enter fullscreen mode Exit fullscreen mode

Initialize Go

To initialize the Go module, run the command on the terminal:

go mod init github.com/${YOUR_USERNAME}/go-embed-spa
Enter fullscreen mode Exit fullscreen mode

Replace ${YOUR_USERNAME} with your Github username. This command will generate a new file called go.mod.

Embed The Static Files

Create a new file called frontend.go inside the frontend folder.

package frontend

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

// Embed the build directory from the frontend.
//go:embed build/*
//go:embed build/_app/immutable/pages/*
//go:embed build/_app/immutable/assets/pages/*
var BuildFs embed.FS

// Get the subtree of the embedded files with `build` directory as a root.
func BuildHTTPFS() http.FileSystem {
    build, err := fs.Sub(BuildFs, "build")
    if err != nil {
        log.Fatal(err)
    }
    return http.FS(build)
}
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/frontend.go

A quick notes about the Go embed directive:

  1. We can't use the //go:embed directive if the pattern started with ../.
  2. But luckily we can export the embed variable. That's the reason why the variable and function name started with capital letter.
  3. The //go:embed directive with directory pattern will include all files and sub-directories recursively, except for the files names beginning with a . or _ . So we'll need to explicitly use the * sign to include them.

The io/fs library with a method of Sub will get the subtree of the embedded files. So we can use the build directory as a root.

Serve with Go HTTP Library

Create a new file called main.go at /go-embed-spa/cmd/http/main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/aprakasa/go-embed-spa/frontend"
)

func main() {
    http.HandleFunc("/hello.json", handleHello)
    http.HandleFunc("/", handleSPA)
    log.Println("the server is listening to port 5050")
    log.Fatal(http.ListenAndServe(":5050", nil))
}

func handleHello(w http.ResponseWriter, r *http.Request) {
    res, err := json.Marshal(map[string]string{
        "message": "hello from the server",
    })
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(res)
}

func handleSPA(w http.ResponseWriter, r *http.Request) {
    http.FileServer(frontend.BuildHTTPFS()).ServeHTTP(w, r)
}
Enter fullscreen mode Exit fullscreen mode
  1. The handleHello is a handler function to serve the JSON at the /hello.json route.
  2. The handleSPA is a handler function to serve the embedded static files.

At this point, the code is enough to embed and serve the build directory from the binary. You can try it by running:

go build ./cmd/http
Enter fullscreen mode Exit fullscreen mode

Try to delete the build/ directory to verify if it is embedded. And then run the binary.

./http
# or
./http.exe
# for windows user
Enter fullscreen mode Exit fullscreen mode

Open the browser and navigate to http://localhost:5050.

Handling The Not-Found Route

Unfortunately, if we direct access to the non-root path, the server will send a 404 error not found. The logic for handling the routes is from the client-side, which is the basic behavior for the single-page application.

We can solve the not-found routes by comparing the requested URL path and the files inside the embedded build directory. So if there are no matching files based on the requested URL path, the server-side will send the index.html file.

The final Go code to handle the not-found route:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "path/filepath"

    "github.com/aprakasa/go-embed-spa/frontend"
)

func main() {
    http.HandleFunc("/hello.json", handleHello)
    http.HandleFunc("/", handleSPA)
    log.Println("the server is listening to port 5050")
    log.Fatal(http.ListenAndServe(":5050", nil))
}

func handleHello(w http.ResponseWriter, r *http.Request) {
    res, err := json.Marshal(map[string]string{
        "message": "hello from the server",
    })
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(res)
}

func handleSPA(w http.ResponseWriter, r *http.Request) {
    buildPath := "build"
    f, err := frontend.BuildFs.Open(filepath.Join(buildPath, r.URL.Path))
    if os.IsNotExist(err) {
        index, err := frontend.BuildFs.ReadFile(filepath.Join(buildPath, "index.html"))
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        w.WriteHeader(http.StatusAccepted)
        w.Write(index)
        return
    } else if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer f.Close()
    http.FileServer(frontend.BuildHTTPFS()).ServeHTTP(w, r)
}
Enter fullscreen mode Exit fullscreen mode

Now we can rebuild the static files and the binary by running:

npm run build --prefix ./frontend && go build ./cmd/http
Enter fullscreen mode Exit fullscreen mode

Run the application from the binary with:

./http
Enter fullscreen mode Exit fullscreen mode

Try to direct access to https://localhost:5050/about or the unknown router in the browser. If everything setup correctly, the the not-found will be handled by the client-side.

Serve with Echo Framework

Add a new file under go-embed-spa/cmd/echo/main.go

package main

import (
    "log"
    "net/http"

    "github.com/aprakasa/go-embed-spa/frontend"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    app := echo.New()
    app.GET("/hello.json", handleHello)
    app.Use(middleware.StaticWithConfig(middleware.StaticConfig{
        Filesystem: frontend.BuildHTTPFS(),
        HTML5:      true,
    }))
    log.Fatal(app.Start(":5050"))
}

func handleHello(c echo.Context) error {
    return c.JSON(http.StatusOK, echo.Map{
        "message": "hello from the echo server",
    })
}
Enter fullscreen mode Exit fullscreen mode

Handling the not-found route and creating API end-point is more simpler with Echo Framework. Just need to set true for HTML5 from the static config middleware.

Serve with Fiber Framework

Add a new file under go-embed-spa/cmd/fiber/main.go

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/aprakasa/go-embed-spa/frontend"
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/filesystem"
)

func main() {
    app := fiber.New()
    app.Get("/hello.json", handleHello)
    app.Use("/", filesystem.New(filesystem.Config{
        Root:         frontend.BuildHTTPFS(),
        NotFoundFile: "index.html",
    }))
    log.Fatal(app.Listen(fmt.Sprintf(":%s", os.Getenv("APP_PORT"))))
}

func handleHello(c *fiber.Ctx) error {
    return c.JSON(fiber.Map{"message": "hello from the fiber server"})
}
Enter fullscreen mode Exit fullscreen mode

Fiber Framework also give a seamless integration for handling the not-found route by just configuring the NotFoundFile with index.html from their filesystem middleware.

Containerize with Docker

To streamline the deploy process, add a Dockerfile inside the project directory with the instructions below:

# syntax=docker/dockerfile:1.2

# Stage 1: Build the static files
FROM node:16.15.0-alpine3.15 as frontend-builder
WORKDIR /builder
COPY /frontend/package.json /frontend/package-lock.json ./
RUN npm ci
COPY /frontend .
RUN npm run build

# Stage 2: Build the binary
FROM golang:1.18.3-alpine3.15 as binary-builder
ARG APP_NAME=http
RUN apk update && apk upgrade && \
  apk --update add git
WORKDIR /builder
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend-builder /builder/build ./frontend/build/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
  -ldflags='-w -s -extldflags "-static"' -a \
  -o engine cmd/${APP_NAME}/main.go

# Stage 3: Run the binary
FROM gcr.io/distroless/static
ENV APP_PORT=5050
WORKDIR /app
COPY --from=binary-builder --chown=nonroot:nonroot /builder/engine .
EXPOSE $APP_PORT
ENTRYPOINT ["./engine"]
Enter fullscreen mode Exit fullscreen mode

Since we have three entry points, we can use ARG instruction to set a custom directory. Let's say APP_NAME with http as the default value.

We can also set the app port to be more customizable with a variable of APP_PORT and set the default value with 5050.

We'll need to update all the hardcoded ports from our Go entry points.

// go-embed-spa/cmd/http/main.go
log.Printf("the server is listening to port %s", os.Getenv("APP_PORT"))
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", os.Getenv("APP_PORT")), nil))

// go-embed-spa/cmd/echo/main.go
log.Fatal(app.Start(fmt.Sprintf(":%s", os.Getenv("APP_PORT"))))

// go-embed-spa/cmd/fiber/main.go
log.Fatal(app.Listen(fmt.Sprintf(":%s", os.Getenv("APP_PORT"))))
Enter fullscreen mode Exit fullscreen mode

Don't forget to import the os library.

Add Makefile

Add a new file called Makefile to shortend the build process.

FRONTEND_DIR=frontend
BUILD_DIR=build
APP_NAME=http
APP_PORT=5050

clean:
    cd $(FRONTEND_DIR); \
    if [ -d $(BUILD_DIR) ] ; then rm -rf $(BUILD_DIR) ; fi

static: clean
    cd $(FRONTEND_DIR); \
    npm install; \
    npm run build

build: clean
    DOCKER_BUILDKIT=1 docker build -t spa:$(APP_NAME) --build-arg APP_NAME=$(APP_NAME) .

run:
    docker run -dp $(APP_PORT):$(APP_PORT) --name spa-$(APP_NAME) -e APP_PORT=$(APP_PORT) --restart unless-stopped spa:$(APP_NAME)

.PHONY: clean static
Enter fullscreen mode Exit fullscreen mode

Now we can build the docker image with:

make build # for net/http libary
make build APP_NAME=echo # for echo libary
make build APP_NAME=fiber # for fiber libary
Enter fullscreen mode Exit fullscreen mode

To run the image we can run the command with:

make run # for net/http libary
make run APP_NAME=echo APP_PORT=5051 # for echo libary
make run APP_NAME=fiber APP_PORT=5052 # for fiber libary
Enter fullscreen mode Exit fullscreen mode

As a result, we have a tiny little single-page application combined with the HTTP server.

Docker images

Discussion (8)

Collapse
mrwormhole profile image
Talha Altınel

amazing post!

Collapse
besworks profile image
Besworks

I just started learning Go recently and found this post to contain a lot of useful information. I really feel like I could build a proper standalone web app after reading it. Thanks especially for the mentions of Echo and Fibre! Coming from Node as my primary backend it's nice to see such a familiar interface.

Collapse
aryaprakasa profile image
Arya Prakasa Author

Thanks, glad you find it useful!
In some circumstances, the standalone app is suitable for some cases, but not always.
That's the main point of frameworks, to make our lives easier.

Collapse
abiiranathan profile image
Dr. Abiira Nathan

Nice article over here. Do you have an idea on to make go://embed include lazy loaded sveltekit endpoints that start with . e.g _id.indexeadfg.js?

If my SPA has hundreds of these, I cant include them one by one.

Collapse
aryaprakasa profile image
Arya Prakasa Author

Hi thanks,

You can do something like these:

//go:embed build/*
//go:embed build/*/*/*/*
//go:embed build/*/*/*/*/*
Enter fullscreen mode Exit fullscreen mode

Or explicitly embed specific file type:

//go:embed build/*.js
//go:embed build/*/*/*/*.js
//go:embed build/*/*/*/*/*.js
Enter fullscreen mode Exit fullscreen mode
Collapse
abiiranathan profile image
Dr. Abiira Nathan

That was actually helpful.

Collapse
citizen428 profile image
Michael Kohl

Great post, well done and thanks for writing this!

Offtopic:

mkdir go-embed-spa && cd go-embed-spa

This happens so frequently, I wrote myself a shell function called mcd for this purpose:

Bash/Zsh:

function mcd() {
  mkdir -p "$1" && cd "$1";
}
Enter fullscreen mode Exit fullscreen mode

Fish:

function mcd --description 'create a directory and cd into it' --argument dir
  mkdir -p $dir && cd $dir
end
Enter fullscreen mode Exit fullscreen mode

Powershell:

function New-Directory {
  param(
    [Parameter(Mandatory = $true)]
    $directory
  )

  New-Item -ItemType "directory" -Path $directory && Set-Location $directory
}
Set-Alias -Name mcd -Value New-Directory
Enter fullscreen mode Exit fullscreen mode

It seems like a small thing but I get very annoyed nowadays when I work on a machine that doesn't have this defined 😂

Collapse
aryaprakasa profile image
Arya Prakasa Author

Thanks, nice little snippets