Serving Single-Page Application in a single binary file with Go
Tags: svelte, go, docker
Published: 2022-06-19 01:01:08

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](https://kit.svelte.dev) to generate **SPA** (alternatively, you can also use any popular front-end framework) and [embed](https://pkg.go.dev/embed) it with [Go](https://go.dev) into a single binary file.

📦 The final repository is hosted at [Github](https://github.com/aprakasa/go-embed-spa).

## Table Of Contents

- [Video Version](#video-version)
- [Initialize SvelteKit](#initialize-sveltekit)
- [Configure Adapter Static](#configure-adapter-static)
- [Add Page Routes](#add-page-routes)
- [Build The Static Files](#build-the-static-files)
- [Initialize Go](#initialize-go)
- [Embed The Static Files](#embed-the-static-files)
- [Serve with Go HTTP Library](#serve-with-go-http-Library)
- [Handling The Not-Found Route](#handling-the-not-found-route)
- [Serve with Echo Framework](#serve-with-echo-framework)
- [Serve with Fiber Framework](#serve-with-fiber-framework)
- [Containerize with Docker](#containerize-with-docker)
- [Add Makefile](#add-makefile)

### Video Version<a name="video-version"></a>

{% embed https://www.youtube.com/watch?v=HSZ-1tOt7uk %}

### Initialize SvelteKit<a name="initialize-sveltekit"></a>

> 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:

```bash
mkdir go-embed-spa && cd go-embed-spa
```

To [scaffold SvelteKit](https://kit.svelte.dev/docs/introduction#getting-started) inside the root of the project directory within the frontend folder, run the command below:

```bash
npm init svelte@next frontend
```

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
```

The initial project structure:

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

### Configure Adapter Static <a name="configure-adapter-static"></a>

SvelteKit has many [adapters](https://kit.svelte.dev/docs/adapters) for building the application. But in this case, we'll use the [static adapter](https://github.com/sveltejs/kit/tree/master/packages/adapter-static). So at the `package.json` file between "devDependencies", replace the adapter from `adapter-auto` to `adapter-static`.

```bash
- "@sveltejs/adapter-auto": "next", // delete this line
+ "@sveltejs/adapter-static": "next", // add this line
```

`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`

```js
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;
```

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

Install all the dependencies by running:

```bash
cd frontend && npm install
```

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

```bash
npm install --prefix ./frontend
```

### Add Page Routes <a name="add-page-routes"></a>

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

```html
<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>
```

`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**.

```html
<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>
```

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

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

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

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

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

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

<slot />
```

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

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

```html
<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>
```

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

### Build The Static Files <a name="build-the-static-files"></a>

To generate the static files, run this command below:

```bash
npm run build
# or
npm run build --prefix ./frontend
# if not inside the frontend directory
```

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/
```

### Initialize Go<a name="initialize-go"></a>

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

```bash
go mod init github.com/${YOUR_USERNAME}/go-embed-spa
```

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

### Embed The Static Files<a name="embed-the-static-files"></a>

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

```go
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)
}
```

`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](https://go.dev/tour/basics/3).
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](https://pkg.go.dev/io/fs) library with a method of [Sub](https://pkg.go.dev/io/fs#Sub) will get the subtree of the embedded files. So we can use the `build` directory as a root.

### Serve with Go HTTP Library<a name="serve-with-go-http-Library"></a>

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

```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)
}
```

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:

```bash
go build ./cmd/http
```

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

```bash
./http
# or
./http.exe
# for windows user
```

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

### Handling The Not-Found Route<a name="handling-the-not-found-route"></a>

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:

```go
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)
}
```

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

```bash
npm run build --prefix ./frontend && go build ./cmd/http
```

Run the application from the binary with:

```bash
./http
```

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<a name="serve-with-echo-framework"></a>

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

```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",
	})
}
```

Handling the _not-found_ route and creating API _end-point_ is more simpler with [Echo Framework](https://echo.labstack.com/). Just need to set **true** for **HTML5** from the static config middleware.

### Serve with Fiber Framework<a name="serve-with-fiber-framework"></a>

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

```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"})
}
```

[Fiber Framework](https://gofiber.io/) 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<a name="containerize-with-docker"></a>

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

```dockerfile
# 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"]
```

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
// 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"))))
```

Don't forget to import the `os` library.

### Add Makefile<a name="add-makefile"></a>

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

```makefile
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
```

Now we can build the docker image with:
```bash
make build # for net/http libary
make build APP_NAME=echo # for echo libary
make build APP_NAME=fiber # for fiber libary
```

To run the image we can run the command with:
```bash
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
```
As a result, we have a tiny little single-page application combined with the HTTP server.

![Docker images](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/epibg70twhmkeivp7ko5.png)